worldbox-observer-mod/IdleSpectator/ActivityRelevance.cs

309 lines
7.8 KiB
C#

using System;
namespace IdleSpectator;
/// <summary>
/// Live-relevance policy for Activity History <b>peek</b> (dossier).
/// The ring stays append-only; Lore Activity still shows the full journal.
/// Peek only keeps lines that still match what the unit is doing (or just did).
/// </summary>
public static class ActivityRelevance
{
/// <summary>
/// Lines newer than this always pass (beat just happened; AI may not have settled).
/// </summary>
public const float SoftWindowSeconds = 8f;
/// <summary>How many newest ring lines to scan when filling a peek of size N.</summary>
public const int PeekScanMax = 24;
public enum Family
{
Ambient,
Combat,
Haul,
Social,
Romance,
Work,
Care,
Travel,
Milestone,
Status,
Happiness
}
public static Family FamilyForEntry(ActivityEntry entry)
{
if (entry == null)
{
return Family.Ambient;
}
switch (entry.Kind)
{
case ActivityKind.StatusGain:
case ActivityKind.StatusLoss:
return Family.Status;
case ActivityKind.Happiness:
return Family.Happiness;
default:
break;
}
string key = (entry.TaskId ?? "").Trim();
if (key.StartsWith("milestone_", StringComparison.OrdinalIgnoreCase))
{
return Family.Milestone;
}
return FamilyForVerb(ActivityVerbMap.Resolve(key));
}
public static Family FamilyForVerb(string verb)
{
if (string.IsNullOrEmpty(verb))
{
return Family.Ambient;
}
switch (verb)
{
case "hunt":
case "fight":
case "flee":
case "fire":
case "ignite":
case "extinguish":
case "warrior":
case "loot":
case "steal":
return Family.Combat;
case "haul":
return Family.Haul;
case "social":
case "group":
return Family.Social;
case "lover":
return Family.Romance;
case "farm":
case "work":
case "pollinate":
case "fish":
case "trade":
case "build":
case "read":
case "heal":
return Family.Work;
case "eat":
case "sleep":
case "play":
case "relieve":
case "laugh":
case "sing":
case "cry":
case "swear":
case "wait":
case "recharge":
case "dream":
return Family.Care;
case "move":
case "settle":
case "boat":
return Family.Travel;
default:
return Family.Ambient;
}
}
/// <summary>
/// Whether this ring line should appear in the dossier History peek for <paramref name="actor"/>.
/// </summary>
public static bool IsPeekRelevant(Actor actor, ActivityEntry entry, float now)
{
if (entry == null)
{
return false;
}
if (now - entry.CreatedAt <= SoftWindowSeconds)
{
return true;
}
Family family = FamilyForEntry(entry);
if (family == Family.Milestone)
{
// Life beats stay peek-worthy; Chronicle also covers them.
return true;
}
if (actor == null || !actor.isAlive())
{
return false;
}
if (family == Family.Status)
{
return StatusStillMatches(actor, entry);
}
if (family == Family.Happiness)
{
// Discrete mood beats age out of peek after the soft window.
return false;
}
string entryVerb = ActivityVerbMap.Resolve(entry.TaskId ?? "");
string liveTaskId = ActivityInterestTable.SafeTaskId(actor);
string liveVerb = ActivityVerbMap.Resolve(liveTaskId);
if (!string.IsNullOrEmpty(entryVerb)
&& !string.IsNullOrEmpty(liveVerb)
&& (string.Equals(entryVerb, liveVerb, StringComparison.OrdinalIgnoreCase)
|| FamilyForVerb(entryVerb) == FamilyForVerb(liveVerb)))
{
return true;
}
return FamilyLivePredicate(actor, family);
}
/// <summary>
/// Fingerprint of live AI bits that can change peek without adding ring lines.
/// </summary>
public static int LiveFingerprint(Actor actor, float now)
{
int h = 17;
h = h * 31 + (int)(now / SoftWindowSeconds);
if (actor == null)
{
return h;
}
try
{
h = h * 31 + (actor.has_attack_target ? 1 : 0);
h = h * 31 + (actor.isCarryingResources() ? 1 : 0);
string task = ActivityInterestTable.SafeTaskId(actor) ?? "";
h = h * 31 + (task?.GetHashCode() ?? 0);
if (actor.hasTask() && actor.ai?.task != null)
{
h = h * 31 + (actor.ai.task.in_combat ? 1 : 0);
h = h * 31 + (actor.ai.task.is_fireman ? 1 : 0);
}
}
catch
{
// ignore
}
return h;
}
private static bool FamilyLivePredicate(Actor actor, Family family)
{
switch (family)
{
case Family.Combat:
return CombatLive(actor);
case Family.Haul:
return HaulLive(actor);
case Family.Social:
case Family.Romance:
case Family.Work:
case Family.Care:
case Family.Travel:
case Family.Ambient:
default:
return false;
}
}
private static bool CombatLive(Actor actor)
{
try
{
if (actor.has_attack_target)
{
return true;
}
if (actor.hasTask() && actor.ai?.task != null
&& (actor.ai.task.in_combat || actor.ai.task.is_fireman))
{
return true;
}
}
catch
{
// ignore
}
return false;
}
private static bool HaulLive(Actor actor)
{
try
{
return actor.isCarryingResources();
}
catch
{
return false;
}
}
private static bool StatusStillMatches(Actor actor, ActivityEntry entry)
{
string statusId = StatusIdFromEntry(entry);
if (string.IsNullOrEmpty(statusId))
{
return false;
}
try
{
bool has = actor.hasStatus(statusId);
if (entry.Kind == ActivityKind.StatusGain)
{
return has;
}
if (entry.Kind == ActivityKind.StatusLoss)
{
return !has;
}
}
catch
{
// ignore
}
return false;
}
private static string StatusIdFromEntry(ActivityEntry entry)
{
string key = (entry?.TaskId ?? "").Trim();
const string gain = "status_gain:";
const string loss = "status_loss:";
if (key.StartsWith(gain, StringComparison.OrdinalIgnoreCase))
{
return key.Substring(gain.Length).Trim();
}
if (key.StartsWith(loss, StringComparison.OrdinalIgnoreCase))
{
return key.Substring(loss.Length).Trim();
}
// NoteStatusChange may store bare status id or ActivityStatusProse.TaskKey.
if (key.IndexOf(':') < 0 && !string.IsNullOrEmpty(key))
{
return key;
}
return "";
}
}