- Implement new commands for marking combat cold and pushing interest arcs - Update InterestDirector to manage forced cold combat states for peers - Introduce frequency adjustments for scoring based on recent arc representation - Refactor scoring weights to include combat urgency and frequency penalties - Increment version to 0.28.38 in mod.json and scoring-model.json
732 lines
23 KiB
C#
732 lines
23 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Soft ~70/30 event-led vs character-led mix with decaying novelty penalties,
|
|
/// hard FixedDwell beat cooldowns, consecutive arc drop-off, and a rolling
|
|
/// rarity window (common arcs lose score; rare/absent arcs gain score).
|
|
/// </summary>
|
|
public static class InterestVariety
|
|
{
|
|
public static float EventLedTarget => InterestScoringConfig.W.eventLedTarget;
|
|
public static int MixWindow => Mathf.Max(1, InterestScoringConfig.W.mixWindow);
|
|
public static float HardCooldownSeconds => InterestScoringConfig.W.hardCooldownSeconds;
|
|
public static float FixedDwellBeatCooldownSeconds =>
|
|
Mathf.Max(HardCooldownSeconds, InterestScoringConfig.W.fixedDwellBeatCooldownSeconds);
|
|
|
|
private static readonly List<InterestLeadKind> RecentMix = new List<InterestLeadKind>(32);
|
|
private static readonly List<string> RecentArcs = new List<string>(32);
|
|
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);
|
|
|
|
private static string _lastArcKey = "";
|
|
private static int _arcStreak;
|
|
|
|
/// <summary>Whether the last <see cref="Pick"/> saw both pools non-empty (for mix metering).</summary>
|
|
public static bool LastPickHadBothPools { get; private set; }
|
|
|
|
/// <summary>Harness/debug: last shown variety arc key.</summary>
|
|
public static string LastArcKey => _lastArcKey ?? "";
|
|
|
|
/// <summary>Harness/debug: consecutive shows of <see cref="LastArcKey"/>.</summary>
|
|
public static int ArcStreak => _arcStreak;
|
|
|
|
/// <summary>Harness/debug: tips recorded in the rarity window.</summary>
|
|
public static int ArcWindowCount => RecentArcs.Count;
|
|
|
|
public static void Clear()
|
|
{
|
|
RecentMix.Clear();
|
|
RecentArcs.Clear();
|
|
CooldownUntil.Clear();
|
|
LastPickHadBothPools = false;
|
|
_lastArcKey = "";
|
|
_arcStreak = 0;
|
|
}
|
|
|
|
/// <summary>Harness: push an arc into the rarity window without a full selection.</summary>
|
|
public static void HarnessPushArc(string arcKey, int times = 1)
|
|
{
|
|
if (string.IsNullOrEmpty(arcKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
int n = Mathf.Max(1, times);
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
PushArc(arcKey);
|
|
}
|
|
}
|
|
|
|
/// <summary>Harness: seed the rolling mix meter without touching cooldowns.</summary>
|
|
public static void HarnessSeedMix(int eventLedCount, int characterLedCount)
|
|
{
|
|
RecentMix.Clear();
|
|
int e = Mathf.Max(0, eventLedCount);
|
|
int c = Mathf.Max(0, characterLedCount);
|
|
for (int i = 0; i < e; i++)
|
|
{
|
|
RecentMix.Add(InterestLeadKind.EventLed);
|
|
}
|
|
|
|
for (int i = 0; i < c; i++)
|
|
{
|
|
RecentMix.Add(InterestLeadKind.CharacterLed);
|
|
}
|
|
|
|
while (RecentMix.Count > MixWindow)
|
|
{
|
|
RecentMix.RemoveAt(0);
|
|
}
|
|
}
|
|
|
|
public static void NoteSelection(InterestCandidate chosen)
|
|
{
|
|
NoteSelection(chosen, updateMix: LastPickHadBothPools);
|
|
}
|
|
|
|
public static void NoteSelection(InterestCandidate chosen, bool updateMix)
|
|
{
|
|
if (chosen == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
NoteVarietyArc(chosen);
|
|
|
|
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);
|
|
}
|
|
|
|
// Moment beats: hard suppress the same story on the same subject.
|
|
if (IsMomentBeat(chosen))
|
|
{
|
|
float beatUntil = Time.unscaledTime + FixedDwellBeatCooldownSeconds;
|
|
foreach (string beatKey in EnumerateBeatKeys(chosen))
|
|
{
|
|
StampCooldown(beatKey, beatUntil);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Score to subtract when this candidate matches the last shown variety arc.
|
|
/// After one duel show, the next duel pays <c>repeatArcPenaltyPer</c>; a lover resets the streak.
|
|
/// </summary>
|
|
public static float RepeatArcPenalty(InterestCandidate c)
|
|
{
|
|
if (c == null || _arcStreak <= 0 || string.IsNullOrEmpty(_lastArcKey))
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
string arc = VarietyArcKey(c);
|
|
if (string.IsNullOrEmpty(arc)
|
|
|| !string.Equals(arc, _lastArcKey, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
float per = w.repeatArcPenaltyPer > 0f ? w.repeatArcPenaltyPer : 24f;
|
|
float cap = w.repeatArcPenaltyCap > 0f ? w.repeatArcPenaltyCap : 72f;
|
|
return Mathf.Min(cap, _arcStreak * per);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rolling rarity: arcs over-represented in the recent tip window lose score;
|
|
/// absent/rare arcs gain score. Combat as a family gets an extra monopoly penalty.
|
|
/// </summary>
|
|
public static float FrequencyAdjust(InterestCandidate c)
|
|
{
|
|
if (c == null || RecentArcs.Count == 0)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
string arc = VarietyArcKey(c);
|
|
if (string.IsNullOrEmpty(arc))
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
int window = Mathf.Max(4, w.arcFrequencyWindow > 0 ? w.arcFrequencyWindow : 16);
|
|
int sample = Mathf.Min(RecentArcs.Count, window);
|
|
if (sample <= 0)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
int arcCount = 0;
|
|
int combatCount = 0;
|
|
int start = RecentArcs.Count - sample;
|
|
for (int i = start; i < RecentArcs.Count; i++)
|
|
{
|
|
string a = RecentArcs[i];
|
|
if (string.Equals(a, arc, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
arcCount++;
|
|
}
|
|
|
|
if (IsCombatArc(a))
|
|
{
|
|
combatCount++;
|
|
}
|
|
}
|
|
|
|
float share = arcCount / (float)sample;
|
|
float fair = w.arcFairShare > 0.02f ? w.arcFairShare : 0.12f;
|
|
float overMax = w.arcOverSharePenaltyMax > 0f ? w.arcOverSharePenaltyMax : 48f;
|
|
float underMax = w.arcUnderShareBoostMax > 0f ? w.arcUnderShareBoostMax : 22f;
|
|
|
|
float adjust = 0f;
|
|
if (share > fair)
|
|
{
|
|
float t = Mathf.Clamp01((share - fair) / Mathf.Max(0.05f, 1f - fair));
|
|
adjust -= t * overMax;
|
|
}
|
|
else
|
|
{
|
|
float t = Mathf.Clamp01((fair - share) / fair);
|
|
adjust += t * underMax;
|
|
}
|
|
|
|
if (IsCombatArc(arc))
|
|
{
|
|
float combatShare = combatCount / (float)sample;
|
|
float combatTarget = w.arcCombatShareTarget > 0.05f ? w.arcCombatShareTarget : 0.35f;
|
|
float combatPen = w.arcCombatOverSharePenaltyMax > 0f ? w.arcCombatOverSharePenaltyMax : 36f;
|
|
if (combatShare > combatTarget)
|
|
{
|
|
float t = Mathf.Clamp01((combatShare - combatTarget) / Mathf.Max(0.05f, 1f - combatTarget));
|
|
adjust -= t * combatPen;
|
|
}
|
|
}
|
|
|
|
return adjust;
|
|
}
|
|
|
|
private static bool IsCombatArc(string arc) =>
|
|
!string.IsNullOrEmpty(arc)
|
|
&& (arc.StartsWith("arc:combat:", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(arc, "arc:war", StringComparison.OrdinalIgnoreCase));
|
|
|
|
/// <summary>
|
|
/// Class-level arc for repeat drop-off (not per unit / pair id).
|
|
/// Duels share one arc so back-to-back scraps lose score; Mass/Battle share another.
|
|
/// </summary>
|
|
public static string VarietyArcKey(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
if (c.Completion == InterestCompletionKind.WarFront
|
|
|| string.Equals(c.AssetId, "live_war", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "arc:war";
|
|
}
|
|
|
|
if (string.Equals(c.Category, "Forage", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(c.AssetId, "building_consume_fruit", StringComparison.OrdinalIgnoreCase)
|
|
|| (!string.IsNullOrEmpty(c.Label)
|
|
&& c.Label.IndexOf("foraging", StringComparison.OrdinalIgnoreCase) >= 0))
|
|
{
|
|
return "arc:forage";
|
|
}
|
|
|
|
if (c.Completion == InterestCompletionKind.PlotActive
|
|
|| string.Equals(c.Category, "Plot", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "arc:plot";
|
|
}
|
|
|
|
if (c.Completion == InterestCompletionKind.StatusOutbreak
|
|
|| string.Equals(c.AssetId, "live_outbreak", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "arc:outbreak";
|
|
}
|
|
|
|
if (c.Completion == InterestCompletionKind.CombatActive
|
|
|| string.Equals(c.Category, "Combat", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(c.AssetId, "live_combat", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(c.Verb, "fighting", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (string.Equals(c.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "arc:combat:multi";
|
|
}
|
|
|
|
ScoringWeights w = InterestScoringConfig.W;
|
|
int sideSum = Mathf.Max(0, c.CombatSideACount) + Mathf.Max(0, c.CombatSideBCount);
|
|
int size = Mathf.Max(Mathf.Max(0, c.ParticipantCount), sideSum);
|
|
int duelMax = Mathf.Max(1, w.duelMaxFighters);
|
|
if (size > 0 && size <= duelMax)
|
|
{
|
|
return "arc:combat:duel";
|
|
}
|
|
|
|
return "arc:combat:multi";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId))
|
|
{
|
|
string h = c.HappinessEffectId.Trim().ToLowerInvariant();
|
|
if (h.IndexOf("death_", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("mourn", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("despair", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("grief", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return "arc:life:grief";
|
|
}
|
|
|
|
if (h.IndexOf("lover", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("love", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("married", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("wedding", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return "arc:life:love";
|
|
}
|
|
|
|
return "arc:hap:" + h;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.StatusId))
|
|
{
|
|
return "arc:status:" + c.StatusId.Trim().ToLowerInvariant();
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
|
if (!string.IsNullOrEmpty(asset) && !asset.StartsWith("live_", StringComparison.Ordinal))
|
|
{
|
|
if (string.Equals(c.Category, "Spectacle", StringComparison.OrdinalIgnoreCase)
|
|
|| asset.StartsWith("cast_", StringComparison.Ordinal)
|
|
|| asset.StartsWith("summon_", StringComparison.Ordinal))
|
|
{
|
|
return "arc:spectacle";
|
|
}
|
|
|
|
if (string.Equals(c.Category, "Politics", StringComparison.OrdinalIgnoreCase)
|
|
|| (c.Label != null
|
|
&& c.Label.IndexOf("decides to", StringComparison.OrdinalIgnoreCase) >= 0))
|
|
{
|
|
return "arc:decision";
|
|
}
|
|
|
|
return "arc:asset:" + asset;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.Category))
|
|
{
|
|
return "arc:cat:" + c.Category.Trim().ToLowerInvariant();
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.Verb))
|
|
{
|
|
return "arc:verb:" + c.Verb.Trim().ToLowerInvariant();
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
private static void NoteVarietyArc(InterestCandidate chosen)
|
|
{
|
|
string arc = VarietyArcKey(chosen);
|
|
if (string.IsNullOrEmpty(arc))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(arc, _lastArcKey, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_arcStreak = Mathf.Max(1, _arcStreak + 1);
|
|
}
|
|
else
|
|
{
|
|
_lastArcKey = arc;
|
|
_arcStreak = 1;
|
|
}
|
|
|
|
PushArc(arc);
|
|
}
|
|
|
|
private static void PushArc(string arc)
|
|
{
|
|
if (string.IsNullOrEmpty(arc))
|
|
{
|
|
return;
|
|
}
|
|
|
|
RecentArcs.Add(arc);
|
|
int window = Mathf.Max(4, InterestScoringConfig.W.arcFrequencyWindow > 0
|
|
? InterestScoringConfig.W.arcFrequencyWindow
|
|
: 16);
|
|
while (RecentArcs.Count > window)
|
|
{
|
|
RecentArcs.RemoveAt(0);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when a FixedDwell / moment beat for this candidate is still hard-cooled.
|
|
/// Sticky combat / war / plot / pack / outbreak scenes are never beat-blocked.
|
|
/// </summary>
|
|
public static bool IsBeatCooled(InterestCandidate c, float now = -1f)
|
|
{
|
|
if (c == null || !IsMomentBeat(c))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (now < 0f)
|
|
{
|
|
now = Time.unscaledTime;
|
|
}
|
|
|
|
foreach (string beatKey in EnumerateBeatKeys(c))
|
|
{
|
|
if (CooldownUntil.TryGetValue(beatKey, out float until) && now < until)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
// Same life beat on the same subject already watched - hard skip.
|
|
if (IsBeatCooled(c, now))
|
|
{
|
|
InterestDropLog.Record("beat_cooldown", BeatDropDetail(c));
|
|
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 soft novelty - never FixedDwell beat cooldowns
|
|
// (those already hard-skipped above).
|
|
if (InterestScoring.IsHotScore(c.TotalScore) || 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 bool IsMomentBeat(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
switch (c.Completion)
|
|
{
|
|
case InterestCompletionKind.FixedDwell:
|
|
case InterestCompletionKind.Manual:
|
|
case InterestCompletionKind.HappinessGrief:
|
|
// Discrete life / emotion beats (parenthood, grief, catalog moments).
|
|
return true;
|
|
case InterestCompletionKind.StatusPhase:
|
|
// Unit status tips cool by status id; sticky outbreaks are excluded below.
|
|
return string.IsNullOrEmpty(c.AssetId)
|
|
|| !c.AssetId.Equals("live_outbreak", System.StringComparison.OrdinalIgnoreCase);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> EnumerateBeatKeys(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
long subject = c.SubjectId;
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId))
|
|
{
|
|
yield return "beat:hap:" + c.HappinessEffectId + ":" + subject;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.StatusId))
|
|
{
|
|
yield return "beat:status:" + c.StatusId + ":" + subject;
|
|
}
|
|
|
|
// Discrete catalog asset ids (not species / not sticky live_* frames).
|
|
string asset = c.AssetId ?? "";
|
|
if (!string.IsNullOrEmpty(asset)
|
|
&& !asset.StartsWith("live_", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(asset, c.SpeciesId, StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(asset, "scored_unit", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(asset, "harness", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
yield return "beat:asset:" + asset + ":" + subject;
|
|
}
|
|
|
|
string labelNorm = NormalizeBeatLabel(c.Label);
|
|
if (!string.IsNullOrEmpty(labelNorm))
|
|
{
|
|
yield return "beat:label:" + labelNorm + ":" + subject;
|
|
}
|
|
}
|
|
|
|
private static string NormalizeBeatLabel(string label)
|
|
{
|
|
if (string.IsNullOrEmpty(label))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string t = label.Trim().ToLowerInvariant();
|
|
// Drop leading proper name so "Ukalo feels the joy…" and rebuilds share a beat.
|
|
int space = t.IndexOf(' ');
|
|
if (space > 0 && space < t.Length - 1)
|
|
{
|
|
string rest = t.Substring(space + 1).Trim();
|
|
if (rest.StartsWith("feels ", StringComparison.Ordinal)
|
|
|| rest.StartsWith("has ", StringComparison.Ordinal)
|
|
|| rest.StartsWith("is ", StringComparison.Ordinal)
|
|
|| rest.StartsWith("becomes ", StringComparison.Ordinal)
|
|
|| rest.StartsWith("gains ", StringComparison.Ordinal)
|
|
|| rest.StartsWith("decides ", StringComparison.Ordinal))
|
|
{
|
|
t = rest;
|
|
}
|
|
}
|
|
|
|
return t.Length > 64 ? t.Substring(0, 64) : t;
|
|
}
|
|
|
|
private static string BeatDropDetail(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return "beat";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId))
|
|
{
|
|
return c.HappinessEffectId;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.StatusId))
|
|
{
|
|
return c.StatusId;
|
|
}
|
|
|
|
return c.AssetId ?? c.Key ?? "beat";
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|