- Prioritize MCs, related cast, meaningful disasters, and exceptional events - Show MC connections in the dossier when following related characters - Promote rare creatures and recurring antagonists through bounded scoring - Reduce roster churn and repetitive love/combat coverage - Add performance telemetry, soak auditing, documentation, and harness coverage
2399 lines
82 KiB
C#
2399 lines
82 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);
|
|
public static float EditorialReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 24f);
|
|
public static float ExactReplayCooldownSeconds => Mathf.Max(HardCooldownSeconds, 90f);
|
|
public static float RoutineReplayCooldownSeconds =>
|
|
Mathf.Max(FixedDwellBeatCooldownSeconds, 120f);
|
|
|
|
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 bool IsCombatRunBlocked(InterestCandidate candidate, int maxRun = 4)
|
|
{
|
|
if (candidate == null
|
|
|| candidate.Completion != InterestCompletionKind.CombatActive
|
|
|| RecentArcs.Count < maxRun)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
NarrativeFunction function = NarrativeFunctionPolicy.Classify(candidate);
|
|
string text = ((candidate.AssetId ?? "") + " "
|
|
+ (candidate.Verb ?? "") + " "
|
|
+ (candidate.Label ?? "")).ToLowerInvariant();
|
|
bool decisive = function == NarrativeFunction.TurningPoint
|
|
|| function == NarrativeFunction.Resolution
|
|
|| function == NarrativeFunction.Consequence
|
|
|| candidate.EventStrength >= 95f
|
|
|| text.IndexOf("kill", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("slain", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("victory", StringComparison.Ordinal) >= 0;
|
|
if (decisive)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
int start = RecentArcs.Count - maxRun;
|
|
for (int i = start; i < RecentArcs.Count; i++)
|
|
{
|
|
if (!IsCombatArc(RecentArcs[i]))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public static bool HarnessProbeStabilityBudgets(out string detail)
|
|
{
|
|
Clear();
|
|
var texture = new InterestCandidate
|
|
{
|
|
SubjectId = 991101,
|
|
AssetId = "find_lover",
|
|
Verb = "decides to find a lover",
|
|
Label = "Probe decides to find a lover",
|
|
Completion = InterestCompletionKind.ActivityActive
|
|
};
|
|
NoteSelection(texture, updateMix: false);
|
|
bool textureBlocked = IsBeatCooled(texture);
|
|
bool loverStillBlockedAtTenMinutes =
|
|
IsBeatCooled(texture, Time.unscaledTime + 600f);
|
|
bool loverEventuallyReopens =
|
|
!IsBeatCooled(texture, Time.unscaledTime + 901f);
|
|
|
|
Clear();
|
|
HarnessPushArc("arc:combat:duel", 4);
|
|
var combat = new InterestCandidate
|
|
{
|
|
SubjectId = 991102,
|
|
AssetId = "live_combat",
|
|
Label = "Duel - Probe A vs Probe B",
|
|
Completion = InterestCompletionKind.CombatActive,
|
|
EventStrength = 80f
|
|
};
|
|
bool combatBlocked = IsCombatRunBlocked(combat);
|
|
combat.Label = "Probe A kills Probe B";
|
|
combat.Verb = "kills";
|
|
bool decisiveAllowed = !IsCombatRunBlocked(combat);
|
|
Clear();
|
|
|
|
detail = "textureBlocked=" + textureBlocked
|
|
+ " lover10m=" + loverStillBlockedAtTenMinutes
|
|
+ " loverReopens=" + loverEventuallyReopens
|
|
+ " combatBlocked=" + combatBlocked
|
|
+ " decisiveAllowed=" + decisiveAllowed;
|
|
return textureBlocked
|
|
&& loverStillBlockedAtTenMinutes
|
|
&& loverEventuallyReopens
|
|
&& combatBlocked
|
|
&& decisiveAllowed;
|
|
}
|
|
|
|
public static void Clear()
|
|
{
|
|
RecentMix.Clear();
|
|
RecentArcs.Clear();
|
|
CooldownUntil.Clear();
|
|
LastPickHadBothPools = false;
|
|
_lastArcKey = "";
|
|
_arcStreak = 0;
|
|
CameraDirector.ClearReplayLedger();
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
string textureFamily = LowInformationTextureFamily(chosen);
|
|
if (!string.IsNullOrEmpty(textureFamily) && chosen.SubjectId != 0)
|
|
{
|
|
float textureCooldown = string.Equals(
|
|
textureFamily,
|
|
"seeking_love",
|
|
StringComparison.Ordinal)
|
|
? 900f
|
|
: RoutineReplayCooldownSeconds;
|
|
StampCooldown(
|
|
"texture:" + chosen.SubjectId + ":" + textureFamily,
|
|
Time.unscaledTime + textureCooldown);
|
|
}
|
|
|
|
// Moment beats: hard suppress the same story on the same subject.
|
|
if (IsMomentBeat(chosen))
|
|
{
|
|
float beatSeconds = FixedDwellBeatCooldownSeconds;
|
|
if (IsSoftLifeChapter(chosen))
|
|
{
|
|
float softCool = InterestScoringConfig.Story.familyLifeBeatCooldownSeconds;
|
|
if (softCool > beatSeconds)
|
|
{
|
|
beatSeconds = softCool;
|
|
}
|
|
}
|
|
|
|
float beatUntil = Time.unscaledTime + beatSeconds;
|
|
foreach (string beatKey in EnumerateBeatKeys(chosen))
|
|
{
|
|
StampCooldown(beatKey, beatUntil);
|
|
}
|
|
|
|
}
|
|
|
|
// Sticky scenes are allowed to remain on screen while live, but once the editor
|
|
// leaves one it must not immediately re-announce the identical phase/card.
|
|
// The organic story-first soak otherwise produced Duel A-vs-B and "A is fighting"
|
|
// several times in succession, plus repeated unchanged family-pack cards.
|
|
string replayKey = EditorialReplayKey(chosen);
|
|
if (!string.IsNullOrEmpty(replayKey))
|
|
{
|
|
StampCooldown(
|
|
replayKey,
|
|
Time.unscaledTime + EditorialReplayCooldownSeconds);
|
|
}
|
|
|
|
string routineKey = RoutineReplayKey(chosen);
|
|
if (!string.IsNullOrEmpty(routineKey))
|
|
{
|
|
StampCooldown(
|
|
routineKey,
|
|
Time.unscaledTime + RoutineReplayCooldownSeconds);
|
|
}
|
|
|
|
string exactKey = ExactReplayKey(chosen);
|
|
if (!string.IsNullOrEmpty(exactKey))
|
|
{
|
|
StampCooldown(
|
|
exactKey,
|
|
Time.unscaledTime + ExactReplayCooldownSeconds);
|
|
}
|
|
|
|
// Soft life chapters cool ledger heat so the same villagers do not monopolize.
|
|
// Soft-life cool no longer demotes saga MCs (CharacterLedger removed).
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the exact sticky editorial card was shown recently. This is deliberately
|
|
/// separate from moment-beat cooling: a live scene may continue, but cannot be selected
|
|
/// again as a fresh cut until another editorial beat has had room to breathe.
|
|
/// </summary>
|
|
public static bool IsEditorialReplayCooled(InterestCandidate c, float now = -1f)
|
|
{
|
|
string key = EditorialReplayKey(c);
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (now < 0f)
|
|
{
|
|
now = Time.unscaledTime;
|
|
}
|
|
|
|
return CooldownUntil.TryGetValue(key, out float until) && now < until;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prevent an identical player-facing sentence from being presented as a fresh cut,
|
|
/// even when separate hooks generated different candidate keys.
|
|
/// </summary>
|
|
public static bool IsExactReplayCooled(InterestCandidate c, float now = -1f)
|
|
{
|
|
string key = ExactReplayKey(c);
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
return false;
|
|
}
|
|
if (now < 0f) now = Time.unscaledTime;
|
|
return CooldownUntil.TryGetValue(key, out float until) && now < until;
|
|
}
|
|
|
|
public static bool HarnessProbeEditorialReplay(out string detail)
|
|
{
|
|
Clear();
|
|
var first = new InterestCandidate
|
|
{
|
|
Key = "combat:pair:1:2",
|
|
Label = "Duel - Alpha vs Beta",
|
|
Completion = InterestCompletionKind.CombatActive
|
|
};
|
|
NoteSelection(first, updateMix: false);
|
|
var replay = new InterestCandidate
|
|
{
|
|
Key = "combat:pair:1:2:refresh",
|
|
Label = "Mass - Beta (3) vs Alpha (5)",
|
|
Completion = InterestCompletionKind.CombatActive
|
|
};
|
|
var different = new InterestCandidate
|
|
{
|
|
Key = "combat:pair:1:3",
|
|
Label = "Duel - Alpha vs Gamma",
|
|
Completion = InterestCompletionKind.CombatActive
|
|
};
|
|
var texture = new InterestCandidate
|
|
{
|
|
Key = "status:sleeping:1",
|
|
Label = "Alpha · Falls asleep",
|
|
Completion = InterestCompletionKind.StatusPhase,
|
|
StatusId = "sleeping"
|
|
};
|
|
bool sameCooled = IsEditorialReplayCooled(replay);
|
|
bool structuralExactCooled = IsExactReplayCooled(replay);
|
|
bool differentOpen = !IsEditorialReplayCooled(different);
|
|
bool textureUsesBeatPolicy = !IsEditorialReplayCooled(texture);
|
|
var life = new InterestCandidate
|
|
{
|
|
Key = "relationship:1:2:first",
|
|
Label = "Norron mates with Onaano",
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
NoteSelection(life, updateMix: false);
|
|
var lifeReplay = new InterestCandidate
|
|
{
|
|
Key = "relationship:1:2:second",
|
|
Label = "Norron mates with Onaano",
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
var lifeProgress = new InterestCandidate
|
|
{
|
|
Key = "relationship:1:2:home",
|
|
Label = "Norron finds a home",
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
bool exactLifeCooled = IsExactReplayCooled(lifeReplay);
|
|
bool progressedLifeOpen = !IsExactReplayCooled(lifeProgress);
|
|
bool ok = sameCooled && structuralExactCooled
|
|
&& differentOpen && textureUsesBeatPolicy
|
|
&& exactLifeCooled && progressedLifeOpen;
|
|
detail = "sameCooled=" + sameCooled
|
|
+ " structuralExact=" + structuralExactCooled
|
|
+ " differentOpen=" + differentOpen
|
|
+ " textureUsesBeatPolicy=" + textureUsesBeatPolicy
|
|
+ " exactLife=" + exactLifeCooled
|
|
+ " progressedLife=" + progressedLifeOpen;
|
|
Clear();
|
|
return ok;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Routine texture may recur in the simulation, but the editor should not rediscover the
|
|
/// same person's sleep/play/laugh/status beat every short cooldown cycle.
|
|
/// </summary>
|
|
public static bool IsRoutineReplayCooled(InterestCandidate c, float now = -1f)
|
|
{
|
|
string key = RoutineReplayKey(c);
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (now < 0f)
|
|
{
|
|
now = Time.unscaledTime;
|
|
}
|
|
|
|
return CooldownUntil.TryGetValue(key, out float until) && now < until;
|
|
}
|
|
|
|
public static bool HarnessProbeRoutineReplay(out string detail)
|
|
{
|
|
Clear();
|
|
var sleeping = new InterestCandidate
|
|
{
|
|
SubjectId = 41,
|
|
StatusId = "sleeping",
|
|
AssetId = "sleeping",
|
|
Completion = InterestCompletionKind.StatusPhase,
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.CharacterTexture
|
|
};
|
|
NoteSelection(sleeping, updateMix: false);
|
|
var same = new InterestCandidate
|
|
{
|
|
SubjectId = 41,
|
|
StatusId = "sleeping",
|
|
AssetId = "sleeping",
|
|
Completion = InterestCompletionKind.StatusPhase,
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.CharacterTexture
|
|
};
|
|
var other = new InterestCandidate
|
|
{
|
|
SubjectId = 42,
|
|
StatusId = "sleeping",
|
|
AssetId = "sleeping",
|
|
Completion = InterestCompletionKind.StatusPhase,
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.CharacterTexture
|
|
};
|
|
var love = new InterestCandidate
|
|
{
|
|
SubjectId = 41,
|
|
HappinessEffectId = "fallen_in_love",
|
|
HasNarrativeFunction = true,
|
|
NarrativeFunction = NarrativeFunction.ThreadOpener
|
|
};
|
|
bool sameCooled = IsRoutineReplayCooled(same);
|
|
bool otherOpen = !IsRoutineReplayCooled(other);
|
|
bool storyOpen = !IsRoutineReplayCooled(love);
|
|
bool ok = sameCooled && otherOpen && storyOpen;
|
|
detail = "same=" + sameCooled + " other=" + otherOpen
|
|
+ " story=" + storyOpen + " pass=" + ok;
|
|
Clear();
|
|
return ok;
|
|
}
|
|
|
|
public static bool HarnessProbeFamilyCoalescing(out string detail)
|
|
{
|
|
Clear();
|
|
var birthFact = new InterestCandidate
|
|
{
|
|
SubjectId = 41,
|
|
RelatedId = 43,
|
|
AssetId = "add_child",
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
NoteSelection(birthFact, updateMix: false);
|
|
|
|
var sameParentReaction = new InterestCandidate
|
|
{
|
|
SubjectId = 41,
|
|
HappinessEffectId = "just_had_child",
|
|
AssetId = "just_had_child",
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
var otherParentReaction = new InterestCandidate
|
|
{
|
|
SubjectId = 42,
|
|
HappinessEffectId = "just_had_child",
|
|
AssetId = "just_had_child",
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
|
|
bool echoCooled = IsBeatCooled(sameParentReaction);
|
|
bool unrelatedOpen = !IsBeatCooled(otherParentReaction);
|
|
bool ok = echoCooled && unrelatedOpen;
|
|
detail = "echo=" + echoCooled
|
|
+ " unrelated=" + unrelatedOpen
|
|
+ " pass=" + ok;
|
|
Clear();
|
|
return ok;
|
|
}
|
|
|
|
/// <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 (StoryReason.IsStoryAsset(c.AssetId)
|
|
|| string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(c.Category, "Story", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (!string.IsNullOrEmpty(c.AssetId)
|
|
&& c.AssetId.StartsWith("epilogue_", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "arc:life:epilogue";
|
|
}
|
|
|
|
return "arc:life:aftermath";
|
|
}
|
|
|
|
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 (IsKillLifeHappinessId(h))
|
|
{
|
|
return "arc:life:kill";
|
|
}
|
|
|
|
if (h == "just_got_out_of_egg")
|
|
{
|
|
return "arc:life:hatch";
|
|
}
|
|
|
|
if (IsIntimacyHappinessId(h))
|
|
{
|
|
return "arc:life:intimacy";
|
|
}
|
|
|
|
if (IsCourtshipHappinessId(h)
|
|
|| 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";
|
|
}
|
|
|
|
if (IsFamilyLifeHappinessId(h))
|
|
{
|
|
return "arc:life:family";
|
|
}
|
|
|
|
if (IsSettlementHappinessId(h))
|
|
{
|
|
return "arc:life:home";
|
|
}
|
|
|
|
if (IsRestLifeId(h))
|
|
{
|
|
return "arc:life:rest";
|
|
}
|
|
|
|
return "arc:hap:" + h;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.StatusId))
|
|
{
|
|
if (IsFamilyLifeHappinessId(c.StatusId))
|
|
{
|
|
return "arc:life:family";
|
|
}
|
|
|
|
if (IsSettlementHappinessId(c.StatusId))
|
|
{
|
|
return "arc:life:home";
|
|
}
|
|
|
|
if (IsCourtshipHappinessId(c.StatusId))
|
|
{
|
|
return "arc:life:love";
|
|
}
|
|
|
|
if (IsRestLifeId(c.StatusId))
|
|
{
|
|
return "arc:life:rest";
|
|
}
|
|
|
|
if (IsSoftStatusFxId(c.StatusId))
|
|
{
|
|
return "arc:status:fx";
|
|
}
|
|
|
|
return "arc:status:" + c.StatusId.Trim().ToLowerInvariant();
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
|
if (!string.IsNullOrEmpty(asset) && !asset.StartsWith("live_", StringComparison.Ordinal))
|
|
{
|
|
if (IsKillLifeAssetId(asset))
|
|
{
|
|
return "arc:life:kill";
|
|
}
|
|
|
|
if (IsCourtshipAssetId(asset))
|
|
{
|
|
return "arc:life:love";
|
|
}
|
|
|
|
if (string.Equals(c.Category, "Spectacle", StringComparison.OrdinalIgnoreCase)
|
|
|| asset.StartsWith("cast_", StringComparison.Ordinal)
|
|
|| asset.StartsWith("summon_", StringComparison.Ordinal))
|
|
{
|
|
return "arc:spectacle";
|
|
}
|
|
|
|
if (EventCatalog.Decision.IsLifeIntentDecision(asset)
|
|
|| IsLifeIntentDecisionChapter(c))
|
|
{
|
|
return "arc:decision:intent";
|
|
}
|
|
|
|
if (IsRestLifeId(asset) || IsRestLifeChapter(c))
|
|
{
|
|
return "arc:life:rest";
|
|
}
|
|
|
|
if (IsWorldLogMetaAssetId(asset) || IsWorldLogMetaChapter(c))
|
|
{
|
|
return "arc:worldlog:meta";
|
|
}
|
|
|
|
if (IsConstructionChapter(c))
|
|
{
|
|
return "arc:construction";
|
|
}
|
|
|
|
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 (IsLifeIntentDecisionChapter(c))
|
|
{
|
|
return "arc:decision:intent";
|
|
}
|
|
|
|
if (IsRestLifeChapter(c))
|
|
{
|
|
return "arc:life:rest";
|
|
}
|
|
|
|
if (IsWorldLogMetaChapter(c))
|
|
{
|
|
return "arc:worldlog:meta";
|
|
}
|
|
|
|
if (IsConstructionChapter(c))
|
|
{
|
|
return "arc:construction";
|
|
}
|
|
|
|
if (IsSoftStatusFxChapter(c))
|
|
{
|
|
return "arc:status:fx";
|
|
}
|
|
|
|
if (IsHatchLifeChapter(c))
|
|
{
|
|
return "arc:life:hatch";
|
|
}
|
|
|
|
if (IsKillLifeChapter(c))
|
|
{
|
|
return "arc:life:kill";
|
|
}
|
|
|
|
if (IsCourtshipLifeChapter(c))
|
|
{
|
|
return "arc:life:love";
|
|
}
|
|
|
|
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)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (now < 0f)
|
|
{
|
|
now = Time.unscaledTime;
|
|
}
|
|
|
|
string textureFamily = LowInformationTextureFamily(c);
|
|
if (!string.IsNullOrEmpty(textureFamily)
|
|
&& c.SubjectId != 0
|
|
&& CooldownUntil.TryGetValue(
|
|
"texture:" + c.SubjectId + ":" + textureFamily,
|
|
out float textureUntil)
|
|
&& now < textureUntil)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!IsMomentBeat(c))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Synthetic story beats finish the active arc - never hard-skip them for FixedDwell cool.
|
|
// Soft novelty / repeat-arc penalties still apply via RecalcTotal.
|
|
if (StoryReason.IsStoryAsset(c.AssetId)
|
|
|| string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (string beatKey in EnumerateBeatKeys(c))
|
|
{
|
|
if (CooldownUntil.TryGetValue(beatKey, out float until) && now < until)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string LowInformationTextureFamily(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string text = ((c.AssetId ?? "") + " "
|
|
+ (c.HappinessEffectId ?? "") + " "
|
|
+ (c.StatusId ?? "") + " "
|
|
+ (c.Verb ?? "") + " "
|
|
+ (c.Label ?? "")).ToLowerInvariant();
|
|
if (text.IndexOf("sleep", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("nap", StringComparison.Ordinal) >= 0)
|
|
return "rest";
|
|
if (text.IndexOf("laugh", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("play", StringComparison.Ordinal) >= 0)
|
|
return "play";
|
|
if (text.IndexOf("caffeine", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("coffee", StringComparison.Ordinal) >= 0)
|
|
return "caffeine";
|
|
if (text.IndexOf("find_lover", StringComparison.Ordinal) >= 0
|
|
|| text.IndexOf("find a lover", StringComparison.Ordinal) >= 0)
|
|
return "seeking_love";
|
|
if (text.IndexOf("wander", StringComparison.Ordinal) >= 0)
|
|
return "wandering";
|
|
return "";
|
|
}
|
|
|
|
/// <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 (IsEditorialReplayCooled(c, now))
|
|
{
|
|
InterestDropLog.Record("editorial_replay", c.Label ?? c.Key);
|
|
continue;
|
|
}
|
|
|
|
if (IsExactReplayCooled(c, now))
|
|
{
|
|
InterestDropLog.Record("exact_replay", c.Label ?? c.Key);
|
|
continue;
|
|
}
|
|
|
|
if (IsRoutineReplayCooled(c, now))
|
|
{
|
|
InterestDropLog.Record("routine_replay", c.Label ?? c.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 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);
|
|
|
|
// Prefer active story-owned beats when present.
|
|
for (int i = 1; i < Ranked.Count; i++)
|
|
{
|
|
if (StoryPlanner.PreferOver(Ranked[i], Ranked[0]))
|
|
{
|
|
InterestCandidate owned = Ranked[i];
|
|
Ranked.RemoveAt(i);
|
|
Ranked.Insert(0, owned);
|
|
break;
|
|
}
|
|
}
|
|
|
|
int topN = Mathf.Min(3, Ranked.Count);
|
|
if (topN <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Deterministic #1 unless near-tie among top scores.
|
|
if (topN == 1)
|
|
{
|
|
return Ranked[0];
|
|
}
|
|
|
|
float epsilon = InterestScoringConfig.Story.nearTieEpsilon;
|
|
if (epsilon <= 0f)
|
|
{
|
|
epsilon = 8f;
|
|
}
|
|
|
|
float sagaEpsilon = InterestScoringConfig.Story.sagaNearTieEpsilon;
|
|
if (sagaEpsilon <= 0f)
|
|
{
|
|
sagaEpsilon = 20f;
|
|
}
|
|
|
|
sagaEpsilon = Mathf.Max(sagaEpsilon, epsilon);
|
|
|
|
// Large gap: take #1 unless a Prefer/MC/cast tip sits within the wider saga band.
|
|
if (Ranked[0].TotalScore - Ranked[1].TotalScore >= epsilon)
|
|
{
|
|
InterestCandidate sagaCut = PickBestSagaInBand(Ranked, Ranked.Count, sagaEpsilon);
|
|
if (sagaCut != null)
|
|
{
|
|
return sagaCut;
|
|
}
|
|
|
|
return Ranked[0];
|
|
}
|
|
|
|
// Near-tie: Prefer'd MC, then any MC, then MC cast, else random among top-N.
|
|
InterestCandidate bestSaga = PickBestSagaInBand(Ranked, topN, epsilon);
|
|
if (bestSaga != null)
|
|
{
|
|
return bestSaga;
|
|
}
|
|
|
|
int pick = Rng.Next(topN);
|
|
return Ranked[pick];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prefer (4) > CrossMC (3) > MC (2) > MC cast (1) among candidates within <paramref name="band"/> of #1.
|
|
/// </summary>
|
|
private static InterestCandidate PickBestSagaInBand(
|
|
List<InterestCandidate> ranked,
|
|
int limit,
|
|
float band)
|
|
{
|
|
if (ranked == null || ranked.Count == 0 || band < 0f)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
int n = Mathf.Min(limit, ranked.Count);
|
|
float top = ranked[0].TotalScore;
|
|
InterestCandidate bestSaga = null;
|
|
int bestRank = 0;
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
InterestCandidate c = ranked[i];
|
|
if (c == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (top - c.TotalScore >= band)
|
|
{
|
|
break;
|
|
}
|
|
|
|
int rank = LifeSagaRoster.SoftBiasRank(c);
|
|
if (rank > bestRank)
|
|
{
|
|
bestRank = rank;
|
|
bestSaga = c;
|
|
}
|
|
}
|
|
|
|
return bestRank > 0 ? bestSaga : null;
|
|
}
|
|
|
|
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 string EditorialReplayKey(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return "";
|
|
}
|
|
EnsureReplayKeys(c);
|
|
return c.CachedEditorialReplayKey;
|
|
}
|
|
|
|
private static string BuildEditorialReplayKey(
|
|
InterestCandidate c,
|
|
string normalizedLabel)
|
|
{
|
|
|
|
bool stickyCard = c.Completion == InterestCompletionKind.CombatActive
|
|
|| c.Completion == InterestCompletionKind.WarFront
|
|
|| c.Completion == InterestCompletionKind.PlotActive
|
|
|| c.Completion == InterestCompletionKind.StatusOutbreak
|
|
|| c.Completion == InterestCompletionKind.FamilyPack
|
|
|| StoryReason.IsStoryAsset(c.AssetId)
|
|
|| string.Equals(c.Source, "story_planner", StringComparison.OrdinalIgnoreCase);
|
|
if (!stickyCard)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(normalizedLabel))
|
|
{
|
|
return "replay:label:" + normalizedLabel;
|
|
}
|
|
|
|
string key = (c.Key ?? "").Trim().ToLowerInvariant();
|
|
return string.IsNullOrEmpty(key) ? "" : "replay:key:" + key;
|
|
}
|
|
|
|
private static string ExactReplayKey(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return "";
|
|
}
|
|
EnsureReplayKeys(c);
|
|
return c.CachedExactReplayKey;
|
|
}
|
|
|
|
private static string RoutineReplayKey(InterestCandidate c)
|
|
{
|
|
if (c == null || c.SubjectId == 0)
|
|
{
|
|
return "";
|
|
}
|
|
EnsureReplayKeys(c);
|
|
return c.CachedRoutineReplayKey;
|
|
}
|
|
|
|
private static string BuildRoutineReplayKey(InterestCandidate c)
|
|
{
|
|
|
|
NarrativeFunction function = NarrativeFunctionPolicy.Classify(c);
|
|
if (function != NarrativeFunction.CharacterTexture
|
|
&& !IsRestLifeChapter(c)
|
|
&& !IsSoftStatusFxChapter(c))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string family = c.StatusId;
|
|
if (string.IsNullOrEmpty(family)) family = c.HappinessEffectId;
|
|
if (string.IsNullOrEmpty(family)) family = c.NarrativePresentation?.ActionId;
|
|
if (string.IsNullOrEmpty(family)) family = c.AssetId;
|
|
if (string.IsNullOrEmpty(family)) family = NormalizeBeatLabel(c.Label);
|
|
family = (family ?? "").Trim().ToLowerInvariant();
|
|
return string.IsNullOrEmpty(family)
|
|
? ""
|
|
: "routine:" + c.SubjectId + ":" + family;
|
|
}
|
|
|
|
private static void EnsureReplayKeys(InterestCandidate c)
|
|
{
|
|
string label = c.Label ?? "";
|
|
string key = c.Key ?? "";
|
|
string assetId = c.AssetId ?? "";
|
|
string source = c.Source ?? "";
|
|
string statusId = c.StatusId ?? "";
|
|
string happinessId = c.HappinessEffectId ?? "";
|
|
string actionId = c.NarrativePresentation?.ActionId ?? "";
|
|
if (c.ReplayKeysCached
|
|
&& c.ReplayCacheSubjectId == c.SubjectId
|
|
&& c.ReplayCacheCompletion == c.Completion
|
|
&& c.ReplayCacheFunction == c.NarrativeFunction
|
|
&& c.ReplayCacheHasFunction == c.HasNarrativeFunction
|
|
&& string.Equals(c.ReplayCacheLabel, label, StringComparison.Ordinal)
|
|
&& string.Equals(c.ReplayCacheKey, key, StringComparison.Ordinal)
|
|
&& string.Equals(c.ReplayCacheAssetId, assetId, StringComparison.Ordinal)
|
|
&& string.Equals(c.ReplayCacheSource, source, StringComparison.Ordinal)
|
|
&& string.Equals(c.ReplayCacheStatusId, statusId, StringComparison.Ordinal)
|
|
&& string.Equals(
|
|
c.ReplayCacheHappinessEffectId, happinessId, StringComparison.Ordinal)
|
|
&& string.Equals(c.ReplayCacheActionId, actionId, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
c.ReplayKeysCached = true;
|
|
c.ReplayCacheLabel = label;
|
|
c.ReplayCacheKey = key;
|
|
c.ReplayCacheAssetId = assetId;
|
|
c.ReplayCacheSource = source;
|
|
c.ReplayCacheStatusId = statusId;
|
|
c.ReplayCacheHappinessEffectId = happinessId;
|
|
c.ReplayCacheActionId = actionId;
|
|
c.ReplayCacheSubjectId = c.SubjectId;
|
|
c.ReplayCacheCompletion = c.Completion;
|
|
c.ReplayCacheFunction = c.NarrativeFunction;
|
|
c.ReplayCacheHasFunction = c.HasNarrativeFunction;
|
|
|
|
string normalizedLabel = EventReason.ReplayStructureKey(label);
|
|
c.CachedEditorialReplayKey = BuildEditorialReplayKey(c, normalizedLabel);
|
|
c.CachedExactReplayKey = string.IsNullOrEmpty(normalizedLabel)
|
|
? ""
|
|
: "exact:label:" + normalizedLabel;
|
|
c.CachedRoutineReplayKey = BuildRoutineReplayKey(c);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parenthood / pregnancy / newborn family chapter - variety + ledger cool as a class.
|
|
/// </summary>
|
|
public static bool IsFamilyLifeChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId)
|
|
&& IsFamilyLifeHappinessId(c.HappinessEffectId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.StatusId)
|
|
&& IsFamilyLifeHappinessId(c.StatusId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
|
return asset.IndexOf("child", StringComparison.Ordinal) >= 0
|
|
|| asset.IndexOf("pregnant", StringComparison.Ordinal) >= 0
|
|
|| asset.IndexOf("birth", StringComparison.Ordinal) >= 0
|
|
|| asset.IndexOf("baby", StringComparison.Ordinal) >= 0
|
|
|| asset.IndexOf("newborn", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Kiss / mate / intimacy chapter - same soft-life cool class as family (pair + world).
|
|
/// </summary>
|
|
public static bool IsIntimacyLifeChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId)
|
|
&& IsIntimacyHappinessId(c.HappinessEffectId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.StatusId)
|
|
&& IsIntimacyHappinessId(c.StatusId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
|
if (IsIntimacyHappinessId(asset))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
|
return label.IndexOf("shares intimacy", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("mates with", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// House-found settlement chapter - repeats easily and steals between combat beats.
|
|
/// (House-lost stays camera-worthy without the same soft cool.)
|
|
/// </summary>
|
|
public static bool IsSettlementLifeChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId)
|
|
&& IsSettlementHappinessId(c.HappinessEffectId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.StatusId)
|
|
&& IsSettlementHappinessId(c.StatusId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
|
if (IsSettlementHappinessId(asset))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
|
return label.IndexOf("finds a home", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("settles into a house", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Chronicle / happiness kill crumbs - repeat easily for hunters between combat tips.
|
|
/// </summary>
|
|
public static bool IsKillLifeChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId)
|
|
&& IsKillLifeHappinessId(c.HappinessEffectId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
|
if (IsKillLifeAssetId(asset))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.Key)
|
|
&& c.Key.IndexOf("milestone_kill", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
|
return label.StartsWith("killed ", StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Smitten / seeking-lover courtship crumbs (not bond outcomes like set_lover).
|
|
/// </summary>
|
|
public static bool IsCourtshipLifeChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId)
|
|
&& IsCourtshipHappinessId(c.HappinessEffectId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.StatusId)
|
|
&& IsCourtshipHappinessId(c.StatusId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
|
if (IsCourtshipAssetId(asset))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
|
return label.IndexOf("is smitten", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("falls in love", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("seeking a lover", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("is seeking a lover", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decision intent crumbs (reproduce / find lover) - outcomes own the real story.
|
|
/// </summary>
|
|
public static bool IsLifeIntentDecisionChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim();
|
|
if (EventCatalog.Decision.IsLifeIntentDecision(asset))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Live decision feed: Source is authoritative even when AssetId was species-clobbered.
|
|
if (string.Equals(c.Source, "decision", StringComparison.OrdinalIgnoreCase)
|
|
&& (EventCatalog.Decision.IsLifeIntentDecision(asset)
|
|
|| asset.IndexOf("reproduction", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| asset.IndexOf("find_lover", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| asset.IndexOf("baby_make", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| asset.IndexOf("have_child", StringComparison.OrdinalIgnoreCase) >= 0))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// Label-only rebuilds still cool when AssetId was lost.
|
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
|
return label.IndexOf("decides to try to reproduce", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("decides to find a lover", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("decides to reproduce", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ordinary civ construction finishes (tents/houses). Spectacle wonders stay hotter.
|
|
/// </summary>
|
|
public static bool IsConstructionChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string asset = c.AssetId ?? "";
|
|
bool buildingSource = string.Equals(c.Source, "building", StringComparison.OrdinalIgnoreCase)
|
|
|| (!string.IsNullOrEmpty(c.Key)
|
|
&& c.Key.StartsWith(
|
|
"building:complete:",
|
|
StringComparison.OrdinalIgnoreCase));
|
|
if (buildingSource)
|
|
{
|
|
return !LibraryAssetNames.IsSpectacleBuilding(asset, null);
|
|
}
|
|
|
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
|
if (label.IndexOf(" finishes ", StringComparison.Ordinal) < 0
|
|
&& !label.StartsWith("building finished", StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Spectacle prose uses "wonder".
|
|
return label.IndexOf("wonder", StringComparison.Ordinal) < 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Brief personal status FX (singing, rooting, moods) - not danger/combat statuses.
|
|
/// </summary>
|
|
public static bool IsSoftStatusFxChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string status = !string.IsNullOrEmpty(c.StatusId) ? c.StatusId : c.AssetId;
|
|
if (string.IsNullOrEmpty(status))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (c.Completion != InterestCompletionKind.StatusPhase
|
|
&& c.Completion != InterestCompletionKind.FixedDwell
|
|
&& string.IsNullOrEmpty(c.StatusId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return IsSoftStatusFxId(status);
|
|
}
|
|
|
|
/// <summary>Egg hatch crumbs - common and steals between richer beats.</summary>
|
|
public static bool IsHatchLifeChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId)
|
|
&& string.Equals(c.HappinessEffectId, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string asset = (c.AssetId ?? "").Trim().ToLowerInvariant();
|
|
if (asset == "just_got_out_of_egg")
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
|
return label.IndexOf("hatches from an egg", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// WorldLog politics meta crumbs (king death, city destroyed, favorite fell).
|
|
/// Sticky and re-fire easily; outcomes stay in Chronicle.
|
|
/// </summary>
|
|
public static bool IsWorldLogMetaChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool worldLog = string.Equals(c.Source, "worldlog", StringComparison.OrdinalIgnoreCase)
|
|
|| (!string.IsNullOrEmpty(c.Key)
|
|
&& c.Key.StartsWith("worldlog:", StringComparison.OrdinalIgnoreCase));
|
|
if (!worldLog && string.IsNullOrEmpty(c.AssetId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (IsWorldLogMetaAssetId(c.AssetId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
|
return label.StartsWith("king died:", StringComparison.Ordinal)
|
|
|| label.StartsWith("king killed:", StringComparison.Ordinal)
|
|
|| label.StartsWith("favorite fell:", StringComparison.Ordinal)
|
|
|| label.StartsWith("city destroyed:", StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Family + intimacy + settlement + kill + courtship + intent/construction/status/hatch/meta/rest.
|
|
/// </summary>
|
|
public static bool IsSoftLifeChapter(InterestCandidate c) =>
|
|
IsFamilyLifeChapter(c)
|
|
|| IsIntimacyLifeChapter(c)
|
|
|| IsSettlementLifeChapter(c)
|
|
|| IsKillLifeChapter(c)
|
|
|| IsCourtshipLifeChapter(c)
|
|
|| IsLifeIntentDecisionChapter(c)
|
|
|| IsWorldLogMetaChapter(c)
|
|
|| IsConstructionChapter(c)
|
|
|| IsSoftStatusFxChapter(c)
|
|
|| IsHatchLifeChapter(c)
|
|
|| IsRestLifeChapter(c);
|
|
|
|
/// <summary>
|
|
/// Sleep / wake / dream cycle crumbs from live status + happiness inventory shape.
|
|
/// One show cools the whole rest class so Falls asleep → ends a rest → dream cannot monopolize.
|
|
/// </summary>
|
|
public static bool IsRestLifeChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (IsRestLifeId(c.HappinessEffectId)
|
|
|| IsRestLifeId(c.StatusId)
|
|
|| IsRestLifeId(c.AssetId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string label = (c.Label ?? "").Trim().ToLowerInvariant();
|
|
return label.IndexOf("falls asleep", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("wakes up", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("ends a rest", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("finishes sleeping", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("dreams pleasantly", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("good dream", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("bad dream", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("nightmare", StringComparison.Ordinal) >= 0
|
|
|| label.IndexOf("sleep without a house", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rest-cycle ids: <c>sleeping</c>, <c>just_slept</c>, <c>had_*dream</c>, <c>had_nightmare</c>,
|
|
/// <c>slept_outside</c> - token shape from live libraries, not tip strings alone.
|
|
/// </summary>
|
|
public static bool IsRestLifeId(string id)
|
|
{
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string s = id.Trim().ToLowerInvariant();
|
|
return s.IndexOf("sleep", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("slept", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("dream", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("nightmare", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Death / destruction WorldLog ids from the live library shape
|
|
/// (king_*, favorite_*, city_destroyed, …) - not crowning / founding.
|
|
/// </summary>
|
|
public static bool IsWorldLogMetaAssetId(string assetId)
|
|
{
|
|
if (string.IsNullOrEmpty(assetId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string s = assetId.Trim().ToLowerInvariant();
|
|
if (s.IndexOf("destroyed", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
bool deathLike = s.IndexOf("dead", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("killed", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("_fell", StringComparison.Ordinal) >= 0;
|
|
if (!deathLike)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return s.IndexOf("king", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("leader", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("favorite", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-fill quiet crumbs: soft life chapters, short interrupt FX, and ambient Pack fill.
|
|
/// Pack must not own the camera during quiet (and no longer breaks quiet).
|
|
/// </summary>
|
|
public static bool IsSoftFillQuietCrumb(InterestCandidate c) =>
|
|
c != null
|
|
&& (c.Completion == InterestCompletionKind.FamilyPack
|
|
|| IsSoftLifeChapter(c)
|
|
|| IsShortInterruptFxChapter(c));
|
|
|
|
/// <summary>
|
|
/// Brief interrupt FX that should not own AFK during soft-fill quiet.
|
|
/// Kept non-outbreak; real danger statuses stay hot.
|
|
/// </summary>
|
|
public static bool IsShortInterruptFxChapter(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string status = !string.IsNullOrEmpty(c.StatusId) ? c.StatusId : c.AssetId;
|
|
if (string.IsNullOrEmpty(status))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (c.Completion != InterestCompletionKind.StatusPhase
|
|
&& c.Completion != InterestCompletionKind.FixedDwell
|
|
&& string.IsNullOrEmpty(c.StatusId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return IsShortInterruptFxId(status);
|
|
}
|
|
|
|
public static bool IsShortInterruptFxId(string statusId)
|
|
{
|
|
if (string.IsNullOrEmpty(statusId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string s = statusId.Trim().ToLowerInvariant();
|
|
return s.IndexOf("stun", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("invincible", StringComparison.Ordinal) >= 0
|
|
|| s == "surprised"
|
|
|| s == "confused";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft personal status FX ids from the live status inventory shape:
|
|
/// mood / life-cycle / brief FX. Danger and combat statuses stay hotter.
|
|
/// </summary>
|
|
public static bool IsSoftStatusFxId(string statusId)
|
|
{
|
|
if (string.IsNullOrEmpty(statusId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Short interrupt FX count as soft crumbs for quiet + variety cooling.
|
|
if (IsShortInterruptFxId(statusId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string s = statusId.Trim().ToLowerInvariant();
|
|
// Danger / combat / possession stay camera-sticky for repeats.
|
|
if (s.IndexOf("burn", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("poison", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("drown", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("starv", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("possess", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("soul", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("curse", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("rage", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("frozen", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("shield", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("combat", StringComparison.Ordinal) >= 0
|
|
|| s == "on_guard"
|
|
|| s == "angry"
|
|
|| s == "egg")
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Family / courtship / settlement already have dedicated soft chapters.
|
|
if (IsFamilyLifeHappinessId(s) || IsCourtshipHappinessId(s) || IsSettlementHappinessId(s)
|
|
|| IsIntimacyHappinessId(s))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Mood / life-cycle / brief FX tokens (covers unauthored live ids of the same shape).
|
|
return s.IndexOf("sing", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("laugh", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("swear", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("cry", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("root", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("budding", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("dream", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("nightmare", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("festive", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("afterglow", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("surprised", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("confused", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("just_ate", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("caffeine", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("cough", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("tantrum", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("strange_urge", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("voices", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("handsome", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("flick", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("ash_fever", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("motivated", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("inspired", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("enchant", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("powerup", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("slowness", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("magnet", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("dodge", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("dash", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("silence", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("boost", StringComparison.Ordinal) >= 0
|
|
|| s.IndexOf("recovery_", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
private static bool IsFamilyLifeHappinessId(string happinessId)
|
|
{
|
|
if (string.IsNullOrEmpty(happinessId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string h = happinessId.Trim().ToLowerInvariant();
|
|
return h == "just_had_child"
|
|
|| h.IndexOf("pregnant", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("newborn", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("birth", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("had_child", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("had_baby", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("parenthood", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
private static bool IsIntimacyHappinessId(string happinessId)
|
|
{
|
|
if (string.IsNullOrEmpty(happinessId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string h = happinessId.Trim().ToLowerInvariant();
|
|
return h == "just_kissed"
|
|
|| h.IndexOf("kissed", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("intimacy", StringComparison.Ordinal) >= 0
|
|
|| h == "mates"
|
|
|| h.StartsWith("mate_", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsSettlementHappinessId(string happinessId)
|
|
{
|
|
if (string.IsNullOrEmpty(happinessId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string h = happinessId.Trim().ToLowerInvariant();
|
|
return h == "just_found_house"
|
|
|| h.IndexOf("found_house", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("find_house", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("found_home", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
private static bool IsKillLifeHappinessId(string happinessId)
|
|
{
|
|
if (string.IsNullOrEmpty(happinessId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string h = happinessId.Trim().ToLowerInvariant();
|
|
return h == "just_killed"
|
|
|| h == "milestone_kill"
|
|
|| h.IndexOf("just_killed", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
private static bool IsKillLifeAssetId(string assetId)
|
|
{
|
|
if (string.IsNullOrEmpty(assetId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string a = assetId.Trim().ToLowerInvariant();
|
|
return a == "milestone_kill"
|
|
|| a == "just_killed";
|
|
}
|
|
|
|
private static bool IsCourtshipHappinessId(string happinessId)
|
|
{
|
|
if (string.IsNullOrEmpty(happinessId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string h = happinessId.Trim().ToLowerInvariant();
|
|
// Bond outcomes (set_lover / milestone_lover) stay hotter; cool intent + smitten only.
|
|
return h == "fallen_in_love"
|
|
|| h == "find_lover"
|
|
|| h == "fell_in_love"
|
|
|| h.IndexOf("fallen_in_love", StringComparison.Ordinal) >= 0
|
|
|| h.IndexOf("find_lover", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
private static bool IsCourtshipAssetId(string assetId)
|
|
{
|
|
if (string.IsNullOrEmpty(assetId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string a = assetId.Trim().ToLowerInvariant();
|
|
return a == "fallen_in_love"
|
|
|| a == "find_lover"
|
|
|| a == "fell_in_love";
|
|
}
|
|
|
|
private static IEnumerable<string> EnumerateBeatKeys(InterestCandidate c)
|
|
{
|
|
if (c == null)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
long subject = c.SubjectId;
|
|
long related = c.RelatedId != 0
|
|
? c.RelatedId
|
|
: EventFeedUtil.SafeId(c.RelatedUnit);
|
|
if (!string.IsNullOrEmpty(c.HappinessEffectId))
|
|
{
|
|
yield return "beat:hap:" + c.HappinessEffectId + ":" + subject;
|
|
// Family chapters cool the happiness id world-wide so parent A then parent B
|
|
// cannot chain the same parenthood tip across the village cast.
|
|
if (IsFamilyLifeHappinessId(c.HappinessEffectId))
|
|
{
|
|
yield return "beat:hap:" + c.HappinessEffectId + ":world";
|
|
yield return "beat:life:family:" + subject;
|
|
}
|
|
|
|
// Intimacy: cool the pair and the happiness id world-wide so one couple
|
|
// cannot keep cutting back in between combat tips.
|
|
if (IsIntimacyHappinessId(c.HappinessEffectId))
|
|
{
|
|
yield return "beat:hap:" + c.HappinessEffectId + ":world";
|
|
yield return "beat:life:intimacy:" + subject;
|
|
if (related != 0)
|
|
{
|
|
yield return "beat:life:intimacy:" + related;
|
|
yield return "beat:life:intimacy:pair:"
|
|
+ EventCatalog.Combat.PairKey(subject, related);
|
|
}
|
|
}
|
|
|
|
if (IsSettlementHappinessId(c.HappinessEffectId))
|
|
{
|
|
yield return "beat:hap:" + c.HappinessEffectId + ":world";
|
|
yield return "beat:life:home:" + subject;
|
|
}
|
|
|
|
if (IsKillLifeHappinessId(c.HappinessEffectId))
|
|
{
|
|
yield return "beat:hap:" + c.HappinessEffectId + ":world";
|
|
yield return "beat:life:kill:" + subject;
|
|
yield return "beat:life:kill:world";
|
|
}
|
|
|
|
if (IsCourtshipHappinessId(c.HappinessEffectId))
|
|
{
|
|
yield return "beat:hap:" + c.HappinessEffectId + ":world";
|
|
yield return "beat:life:love:" + subject;
|
|
yield return "beat:life:love:world";
|
|
if (related != 0)
|
|
{
|
|
yield return "beat:life:love:" + related;
|
|
yield return "beat:life:love:pair:"
|
|
+ EventCatalog.Combat.PairKey(subject, related);
|
|
}
|
|
}
|
|
|
|
if (string.Equals(
|
|
c.HappinessEffectId,
|
|
"just_got_out_of_egg",
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
yield return "beat:hap:just_got_out_of_egg:world";
|
|
yield return "beat:life:hatch:" + subject;
|
|
yield return "beat:life:hatch:world";
|
|
}
|
|
|
|
if (IsRestLifeId(c.HappinessEffectId))
|
|
{
|
|
yield return "beat:hap:" + c.HappinessEffectId + ":world";
|
|
yield return "beat:life:rest:" + subject;
|
|
yield return "beat:life:rest:world";
|
|
}
|
|
|
|
// Happiness-path soft FX (dreams without StatusId bind) still cool as a class.
|
|
if (IsSoftStatusFxId(c.HappinessEffectId) && !IsRestLifeId(c.HappinessEffectId))
|
|
{
|
|
yield return "beat:hap:" + c.HappinessEffectId + ":world";
|
|
yield return "beat:status:fx:" + subject;
|
|
yield return "beat:status:fx:world";
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(c.StatusId))
|
|
{
|
|
yield return "beat:status:" + c.StatusId + ":" + subject;
|
|
if (IsFamilyLifeHappinessId(c.StatusId))
|
|
{
|
|
yield return "beat:status:" + c.StatusId + ":world";
|
|
}
|
|
|
|
if (IsIntimacyHappinessId(c.StatusId))
|
|
{
|
|
yield return "beat:status:" + c.StatusId + ":world";
|
|
yield return "beat:life:intimacy:" + subject;
|
|
}
|
|
|
|
if (IsCourtshipHappinessId(c.StatusId))
|
|
{
|
|
yield return "beat:status:" + c.StatusId + ":world";
|
|
yield return "beat:life:love:" + subject;
|
|
yield return "beat:life:love:world";
|
|
}
|
|
|
|
if (IsRestLifeId(c.StatusId))
|
|
{
|
|
yield return "beat:status:" + c.StatusId + ":world";
|
|
yield return "beat:life:rest:" + subject;
|
|
yield return "beat:life:rest:world";
|
|
}
|
|
|
|
if (IsSoftStatusFxId(c.StatusId) && !IsRestLifeId(c.StatusId))
|
|
{
|
|
yield return "beat:status:" + c.StatusId + ":world";
|
|
yield return "beat:status:fx:" + subject;
|
|
yield return "beat:status:fx:world";
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
if (IsKillLifeAssetId(asset))
|
|
{
|
|
yield return "beat:asset:" + asset + ":world";
|
|
yield return "beat:life:kill:" + subject;
|
|
yield return "beat:life:kill:world";
|
|
}
|
|
|
|
if (IsCourtshipAssetId(asset))
|
|
{
|
|
yield return "beat:asset:" + asset + ":world";
|
|
yield return "beat:life:love:" + subject;
|
|
yield return "beat:life:love:world";
|
|
}
|
|
|
|
// The relationship feed reports the concrete birth first ("gains a child");
|
|
// happiness can report the same parent's reaction a frame later. They are one
|
|
// family chapter, so showing the fact suppresses that immediate emotional echo
|
|
// without cooling unrelated parents or later births world-wide.
|
|
if (string.Equals(asset, "add_child", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
yield return "beat:life:family:" + subject;
|
|
}
|
|
|
|
if (EventCatalog.Decision.IsLifeIntentDecision(asset))
|
|
{
|
|
yield return "beat:asset:" + asset + ":world";
|
|
yield return "beat:decision:intent:" + subject;
|
|
yield return "beat:decision:intent:world";
|
|
}
|
|
|
|
if (IsRestLifeId(asset))
|
|
{
|
|
yield return "beat:asset:" + asset + ":world";
|
|
yield return "beat:life:rest:" + subject;
|
|
yield return "beat:life:rest:world";
|
|
}
|
|
|
|
if (IsWorldLogMetaAssetId(asset))
|
|
{
|
|
yield return "beat:asset:" + asset + ":world";
|
|
yield return "beat:worldlog:meta:" + subject;
|
|
yield return "beat:worldlog:meta:world";
|
|
}
|
|
|
|
if (string.Equals(asset, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
yield return "beat:asset:" + asset + ":world";
|
|
yield return "beat:life:hatch:" + subject;
|
|
yield return "beat:life:hatch:world";
|
|
}
|
|
}
|
|
|
|
// Chapter-level world keys (ungated): survive species AssetId / missing StatusId / label rebuilds.
|
|
if (IsLifeIntentDecisionChapter(c))
|
|
{
|
|
yield return "beat:decision:intent:" + subject;
|
|
yield return "beat:decision:intent:world";
|
|
}
|
|
|
|
if (IsRestLifeChapter(c))
|
|
{
|
|
yield return "beat:life:rest:" + subject;
|
|
yield return "beat:life:rest:world";
|
|
}
|
|
|
|
if (IsConstructionChapter(c))
|
|
{
|
|
yield return "beat:construction:" + subject;
|
|
yield return "beat:construction:world";
|
|
}
|
|
|
|
if (IsWorldLogMetaChapter(c) && string.IsNullOrEmpty(asset))
|
|
{
|
|
yield return "beat:worldlog:meta:" + subject;
|
|
yield return "beat:worldlog:meta:world";
|
|
}
|
|
|
|
string labelNorm = NormalizeBeatLabel(c.Label);
|
|
if (!string.IsNullOrEmpty(labelNorm))
|
|
{
|
|
yield return "beat:label:" + labelNorm + ":" + subject;
|
|
// Label-only soft chapters (missing HappinessEffectId) still cool as a class.
|
|
if (string.IsNullOrEmpty(c.HappinessEffectId))
|
|
{
|
|
if (IsIntimacyLifeChapter(c)
|
|
&& (labelNorm.IndexOf("intimacy", StringComparison.Ordinal) >= 0
|
|
|| labelNorm.IndexOf("mates with", StringComparison.Ordinal) >= 0))
|
|
{
|
|
yield return "beat:life:intimacy:" + subject;
|
|
yield return "beat:life:intimacy:world";
|
|
if (related != 0)
|
|
{
|
|
yield return "beat:life:intimacy:pair:"
|
|
+ EventCatalog.Combat.PairKey(subject, related);
|
|
}
|
|
}
|
|
|
|
if (IsSettlementLifeChapter(c)
|
|
&& (labelNorm.IndexOf("finds a home", StringComparison.Ordinal) >= 0
|
|
|| labelNorm.IndexOf("settles into", StringComparison.Ordinal) >= 0))
|
|
{
|
|
yield return "beat:life:home:" + subject;
|
|
yield return "beat:life:home:world";
|
|
}
|
|
|
|
if (IsKillLifeChapter(c)
|
|
&& labelNorm.StartsWith("killed ", StringComparison.Ordinal))
|
|
{
|
|
yield return "beat:life:kill:" + subject;
|
|
yield return "beat:life:kill:world";
|
|
}
|
|
|
|
if (IsCourtshipLifeChapter(c)
|
|
&& (labelNorm.IndexOf("smitten", StringComparison.Ordinal) >= 0
|
|
|| labelNorm.IndexOf("falls in love", StringComparison.Ordinal) >= 0
|
|
|| labelNorm.IndexOf("seeking a lover", StringComparison.Ordinal) >= 0))
|
|
{
|
|
yield return "beat:life:love:" + subject;
|
|
yield return "beat:life:love:world";
|
|
if (related != 0)
|
|
{
|
|
yield return "beat:life:love:pair:"
|
|
+ EventCatalog.Combat.PairKey(subject, related);
|
|
}
|
|
}
|
|
|
|
if (IsHatchLifeChapter(c)
|
|
&& labelNorm.IndexOf("hatches", StringComparison.Ordinal) >= 0)
|
|
{
|
|
yield return "beat:life:hatch:" + subject;
|
|
yield return "beat:life:hatch:world";
|
|
}
|
|
|
|
if (IsWorldLogMetaChapter(c))
|
|
{
|
|
yield return "beat:worldlog:meta:" + subject;
|
|
yield return "beat:worldlog:meta:world";
|
|
}
|
|
|
|
if (IsRestLifeChapter(c)
|
|
&& (labelNorm.IndexOf("asleep", StringComparison.Ordinal) >= 0
|
|
|| labelNorm.IndexOf("rest", StringComparison.Ordinal) >= 0
|
|
|| labelNorm.IndexOf("dream", StringComparison.Ordinal) >= 0
|
|
|| labelNorm.IndexOf("nightmare", StringComparison.Ordinal) >= 0
|
|
|| labelNorm.IndexOf("sleep", StringComparison.Ordinal) >= 0))
|
|
{
|
|
yield return "beat:life:rest:" + subject;
|
|
yield return "beat:life:rest:world";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
|| rest.StartsWith("shares ", StringComparison.Ordinal)
|
|
|| rest.StartsWith("mates ", StringComparison.Ordinal)
|
|
|| rest.StartsWith("mourns ", 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;
|
|
}
|
|
}
|
|
}
|