- Introduce new probes for combat lifecycle, scheduler bounds, and exposure - Implement narrative scheduler health probe in harness scenarios - Update InterestCandidate to include approved presentation details - Refactor EventPresentability to manage candidate approval and caching - Improve IdleHitchProbe to track performance metrics during narrative events
1401 lines
46 KiB
C#
1401 lines
46 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Converts live streams into <see cref="InterestRegistry"/> candidates.
|
|
/// Sole path for WorldLog / happiness / scanner / activity / status / chronicle → registry.
|
|
/// </summary>
|
|
public static class InterestFeeds
|
|
{
|
|
private static ulong _happinessCursor;
|
|
private static readonly List<HappinessOccurrence> HappinessDrain = new List<HappinessOccurrence>(64);
|
|
private static float _lastScannerAt = -999f;
|
|
private const float ScannerInterval = 1.25f;
|
|
private static int _tickParity;
|
|
public static float LastHappinessMs { get; private set; }
|
|
public static float LastScannerMs { get; private set; }
|
|
public static float LastBattleScanMs { get; private set; }
|
|
public static float LastWarFeedMs { get; private set; }
|
|
public static float LastPlotFeedMs { get; private set; }
|
|
public static float LastFamilyFeedMs { get; private set; }
|
|
public static float LastStatusFeedMs { get; private set; }
|
|
|
|
// Civic aggregate boosts keyed by kingdom+effect.
|
|
private static readonly Dictionary<string, float> CivicBoostUntil = new Dictionary<string, float>(32);
|
|
|
|
public static void Reset()
|
|
{
|
|
_happinessCursor = HappinessEventRouter.CurrentSequence;
|
|
HappinessDrain.Clear();
|
|
_lastScannerAt = -999f;
|
|
CivicBoostUntil.Clear();
|
|
InterestDropLog.Clear();
|
|
LastHappinessMs = 0f;
|
|
LastScannerMs = 0f;
|
|
LastBattleScanMs = 0f;
|
|
LastWarFeedMs = 0f;
|
|
LastPlotFeedMs = 0f;
|
|
LastFamilyFeedMs = 0f;
|
|
LastStatusFeedMs = 0f;
|
|
}
|
|
|
|
/// <summary>Keep drain cursor coherent when the happiness router sequence resets.</summary>
|
|
public static void OnHappinessRouterCleared()
|
|
{
|
|
_happinessCursor = HappinessEventRouter.CurrentSequence;
|
|
HappinessDrain.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Harness: if the router was cleared without syncing the feed cursor, rewind so
|
|
/// <see cref="HarnessDrainOnce"/> can see the new sequence.
|
|
/// </summary>
|
|
public static void HarnessEnsureCursorBefore(ulong sequence)
|
|
{
|
|
if (sequence == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_happinessCursor >= sequence)
|
|
{
|
|
_happinessCursor = sequence - 1;
|
|
}
|
|
}
|
|
|
|
public static ulong HappinessCursor => _happinessCursor;
|
|
|
|
public static int CivicBoostCount
|
|
{
|
|
get
|
|
{
|
|
int n = 0;
|
|
foreach (KeyValuePair<string, float> kv in CivicBoostUntil)
|
|
{
|
|
if (kv.Key != null && !kv.Key.EndsWith("|amt") && kv.Value > Time.unscaledTime)
|
|
{
|
|
n++;
|
|
}
|
|
}
|
|
|
|
return n;
|
|
}
|
|
}
|
|
|
|
public static bool HasCivicBoostContaining(string effectId)
|
|
{
|
|
if (string.IsNullOrEmpty(effectId))
|
|
{
|
|
return CivicBoostCount > 0;
|
|
}
|
|
|
|
foreach (KeyValuePair<string, float> kv in CivicBoostUntil)
|
|
{
|
|
if (kv.Key != null
|
|
&& !kv.Key.EndsWith("|amt")
|
|
&& kv.Key.IndexOf(effectId, System.StringComparison.OrdinalIgnoreCase) >= 0
|
|
&& kv.Value > Time.unscaledTime)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Harness: drain happiness once; returns how many occurrences were ingested.</summary>
|
|
public static int HarnessDrainOnce()
|
|
{
|
|
DrainHappiness();
|
|
return HappinessDrain.Count;
|
|
}
|
|
|
|
/// <summary>Harness: stamp a civic boost as AggregateSummary would.</summary>
|
|
public static void HarnessStampCivicBoost(string kingdom, string effectId, float amount)
|
|
{
|
|
StampCivicBoost(kingdom ?? "harness", effectId ?? "", amount);
|
|
}
|
|
|
|
public static void Tick()
|
|
{
|
|
long started = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
DrainHappiness();
|
|
LastHappinessMs = ElapsedMs(started);
|
|
PruneCivicBoosts();
|
|
LastScannerMs = 0f;
|
|
// Stagger battle/character scanner off the happiness drain frame.
|
|
if ((_tickParity++ & 1) == 0)
|
|
{
|
|
started = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
TickScanner();
|
|
LastScannerMs = ElapsedMs(started);
|
|
}
|
|
}
|
|
|
|
public static void OnWorldLogMessage(WorldLogMessage message)
|
|
{
|
|
if (message == null || !AgentHarness.LiveFeedsAllowed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(message.asset_id);
|
|
if (!EventCatalog.IsCameraWorthy(entry))
|
|
{
|
|
InterestDropLog.Record(
|
|
"createsInterest=false",
|
|
"worldlog:" + (message.asset_id ?? ""));
|
|
return;
|
|
}
|
|
|
|
float evtSeed = entry.EventStrength;
|
|
Vector3 position = message.getLocation();
|
|
// The message's unit is an explicit event participant even when the WorldLog asset
|
|
// suppresses vanilla follow-location UI (for example alien invasion). Preserve that
|
|
// ownership so camera-worthy events do not become anchorless and disappear.
|
|
Actor unit = message.unit;
|
|
|
|
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
|
|
string kingdom = message.special1 ?? "";
|
|
string key = "worldlog:" + assetId + ":" + kingdom;
|
|
float boost = PeekCivicBoost(kingdom, assetId);
|
|
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
|
|
string label = entry.MakeLabel(message);
|
|
|
|
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
|
|
{
|
|
EventFeedUtil.Register(
|
|
key,
|
|
"worldlog",
|
|
category,
|
|
label,
|
|
assetId,
|
|
evtSeed + boost,
|
|
pos,
|
|
follow,
|
|
minWatch: entry.MinWatch,
|
|
maxWatch: entry.MaxWatch);
|
|
return;
|
|
}
|
|
|
|
// Civic / disaster without a real unit: location-only (no stranger subject).
|
|
if (position != Vector3.zero && !float.IsNaN(position.x))
|
|
{
|
|
EventFeedUtil.Register(
|
|
key,
|
|
"worldlog",
|
|
category,
|
|
label,
|
|
assetId,
|
|
evtSeed + boost,
|
|
position,
|
|
follow: null,
|
|
locationOnly: true,
|
|
minWatch: entry.MinWatch,
|
|
maxWatch: entry.MaxWatch);
|
|
}
|
|
}
|
|
|
|
/// <summary>Legacy / discovery / harness enqueue path → registry.</summary>
|
|
public static InterestCandidate RegisterDirect(InterestEvent interest)
|
|
{
|
|
if (interest == null || !interest.HasValidPosition)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Actor unit = interest.FollowUnit;
|
|
long subjectId = SafeId(unit);
|
|
string asset = interest.AssetId ?? "";
|
|
float score = interest.Score;
|
|
bool hot = score >= InterestScoringConfig.W.noticeScoreMin || asset == "live_battle";
|
|
string key = "direct:" + subjectId + ":" + (interest.Label ?? "") + ":" + asset;
|
|
InterestLeadKind lead = hot ? InterestLeadKind.EventLed : InterestLeadKind.CharacterLed;
|
|
InterestCompletionKind completion = hot
|
|
? InterestCompletionKind.FixedDwell
|
|
: InterestCompletionKind.CharacterVignette;
|
|
|
|
if (asset == "live_battle")
|
|
{
|
|
key = "battle:hot:" + Mathf.FloorToInt(interest.Position.x) + ":" + Mathf.FloorToInt(interest.Position.y);
|
|
lead = InterestLeadKind.EventLed;
|
|
completion = InterestCompletionKind.CombatActive;
|
|
}
|
|
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = key,
|
|
LeadKind = lead,
|
|
Category = asset == "live_battle"
|
|
? "Combat"
|
|
: CategoryForLead(lead, completion, asset),
|
|
Source = "direct",
|
|
EventStrength = score > 0f ? score : 40f,
|
|
CharacterSignificance = interest.CharacterSignificance > 0f
|
|
? interest.CharacterSignificance
|
|
: (lead == InterestLeadKind.CharacterLed ? score * 0.5f : 0f),
|
|
VisualConfidence = unit != null ? 0.75f : 0.4f,
|
|
Position = interest.Position,
|
|
FollowUnit = unit,
|
|
SubjectId = subjectId,
|
|
Label = interest.Label ?? "",
|
|
AssetId = asset,
|
|
SpeciesId = unit?.asset != null ? unit.asset.id : asset,
|
|
ParticipantCount = interest.ParticipantCount,
|
|
NotableParticipantCount = interest.NotableParticipantCount,
|
|
Verb = asset == "live_battle" ? "fighting" : "",
|
|
CreatedAt = interest.CreatedAt > 0f ? interest.CreatedAt : Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + 30f,
|
|
MinWatch = InterestScoring.IsFillScore(score) ? 1.2f : 1.5f,
|
|
MaxWatch = InterestScoring.IsHotScore(score) ? 40f : 25f,
|
|
Completion = completion
|
|
};
|
|
InterestScoring.ScoreCheap(candidate);
|
|
return EventFeedUtil.RegisterCandidate(candidate);
|
|
}
|
|
|
|
public static InterestCandidate RegisterHarness(
|
|
Actor follow,
|
|
string label,
|
|
string keySuffix,
|
|
InterestLeadKind lead,
|
|
InterestCompletionKind completion,
|
|
bool forceActive,
|
|
float eventStrength,
|
|
float maxWatch)
|
|
{
|
|
return RegisterHarness(
|
|
follow,
|
|
label,
|
|
keySuffix,
|
|
lead,
|
|
completion,
|
|
forceActive,
|
|
eventStrength,
|
|
characterSignificance: lead == InterestLeadKind.CharacterLed
|
|
? UnityEngine.Mathf.Min(40f, eventStrength * 0.4f)
|
|
: 10f,
|
|
maxWatch,
|
|
ttlSeconds: 60f,
|
|
related: null,
|
|
resumable: true);
|
|
}
|
|
|
|
public static InterestCandidate RegisterHarness(
|
|
Actor follow,
|
|
string label,
|
|
string keySuffix,
|
|
InterestLeadKind lead,
|
|
InterestCompletionKind completion,
|
|
bool forceActive,
|
|
float eventStrength,
|
|
float characterSignificance,
|
|
float maxWatch,
|
|
float ttlSeconds,
|
|
Actor related,
|
|
bool resumable)
|
|
{
|
|
if (follow == null || !follow.isAlive())
|
|
{
|
|
return null;
|
|
}
|
|
|
|
long id = SafeId(follow);
|
|
string key = "harness:" + (keySuffix ?? "scene") + ":" + id;
|
|
float now = Time.unscaledTime;
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = key,
|
|
LeadKind = lead,
|
|
Category = CategoryForLead(lead, completion, ""),
|
|
Source = "harness",
|
|
EventStrength = eventStrength,
|
|
CharacterSignificance = characterSignificance,
|
|
VisualConfidence = 0.9f,
|
|
Position = follow.current_position,
|
|
FollowUnit = follow,
|
|
RelatedUnit = related,
|
|
SubjectId = id,
|
|
Label = label ?? "Harness",
|
|
AssetId = follow.asset != null ? follow.asset.id : "harness",
|
|
SpeciesId = follow.asset != null ? follow.asset.id : "",
|
|
CreatedAt = now,
|
|
LastSeenAt = now,
|
|
ExpiresAt = now + (ttlSeconds > 0f ? ttlSeconds : 60f),
|
|
MinWatch = 1f,
|
|
MaxWatch = maxWatch > 0f ? maxWatch : 20f,
|
|
Completion = completion,
|
|
ForceActive = forceActive,
|
|
Resumable = resumable
|
|
};
|
|
if (related != null)
|
|
{
|
|
try
|
|
{
|
|
candidate.RelatedId = related.getID();
|
|
}
|
|
catch
|
|
{
|
|
candidate.RelatedId = 0;
|
|
}
|
|
}
|
|
|
|
InterestScoring.ScoreCheap(candidate);
|
|
return EventFeedUtil.RegisterCandidate(candidate);
|
|
}
|
|
|
|
public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot)
|
|
{
|
|
if (actor == null || !actor.isAlive() || !AgentHarness.LiveFeedsAllowed || !hot)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Non-combat hot activity is not an event reason - task chip covers live job.
|
|
if (!actor.has_attack_target)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor foe = null;
|
|
try
|
|
{
|
|
if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor())
|
|
{
|
|
foe = actor.attack_target.a;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
foe = null;
|
|
}
|
|
|
|
string verb = taskOrBeat ?? "fighting";
|
|
WorldActivityScanner.PreferCombatFollow(actor, foe, out Actor follow, out Actor related);
|
|
Vector3 pos = follow != null ? follow.current_position : actor.current_position;
|
|
LiveEnsemble.TryBuildCombat(pos, 10f, out LiveEnsemble ensemble);
|
|
if (ensemble != null && ensemble.HasFocus)
|
|
{
|
|
// Multi camps may reframe focus; NamedPair keeps the first claimant.
|
|
if (ensemble.ParticipantCount > 2
|
|
|| LiveEnsemble.HasOpposingCollectiveSides(ensemble)
|
|
|| ensemble.Scale > EnsembleScale.Pair)
|
|
{
|
|
follow = ensemble.Focus;
|
|
related = ensemble.Related ?? related;
|
|
}
|
|
else
|
|
{
|
|
related = related ?? ensemble.Related;
|
|
}
|
|
}
|
|
|
|
Actor followActor = follow ?? actor;
|
|
long followId = SafeId(followActor);
|
|
string key = EventCatalog.Combat.KeyFor(followActor, related);
|
|
string label = ensemble != null
|
|
? EventReason.Combat(ensemble)
|
|
: EventReason.Fight(followActor, related);
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = key,
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Category = "Combat",
|
|
Source = "activity",
|
|
EventStrength = EventCatalog.Combat.ActivityStrength,
|
|
VisualConfidence = 0.8f,
|
|
Position = followActor != null ? followActor.current_position : actor.current_position,
|
|
FollowUnit = followActor,
|
|
RelatedUnit = related,
|
|
SubjectId = followId,
|
|
RelatedId = related != null ? SafeId(related) : 0,
|
|
Label = label,
|
|
AssetId = followActor.asset != null ? followActor.asset.id : "",
|
|
SpeciesId = followActor.asset != null ? followActor.asset.id : "",
|
|
Verb = verb,
|
|
CityKey = followActor.city != null ? (followActor.city.name ?? "") : "",
|
|
KingdomKey = followActor.kingdom != null ? (followActor.kingdom.name ?? "") : "",
|
|
ParticipantCount = ensemble != null ? Mathf.Max(1, ensemble.ParticipantCount) : (related != null ? 2 : 1),
|
|
NotableParticipantCount = ensemble != null ? ensemble.NotableParticipantCount : 0,
|
|
CreatedAt = Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
|
|
MinWatch = EventCatalog.Combat.MinWatch,
|
|
MaxWatch = EventCatalog.Combat.MaxWatch,
|
|
Completion = InterestCompletionKind.CombatActive
|
|
};
|
|
candidate.StampCombatPair(followActor, related);
|
|
if (ensemble != null)
|
|
{
|
|
WorldActivityScanner.StampParticipantIds(candidate, ensemble);
|
|
if (ensemble.Scale >= EnsembleScale.Skirmish)
|
|
{
|
|
candidate.AssetId = "live_battle";
|
|
}
|
|
|
|
if (ensemble.Frame == EnsembleFrame.KingdomVsKingdom
|
|
&& ensemble.SideA != null
|
|
&& !string.IsNullOrEmpty(ensemble.SideA.Key))
|
|
{
|
|
candidate.KingdomKey = ensemble.SideA.Key;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
candidate.ParticipantIds.Add(followId);
|
|
if (related != null)
|
|
{
|
|
long rid = SafeId(related);
|
|
if (rid != 0)
|
|
{
|
|
candidate.ParticipantIds.Add(rid);
|
|
}
|
|
}
|
|
}
|
|
|
|
InterestScoring.ScoreCheap(candidate);
|
|
EventFeedUtil.RegisterCandidate(candidate);
|
|
}
|
|
|
|
public static void OnStatusChange(Actor actor, string statusId, bool gained)
|
|
{
|
|
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(statusId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!AgentHarness.LiveFeedsAllowed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Egg timer end is the reliable hatch beat. Happiness just_got_out_of_egg is often
|
|
// suppressed (no emotions), so status loss owns the camera reason.
|
|
if (!gained && string.Equals(statusId.Trim(), "egg", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
EmitHatch(actor, "status_egg_loss");
|
|
return;
|
|
}
|
|
|
|
if (!gained)
|
|
{
|
|
InterestDropLog.Record("status_loss_ignored", statusId);
|
|
return;
|
|
}
|
|
|
|
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId);
|
|
if (!EventCatalog.IsCameraWorthy(entry))
|
|
{
|
|
InterestDropLog.Record("createsInterest=false", "status:" + statusId);
|
|
return;
|
|
}
|
|
|
|
long id = SafeId(actor);
|
|
if (entry.ExtendsGrief || statusId == "crying")
|
|
{
|
|
string griefPrefix = "happiness:death_";
|
|
if (ExtendMatching(id, griefPrefix, statusId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Standalone crying with no grief scene still creates interest.
|
|
if (statusId != "crying" && entry.ExtendsGrief)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
string phrase = ActivityStatusProse.Phrase(statusId, gained: true, out _);
|
|
string key = "status:" + statusId + ":" + id;
|
|
InterestCandidate registered = EventFeedUtil.Register(
|
|
key,
|
|
"status",
|
|
string.IsNullOrEmpty(entry.Category) ? "StatusTransformation" : entry.Category,
|
|
EventReason.Status(actor, phrase),
|
|
statusId,
|
|
entry.EventStrength,
|
|
actor.current_position,
|
|
actor,
|
|
minWatch: 2f,
|
|
maxWatch: InterestScoringConfig.W.maxWatchStatus,
|
|
completion: InterestCompletionKind.StatusPhase);
|
|
if (registered != null)
|
|
{
|
|
registered.StatusId = statusId;
|
|
registered.LastSeenAt = Time.unscaledTime;
|
|
// Cluster of carriers → sticky outbreak tip instead of thin single-unit prose.
|
|
StatusOutbreakFeed.TryUpgradeFromEmit(actor, statusId, registered);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hatch camera beat. Shared key with happiness <c>just_got_out_of_egg</c> so both paths merge.
|
|
/// </summary>
|
|
public static void EmitHatch(Actor actor, string source)
|
|
{
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id = SafeId(actor);
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float strength = Mathf.Max(
|
|
InterestScoring.EventStrengthForHappiness("just_got_out_of_egg"),
|
|
88f);
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = HatchKey(id),
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Category = "LifeChapter",
|
|
Source = string.IsNullOrEmpty(source) ? "hatch" : source,
|
|
EventStrength = strength,
|
|
VisualConfidence = 0.8f,
|
|
Position = actor.current_position,
|
|
FollowUnit = actor,
|
|
SubjectId = id,
|
|
Label = EventReason.Hatch(actor),
|
|
// Hatch id owns AssetId so cool keys are class-stable (not species).
|
|
AssetId = "just_got_out_of_egg",
|
|
SpeciesId = SafeAsset(actor),
|
|
HappinessEffectId = "just_got_out_of_egg",
|
|
CreatedAt = Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + 7f,
|
|
MinWatch = 4f,
|
|
MaxWatch = 15f,
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
candidate.ParticipantIds.Add(id);
|
|
InterestScoring.ScoreCheap(candidate);
|
|
EventFeedUtil.RegisterCandidate(candidate);
|
|
}
|
|
|
|
private static string HatchKey(long subjectId) => "hatch:" + subjectId;
|
|
|
|
private static string AlphaKey(long subjectId) => "alpha:" + subjectId;
|
|
|
|
/// <summary>
|
|
/// Family alpha succession. Shared key with happiness <c>become_alpha</c> so emotion and
|
|
/// emotion-less paths merge into one camera beat.
|
|
/// </summary>
|
|
public static void EmitAlpha(Actor actor, string source)
|
|
{
|
|
if (actor == null || !actor.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!AgentHarness.LiveFeedsAllowed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long id = SafeId(actor);
|
|
if (id == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback("become_alpha");
|
|
if (!EventCatalog.IsCameraWorthy(entry))
|
|
{
|
|
InterestDropLog.Record("createsInterest=false", "become_alpha");
|
|
return;
|
|
}
|
|
|
|
float strength = Mathf.Max(
|
|
entry.EventStrength,
|
|
InterestScoring.EventStrengthForHappiness("become_alpha"));
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = AlphaKey(id),
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Category = string.IsNullOrEmpty(entry.Category) ? "LifeChapter" : entry.Category,
|
|
Source = string.IsNullOrEmpty(source) ? "alpha" : source,
|
|
EventStrength = strength,
|
|
VisualConfidence = 0.8f,
|
|
Position = actor.current_position,
|
|
FollowUnit = actor,
|
|
SubjectId = id,
|
|
Label = EventReason.BecomeAlpha(actor),
|
|
AssetId = SafeAsset(actor),
|
|
SpeciesId = SafeAsset(actor),
|
|
HappinessEffectId = "become_alpha",
|
|
CreatedAt = Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + 25f,
|
|
MinWatch = 5f,
|
|
MaxWatch = 22f,
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
candidate.ParticipantIds.Add(id);
|
|
InterestScoring.ScoreCheap(candidate);
|
|
EventFeedUtil.RegisterCandidate(candidate);
|
|
}
|
|
|
|
public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label)
|
|
{
|
|
if (subject == null || !subject.isAlive() || string.IsNullOrEmpty(milestoneKey))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!AgentHarness.LiveFeedsAllowed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
long sid = SafeId(subject);
|
|
long rid = SafeId(related);
|
|
// Shared key space with happiness canonical merges.
|
|
string key = "chronicle:" + milestoneKey + ":" + sid + ":" + rid;
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = key,
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Category = "Relationship",
|
|
Source = "chronicle",
|
|
EventStrength = 60f,
|
|
VisualConfidence = 0.7f,
|
|
Position = subject.current_position,
|
|
FollowUnit = subject,
|
|
RelatedUnit = related,
|
|
SubjectId = sid,
|
|
RelatedId = rid,
|
|
Label = label ?? milestoneKey,
|
|
// Milestone id owns AssetId so kill/lover/friend crumbs cool as a class
|
|
// (species id used to land every hunter tip on arc:asset:elf).
|
|
AssetId = milestoneKey,
|
|
SpeciesId = subject.asset != null ? subject.asset.id : "",
|
|
CreatedAt = Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + 25f,
|
|
MinWatch = 3f,
|
|
MaxWatch = 20f,
|
|
Completion = InterestCompletionKind.FixedDwell
|
|
};
|
|
InterestScoring.ScoreCheap(candidate);
|
|
EventFeedUtil.RegisterCandidate(candidate);
|
|
}
|
|
|
|
private static void DrainHappiness()
|
|
{
|
|
ulong next = HappinessEventRouter.DrainSince(_happinessCursor, HappinessDrain);
|
|
if (HappinessDrain.Count == 0)
|
|
{
|
|
_happinessCursor = next;
|
|
return;
|
|
}
|
|
|
|
for (int i = 0; i < HappinessDrain.Count; i++)
|
|
{
|
|
IngestHappiness(HappinessDrain[i]);
|
|
}
|
|
|
|
_happinessCursor = next;
|
|
}
|
|
|
|
private static void IngestHappiness(HappinessOccurrence occ)
|
|
{
|
|
if (occ == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!occ.Applied || occ.SuppressedByPsychopath)
|
|
{
|
|
InterestDropLog.Record(
|
|
occ.SuppressedByPsychopath ? "suppressed" : "not_applied",
|
|
occ.EffectId ?? "");
|
|
return;
|
|
}
|
|
|
|
if (!AgentHarness.LiveFeedsAllowed && occ.SourceHook != HappinessSourceHook.Harness)
|
|
{
|
|
InterestDropLog.Record("busy", occ.EffectId ?? "");
|
|
return;
|
|
}
|
|
|
|
if (occ.Presentation == HappinessPresentationTier.Aggregate)
|
|
{
|
|
if (!occ.Merged || occ.SourceHook == HappinessSourceHook.AggregateSummary)
|
|
{
|
|
string kingdom = "";
|
|
try
|
|
{
|
|
// Prefer civ key fragment.
|
|
string civ = occ.CivEventKey ?? "";
|
|
int pipe = civ.IndexOf('|');
|
|
if (pipe >= 0 && pipe + 1 < civ.Length)
|
|
{
|
|
int pipe2 = civ.IndexOf('|', pipe + 1);
|
|
kingdom = pipe2 > pipe
|
|
? civ.Substring(pipe + 1, pipe2 - pipe - 1)
|
|
: civ.Substring(pipe + 1);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
kingdom = "";
|
|
}
|
|
|
|
StampCivicBoost(kingdom, occ.EffectId, 25f + Mathf.Min(40f, occ.TotalAffectedCount));
|
|
}
|
|
|
|
InterestDropLog.Record("aggregate", occ.EffectId ?? "");
|
|
return;
|
|
}
|
|
|
|
if (!EventCatalog.IsCameraWorthy(occ.Presentation))
|
|
{
|
|
// Layer B only - never owns the camera.
|
|
InterestDropLog.Record("ambient", occ.EffectId ?? "");
|
|
return;
|
|
}
|
|
|
|
// Signal: skip merged duplicates (canonical / continuation already covered).
|
|
if (occ.Merged)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Happiness can arrive in large bursts on old, populous worlds. Resolving each
|
|
// occurrence by walking every living unit turns one drain into O(events * units).
|
|
// ActorManager already owns an indexed lookup, so keep this path O(events).
|
|
Actor subject = EventFeedUtil.FindAliveById(occ.SubjectId);
|
|
if (subject == null || !subject.isAlive())
|
|
{
|
|
return;
|
|
}
|
|
|
|
Actor related = occ.RelatedId != 0 ? EventFeedUtil.FindUnitById(occ.RelatedId) : null;
|
|
NarrativePresentationModel narrativePresentation =
|
|
NarrativePresentationFactory.Happiness(occ, subject, related);
|
|
string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory;
|
|
bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_");
|
|
float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId);
|
|
bool hatchMoment = IsFreshLifeMoment(occ.EffectId);
|
|
bool alphaMoment = IsAlphaMoment(occ.EffectId);
|
|
|
|
// Hatch / alpha share keys with status-loss / Family.setAlpha so paths merge.
|
|
string key;
|
|
if (hatchMoment)
|
|
{
|
|
key = HatchKey(occ.SubjectId);
|
|
}
|
|
else if (alphaMoment)
|
|
{
|
|
key = AlphaKey(occ.SubjectId);
|
|
}
|
|
else
|
|
{
|
|
key = "happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId
|
|
+ ":" + CorrBucket(occ.CorrelationKey);
|
|
}
|
|
|
|
// Hatch: short queue TTL, but on-camera floor comes from minCameraDwell.
|
|
float ttl = hatchMoment ? 7f : 35f;
|
|
float minWatch = hatchMoment ? 4f : 8f;
|
|
float maxWatch = hatchMoment ? 15f : 28f;
|
|
if (hatchMoment)
|
|
{
|
|
strength = Mathf.Max(strength, 88f);
|
|
}
|
|
|
|
if (alphaMoment)
|
|
{
|
|
strength = Mathf.Max(strength, EventCatalog.Relationship.GetOrFallback("become_alpha").EventStrength);
|
|
ttl = 25f;
|
|
minWatch = 5f;
|
|
maxWatch = 22f;
|
|
}
|
|
|
|
string label;
|
|
if (hatchMoment)
|
|
{
|
|
label = EventReason.Hatch(subject);
|
|
}
|
|
else if (alphaMoment)
|
|
{
|
|
label = EventReason.BecomeAlpha(subject);
|
|
}
|
|
else if (!string.IsNullOrEmpty(occ.PlainClause))
|
|
{
|
|
label = occ.SubjectName + " " + occ.PlainClause;
|
|
}
|
|
else
|
|
{
|
|
label = occ.SubjectName + " · " + occ.EffectId;
|
|
}
|
|
|
|
InterestCompletionKind completion = grief
|
|
? InterestCompletionKind.HappinessGrief
|
|
: InterestCompletionKind.FixedDwell;
|
|
string statusId = grief ? "crying" : "";
|
|
|
|
if (!grief
|
|
&& !hatchMoment
|
|
&& !alphaMoment
|
|
&& !TryBindStatusOverlapCamera(
|
|
occ,
|
|
subject,
|
|
ref key,
|
|
ref minWatch,
|
|
ref maxWatch,
|
|
ref strength,
|
|
ref completion,
|
|
ref statusId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = key,
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Category = grief ? "FamilyEmotion" : MapHappinessCategory(cat),
|
|
Source = hatchMoment
|
|
? "happiness_hatch"
|
|
: (alphaMoment ? "happiness_alpha" : "happiness"),
|
|
EventStrength = strength,
|
|
VisualConfidence = 0.75f,
|
|
Position = occ.Position != Vector3.zero ? occ.Position : subject.current_position,
|
|
FollowUnit = subject,
|
|
RelatedUnit = related,
|
|
SubjectId = occ.SubjectId,
|
|
RelatedId = occ.RelatedId,
|
|
Label = label,
|
|
AssetId = occ.SubjectSpecies,
|
|
SpeciesId = occ.SubjectSpecies,
|
|
HappinessEffectId = occ.EffectId,
|
|
StatusId = statusId,
|
|
CorrelationKey = occ.CorrelationKey,
|
|
NarrativeDevelopmentId = narrativePresentation?.DevelopmentId ?? "",
|
|
NarrativePresentation = narrativePresentation,
|
|
CreatedAt = Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + ttl,
|
|
MinWatch = minWatch,
|
|
MaxWatch = maxWatch,
|
|
Completion = completion
|
|
};
|
|
candidate.ParticipantIds.Add(occ.SubjectId);
|
|
if (occ.RelatedId != 0)
|
|
{
|
|
candidate.ParticipantIds.Add(occ.RelatedId);
|
|
}
|
|
|
|
InterestScoring.ScoreCheap(candidate);
|
|
EventFeedUtil.RegisterCandidate(candidate);
|
|
}
|
|
|
|
/// <summary>Harness / push path: ingest one occurrence into the registry now.</summary>
|
|
public static void IngestForHarness(HappinessOccurrence occ)
|
|
{
|
|
IngestHappiness(occ);
|
|
if (occ != null && occ.Sequence > _happinessCursor)
|
|
{
|
|
_happinessCursor = occ.Sequence;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Camera-worthy status-overlap happiness must not FixedDwell-claim a status the unit lacks.
|
|
/// Hold (onset): StatusPhase while live, else drop. Offset (end-ping): FixedDwell without status.
|
|
/// </summary>
|
|
private static bool TryBindStatusOverlapCamera(
|
|
HappinessOccurrence occ,
|
|
Actor subject,
|
|
ref string key,
|
|
ref float minWatch,
|
|
ref float maxWatch,
|
|
ref float strength,
|
|
ref InterestCompletionKind completion,
|
|
ref string statusId)
|
|
{
|
|
if (occ == null || string.IsNullOrEmpty(occ.StatusOverlapId))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
HappinessStatusOverlapKind kind = ResolveStatusOverlapKind(occ);
|
|
if (kind == HappinessStatusOverlapKind.None)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
string overlapId = occ.StatusOverlapId.Trim();
|
|
StatusInterestEntry statusEntry = EventCatalog.Status.GetOrFallback(overlapId);
|
|
if (!EventCatalog.IsCameraWorthy(statusEntry))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
bool hasStatus = StatusGameApi.HasStatus(subject, overlapId)
|
|
|| InterestCompletionHasStatus(subject, overlapId);
|
|
if (hasStatus)
|
|
{
|
|
key = "status:" + overlapId + ":" + occ.SubjectId;
|
|
statusId = overlapId;
|
|
completion = InterestCompletionKind.StatusPhase;
|
|
minWatch = Mathf.Max(minWatch, 2f);
|
|
maxWatch = Mathf.Max(maxWatch, InterestScoringConfig.W.maxWatchStatus);
|
|
strength = Mathf.Max(strength, statusEntry.EventStrength);
|
|
return true;
|
|
}
|
|
|
|
if (kind == HappinessStatusOverlapKind.Offset)
|
|
{
|
|
// End-ping after the status cleared - short FixedDwell, keep happiness key.
|
|
maxWatch = Mathf.Min(maxWatch, 12f);
|
|
return true;
|
|
}
|
|
|
|
InterestDropLog.Record("status_overlap_absent", occ.EffectId ?? overlapId);
|
|
return false;
|
|
}
|
|
|
|
private static HappinessStatusOverlapKind ResolveStatusOverlapKind(HappinessOccurrence occ)
|
|
{
|
|
if (occ.StatusOverlapKind != HappinessStatusOverlapKind.None)
|
|
{
|
|
return occ.StatusOverlapKind;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(occ.StatusOverlapId))
|
|
{
|
|
return HappinessStatusOverlapKind.None;
|
|
}
|
|
|
|
string id = (occ.EffectId ?? "").Trim().ToLowerInvariant();
|
|
switch (id)
|
|
{
|
|
case "just_laughed":
|
|
case "just_sang":
|
|
case "just_swore":
|
|
case "just_cried":
|
|
case "just_had_tantrum":
|
|
case "just_inspired":
|
|
return HappinessStatusOverlapKind.Offset;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
string clause = (occ.PlainClause ?? "").Trim();
|
|
if (clause.StartsWith("finishes", StringComparison.OrdinalIgnoreCase)
|
|
|| clause.StartsWith("comes down", StringComparison.OrdinalIgnoreCase)
|
|
|| clause.StartsWith("calms after", StringComparison.OrdinalIgnoreCase)
|
|
|| clause.StartsWith("lets inspiration", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return HappinessStatusOverlapKind.Offset;
|
|
}
|
|
|
|
return HappinessStatusOverlapKind.Hold;
|
|
}
|
|
|
|
private static bool InterestCompletionHasStatus(Actor actor, string statusId)
|
|
{
|
|
if (actor == null || string.IsNullOrEmpty(statusId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
return actor.hasStatus(statusId);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Direct registry insert for harness happiness signals (skips drain filters).</summary>
|
|
public static InterestCandidate RegisterHappinessSignal(
|
|
Actor subject,
|
|
string effectId,
|
|
string label,
|
|
string relatedName = "")
|
|
{
|
|
if (subject == null || string.IsNullOrEmpty(effectId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
long id = SafeId(subject);
|
|
Vector3 pos = Vector3.zero;
|
|
try
|
|
{
|
|
pos = subject.current_position;
|
|
}
|
|
catch
|
|
{
|
|
pos = Vector3.zero;
|
|
}
|
|
|
|
bool grief = effectId.StartsWith("death_");
|
|
float strength = InterestScoring.EventStrengthForHappiness(effectId);
|
|
string key = "happiness:" + effectId + ":" + id + ":0:harness";
|
|
bool alive = false;
|
|
try
|
|
{
|
|
alive = subject.isAlive();
|
|
}
|
|
catch
|
|
{
|
|
alive = false;
|
|
}
|
|
|
|
var candidate = new InterestCandidate
|
|
{
|
|
Key = key,
|
|
LeadKind = InterestLeadKind.EventLed,
|
|
Category = grief ? "FamilyEmotion" : "LifeChapter",
|
|
Source = "happiness_harness",
|
|
EventStrength = strength,
|
|
VisualConfidence = 0.9f,
|
|
Position = pos != Vector3.zero ? pos : new Vector3(1f, 1f, 0f),
|
|
FollowUnit = alive ? subject : null,
|
|
SubjectId = id,
|
|
Label = !string.IsNullOrEmpty(label)
|
|
? label
|
|
: (SafeName(subject) + " · " + effectId),
|
|
AssetId = subject.asset != null ? subject.asset.id : "",
|
|
SpeciesId = subject.asset != null ? subject.asset.id : "",
|
|
HappinessEffectId = effectId,
|
|
StatusId = grief ? "crying" : "",
|
|
CreatedAt = Time.unscaledTime,
|
|
LastSeenAt = Time.unscaledTime,
|
|
ExpiresAt = Time.unscaledTime + 60f,
|
|
MinWatch = 4f,
|
|
MaxWatch = 28f,
|
|
Completion = grief
|
|
? InterestCompletionKind.HappinessGrief
|
|
: InterestCompletionKind.FixedDwell
|
|
};
|
|
if (!string.IsNullOrEmpty(relatedName))
|
|
{
|
|
candidate.Label = SafeName(subject) + " mourns " + relatedName;
|
|
}
|
|
|
|
InterestScoring.ScoreCheap(candidate);
|
|
return EventFeedUtil.RegisterCandidate(candidate);
|
|
}
|
|
|
|
private static void TickScanner()
|
|
{
|
|
LastBattleScanMs = 0f;
|
|
LastWarFeedMs = 0f;
|
|
LastPlotFeedMs = 0f;
|
|
LastFamilyFeedMs = 0f;
|
|
LastStatusFeedMs = 0f;
|
|
float now = Time.unscaledTime;
|
|
if (now - _lastScannerAt < ScannerInterval)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastScannerAt = now;
|
|
if (!AgentHarness.LiveFeedsAllowed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
InterestCandidate current = InterestDirector.CurrentCandidate;
|
|
bool stickyCombat = current != null
|
|
&& current.Completion == InterestCompletionKind.CombatActive
|
|
&& InterestDirector.CurrentIsActive
|
|
&& current.ParticipantCount >= 2;
|
|
|
|
long stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
InterestEvent battle;
|
|
if (stickyCombat)
|
|
{
|
|
// Sticky owns the camera - never CollectAllCombatFighters. Chunk/BattleKeeper only.
|
|
Vector3 anchor = current.Position;
|
|
try
|
|
{
|
|
if (current.FollowUnit != null && current.FollowUnit.isAlive())
|
|
{
|
|
anchor = current.FollowUnit.current_position;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// keep Position
|
|
}
|
|
|
|
battle = WorldActivityScanner.FindHottestBattleLight(anchor);
|
|
}
|
|
else
|
|
{
|
|
// BattleKeeper plus chunk-local ensembles yields the strongest combat scene
|
|
// without collecting every fighter in the world. The old extra four combat
|
|
// vignettes duplicated that scene and made large peaceful worlds pay a full
|
|
// population scan every refresh.
|
|
battle = WorldActivityScanner.FindHottestBattleLight(
|
|
current != null ? current.Position : Vector3.zero);
|
|
}
|
|
LastBattleScanMs = ElapsedMs(stageStarted);
|
|
|
|
if (battle != null)
|
|
{
|
|
if (string.Equals(battle.AssetId, "live_battle", StringComparison.OrdinalIgnoreCase)
|
|
&& InterestDirector.TryAbsorbCombatBattle(battle))
|
|
{
|
|
// Active combat scene refreshed in place.
|
|
}
|
|
else if (!stickyCombat)
|
|
{
|
|
// Do not let a far-away light tip steal sticky ownership via RegisterDirect.
|
|
RegisterDirect(battle);
|
|
}
|
|
}
|
|
|
|
// Ongoing wars from WarManager (complements WorldLog war start/end).
|
|
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
WarInterestFeed.Tick();
|
|
LastWarFeedMs = ElapsedMs(stageStarted);
|
|
|
|
// Active plots from PlotManager / World.plots.
|
|
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
PlotInterestFeed.Tick();
|
|
LastPlotFeedMs = ElapsedMs(stageStarted);
|
|
|
|
// Active family packs (2+ members) from World.families.
|
|
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
FamilyInterestFeed.Tick();
|
|
LastFamilyFeedMs = ElapsedMs(stageStarted);
|
|
|
|
// Nearby multi-carrier status clusters.
|
|
stageStarted = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
StatusOutbreakFeed.Tick();
|
|
LastStatusFeedMs = ElapsedMs(stageStarted);
|
|
}
|
|
|
|
private static float ElapsedMs(long started) =>
|
|
(float)((System.Diagnostics.Stopwatch.GetTimestamp() - started) * 1000d
|
|
/ System.Diagnostics.Stopwatch.Frequency);
|
|
|
|
private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId)
|
|
{
|
|
var pending = new List<InterestCandidate>(32);
|
|
InterestRegistry.CopyPending(pending);
|
|
for (int i = 0; i < pending.Count; i++)
|
|
{
|
|
InterestCandidate c = pending[i];
|
|
if (c == null || c.SubjectId != subjectId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (c.Key != null && c.Key.StartsWith(keyPrefix))
|
|
{
|
|
c.StatusId = statusId;
|
|
c.LastSeenAt = Time.unscaledTime;
|
|
c.ExpiresAt = Time.unscaledTime + 20f;
|
|
EventFeedUtil.RegisterCandidate(c);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static void StampCivicBoost(string kingdom, string effectId, float amount)
|
|
{
|
|
string key = (kingdom ?? "") + "|" + (effectId ?? "");
|
|
CivicBoostUntil[key] = Time.unscaledTime + 12f;
|
|
// Store amount in a parallel way via Expires encoding isn't ideal; keep simple additive peek.
|
|
CivicBoostUntil[key + "|amt"] = amount;
|
|
}
|
|
|
|
private static float PeekCivicBoost(string kingdom, string assetId)
|
|
{
|
|
// Map worldlog war assets to happiness war effects loosely.
|
|
string effect = "";
|
|
if (assetId != null && assetId.Contains("war"))
|
|
{
|
|
effect = "just_started_war";
|
|
}
|
|
else if (assetId != null && (assetId.Contains("city") || assetId.Contains("kingdom")))
|
|
{
|
|
effect = "conquered_city";
|
|
}
|
|
|
|
string key = (kingdom ?? "") + "|" + effect;
|
|
if (!CivicBoostUntil.TryGetValue(key, out float until) || Time.unscaledTime > until)
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
return CivicBoostUntil.TryGetValue(key + "|amt", out float amt) ? amt : 15f;
|
|
}
|
|
|
|
private static void PruneCivicBoosts()
|
|
{
|
|
if (CivicBoostUntil.Count < 64)
|
|
{
|
|
return;
|
|
}
|
|
|
|
float now = Time.unscaledTime;
|
|
var remove = new List<string>();
|
|
foreach (KeyValuePair<string, float> kv in CivicBoostUntil)
|
|
{
|
|
if (!kv.Key.EndsWith("|amt") && now > kv.Value)
|
|
{
|
|
remove.Add(kv.Key);
|
|
remove.Add(kv.Key + "|amt");
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < remove.Count; i++)
|
|
{
|
|
CivicBoostUntil.Remove(remove[i]);
|
|
}
|
|
}
|
|
|
|
private static bool IsFreshLifeMoment(string effectId)
|
|
{
|
|
if (string.IsNullOrEmpty(effectId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Hatch moments only - just_born is spawn bookkeeping, not a birth/hatch beat.
|
|
string id = effectId.Trim();
|
|
return string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool IsAlphaMoment(string effectId)
|
|
{
|
|
return !string.IsNullOrEmpty(effectId)
|
|
&& string.Equals(effectId.Trim(), "become_alpha", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static string MapHappinessCategory(string cat)
|
|
{
|
|
switch ((cat ?? "").ToLowerInvariant())
|
|
{
|
|
case "grief":
|
|
return "FamilyEmotion";
|
|
case "social":
|
|
return "Relationship";
|
|
case "lifechapter":
|
|
return "LifeChapter";
|
|
case "civic":
|
|
return "Politics";
|
|
default:
|
|
return "FamilyEmotion";
|
|
}
|
|
}
|
|
|
|
private static string CategoryForLead(
|
|
InterestLeadKind lead,
|
|
InterestCompletionKind completion,
|
|
string asset)
|
|
{
|
|
if (asset == "live_battle" || completion == InterestCompletionKind.CombatActive)
|
|
{
|
|
return "Combat";
|
|
}
|
|
|
|
if (completion == InterestCompletionKind.CharacterVignette
|
|
|| lead == InterestLeadKind.CharacterLed)
|
|
{
|
|
return "CharacterVignette";
|
|
}
|
|
|
|
if (completion == InterestCompletionKind.HappinessGrief)
|
|
{
|
|
return "FamilyEmotion";
|
|
}
|
|
|
|
if (completion == InterestCompletionKind.ActivityActive)
|
|
{
|
|
return "Work";
|
|
}
|
|
|
|
return "Settlement";
|
|
}
|
|
|
|
private static string CorrBucket(string corr)
|
|
{
|
|
if (string.IsNullOrEmpty(corr))
|
|
{
|
|
return "0";
|
|
}
|
|
|
|
int hash = corr.GetHashCode();
|
|
return (hash & 0xFFFF).ToString("x");
|
|
}
|
|
|
|
private static long SafeId(Actor actor)
|
|
{
|
|
if (actor == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
try
|
|
{
|
|
return actor.getID();
|
|
}
|
|
catch
|
|
{
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private static string SafeName(Actor actor)
|
|
{
|
|
if (actor == null)
|
|
{
|
|
return "Someone";
|
|
}
|
|
|
|
try
|
|
{
|
|
string n = actor.getName();
|
|
if (!string.IsNullOrEmpty(n))
|
|
{
|
|
return n;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
|
|
return actor.asset != null ? actor.asset.id : "creature";
|
|
}
|
|
|
|
private static string SafeAsset(Actor actor)
|
|
{
|
|
if (actor == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
try
|
|
{
|
|
return actor.asset != null ? actor.asset.id : "";
|
|
}
|
|
catch
|
|
{
|
|
return "";
|
|
}
|
|
}
|
|
}
|