Compare commits
11 commits
8427079188
...
1c00a7980a
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c00a7980a | |||
| 63ba0ca3d1 | |||
| ff824c76a1 | |||
| c253b5dd9c | |||
| 3fe12d6ff0 | |||
| 6c6a7cc286 | |||
| fd2283222c | |||
| 020d73fa9c | |||
| eaa431cc3d | |||
| 20996fa3a7 | |||
| 199adee644 |
72 changed files with 9496 additions and 764 deletions
|
|
@ -125,12 +125,16 @@ A loaded world is still required.
|
|||
|
||||
| Name | Purpose |
|
||||
|------|---------|
|
||||
| `watch_reason_clarity` | Orange reason = event sentence (`is fighting`, `is seeking a lover`); no Signal gate; fill has empty reason |
|
||||
| `critical_smoke` | PR gate: settings gate, idle health, tip match, retarget, manual exit |
|
||||
| `settings_full` | Disable-while-active, show_watch_reasons |
|
||||
| `ghost_guard` | Fake new-species tip must not steal focus onto wrong unit |
|
||||
| `retarget_stability` | Many watches without focus/power-bar flicker |
|
||||
| `input_exit` | Post-enable grace + camera-drag exit |
|
||||
| `director_tiers` | Curiosity accept, Action interrupt, curiosity blocked by Action |
|
||||
| `director_tiers` | Score-margin cut-in (Action over Curiosity); peer rotate still behind MinDwell |
|
||||
| `director_lifecycle` | Hold / resume / MaxWatch / quiet grace → ambient |
|
||||
| `director_gaps` | Forced session interrupt + resume after higher-score cut |
|
||||
| `focus_interrupt` | Instant score-margin cut (no settle); hold; still-worth-watching; 6s grace |
|
||||
| `discovery` | Present dedupe + force drain + tip match |
|
||||
| `world_log` | Story/Epic interest interrupt via collector |
|
||||
| `chronicle_smoke` | History inject, death-cause samples, World feed isolation, History\|World tabs, HUD jump |
|
||||
|
|
|
|||
|
|
@ -204,11 +204,125 @@ public static class ActivityAssetCatalog
|
|||
}
|
||||
|
||||
public static List<string> EnumerateLiveTaskIds()
|
||||
{
|
||||
return EnumerateLibraryIds("tasks_actor");
|
||||
}
|
||||
|
||||
public static List<string> EnumerateLiveWorldLogIds()
|
||||
{
|
||||
return EnumerateLibraryIds("world_log_library");
|
||||
}
|
||||
|
||||
public static WorldLogAsset TryGetWorldLogAsset(string assetId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return AssetManager.world_log_library?.get(assetId.Trim());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<string> EnumerateLiveDisasterIds()
|
||||
{
|
||||
return EnumerateLibraryIds("disasters");
|
||||
}
|
||||
|
||||
public static DisasterAsset TryGetDisasterAsset(string disasterId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(disasterId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return AssetManager.disasters?.get(disasterId.Trim());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<string> EnumerateLiveWarTypeIds()
|
||||
{
|
||||
return EnumerateLibraryIds("war_types_library");
|
||||
}
|
||||
|
||||
public static WarTypeAsset TryGetWarTypeAsset(string warTypeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(warTypeId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return AssetManager.war_types_library?.get(warTypeId.Trim());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<string> EnumerateLivePlotIds()
|
||||
{
|
||||
return EnumerateLibraryIds("plots_library");
|
||||
}
|
||||
|
||||
public static List<string> EnumerateLiveEraIds()
|
||||
{
|
||||
return EnumerateLibraryIds("era_library");
|
||||
}
|
||||
|
||||
public static List<string> EnumerateLiveBookTypeIds()
|
||||
{
|
||||
return EnumerateLibraryIds("book_types");
|
||||
}
|
||||
|
||||
public static List<string> EnumerateLiveTraitIds()
|
||||
{
|
||||
return EnumerateLibraryIds("traits");
|
||||
}
|
||||
|
||||
public static List<string> EnumerateLiveBuildingIds()
|
||||
{
|
||||
return EnumerateLibraryIds("buildings");
|
||||
}
|
||||
|
||||
public static List<string> EnumerateLiveSpellIds() => EnumerateLibraryIds("spells");
|
||||
|
||||
public static List<string> EnumerateLivePowerIds() => EnumerateLibraryIds("powers");
|
||||
|
||||
public static List<string> EnumerateLiveDecisionIds() => EnumerateLibraryIds("decisions_library");
|
||||
|
||||
public static List<string> EnumerateLiveItemIds() => EnumerateLibraryIds("items");
|
||||
|
||||
public static List<string> EnumerateLiveSubspeciesTraitIds() => EnumerateLibraryIds("subspecies_traits");
|
||||
|
||||
public static List<string> EnumerateLiveGeneIds() => EnumerateLibraryIds("gene_library");
|
||||
|
||||
public static List<string> EnumerateLivePhenotypeIds() => EnumerateLibraryIds("phenotype_library");
|
||||
|
||||
public static List<string> EnumerateLiveWorldLawIds() => EnumerateLibraryIds("world_laws_library");
|
||||
|
||||
public static List<string> EnumerateLiveBiomeIds() => EnumerateLibraryIds("biome_library");
|
||||
|
||||
private static List<string> EnumerateLibraryIds(string assetManagerField)
|
||||
{
|
||||
var ids = new List<string>();
|
||||
try
|
||||
{
|
||||
object lib = typeof(AssetManager).GetField("tasks_actor")?.GetValue(null);
|
||||
object lib = typeof(AssetManager).GetField(assetManagerField)?.GetValue(null);
|
||||
object listObj = lib?.GetType().GetField("list")?.GetValue(lib)
|
||||
?? lib?.GetType().GetProperty("list")?.GetValue(lib, null);
|
||||
System.Collections.IList list = listObj as System.Collections.IList;
|
||||
|
|
@ -219,9 +333,9 @@ public static class ActivityAssetCatalog
|
|||
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
object task = list[i];
|
||||
object idObj = task?.GetType().GetField("id")?.GetValue(task)
|
||||
?? task?.GetType().GetProperty("id")?.GetValue(task, null);
|
||||
object asset = list[i];
|
||||
object idObj = asset?.GetType().GetField("id")?.GetValue(asset)
|
||||
?? asset?.GetType().GetProperty("id")?.GetValue(asset, null);
|
||||
string id = idObj as string;
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ public sealed class ActivityHappinessAuditResult
|
|||
public int OrphanAuthored;
|
||||
public int MissingPolicy;
|
||||
public int RequiredContextFailures;
|
||||
public int MissingEventStrength;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -29,7 +30,7 @@ public static class ActivityHappinessHarness
|
|||
{
|
||||
List<string> liveIds = ActivityAssetCatalog.EnumerateLiveHappinessIds();
|
||||
var authored = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string id in HappinessEventCatalog.AuthoredIds)
|
||||
foreach (string id in EventCatalog.Happiness.AuthoredIds)
|
||||
{
|
||||
authored.Add(id);
|
||||
}
|
||||
|
|
@ -41,9 +42,10 @@ public static class ActivityHappinessHarness
|
|||
int orphanAuthored = 0;
|
||||
int missingPolicy = 0;
|
||||
int requiredContextFailures = 0;
|
||||
int missingEventStrength = 0;
|
||||
var tsv = new StringBuilder();
|
||||
tsv.AppendLine(
|
||||
"id\tvalue\tpsychopath\tpresentation\tlife\tcontext\toverlap\tclause\tauthored\ticon\tlocale_leak\tnotes");
|
||||
"id\tvalue\tpsychopath\tpresentation\tlife\tcontext\toverlap\tevent_strength\tclause\tauthored\ticon\tlocale_leak\tnotes");
|
||||
|
||||
var liveSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < liveIds.Count; i++)
|
||||
|
|
@ -51,13 +53,18 @@ public static class ActivityHappinessHarness
|
|||
string id = liveIds[i];
|
||||
liveSet.Add(id);
|
||||
HappinessAsset asset = ActivityAssetCatalog.TryGetHappinessAsset(id);
|
||||
bool hasAuthored = HappinessEventCatalog.HasAuthored(id);
|
||||
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(id);
|
||||
bool hasAuthored = EventCatalog.Happiness.HasAuthored(id);
|
||||
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(id);
|
||||
if (!hasAuthored || entry.IsFallback)
|
||||
{
|
||||
missingAuthored++;
|
||||
}
|
||||
|
||||
if (hasAuthored && entry.EventStrength <= 0f)
|
||||
{
|
||||
missingEventStrength++;
|
||||
}
|
||||
|
||||
string clauseWith = ActivityHappinessProse.Clause(entry, "Ava", out bool usedRelated, out _);
|
||||
string clauseWithout = ActivityHappinessProse.Clause(entry, "", out _, out _);
|
||||
if (string.IsNullOrEmpty(clauseWith) || string.IsNullOrEmpty(clauseWithout))
|
||||
|
|
@ -126,6 +133,10 @@ public static class ActivityHappinessHarness
|
|||
{
|
||||
notes = "missing_authored";
|
||||
}
|
||||
else if (entry.EventStrength <= 0f)
|
||||
{
|
||||
notes = "missing_event_strength";
|
||||
}
|
||||
else if (!iconOk)
|
||||
{
|
||||
notes = "missing_icon";
|
||||
|
|
@ -151,6 +162,7 @@ public static class ActivityHappinessHarness
|
|||
.Append(entry.Life).Append('\t')
|
||||
.Append(entry.Context).Append('\t')
|
||||
.Append(entry.Overlap).Append('\t')
|
||||
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
|
||||
.Append(Escape(clauseWith)).Append('\t')
|
||||
.Append(hasAuthored ? "1" : "0").Append('\t')
|
||||
.Append(iconOk ? "1" : "0").Append('\t')
|
||||
|
|
@ -164,7 +176,7 @@ public static class ActivityHappinessHarness
|
|||
{
|
||||
orphanAuthored++;
|
||||
tsv.Append(id).Append('\t')
|
||||
.Append("\t\t\t\t\t\t\t1\t0\t0\torphan_authored").AppendLine();
|
||||
.Append("\t\t\t\t\t\t\t0\t\t1\t0\t0\torphan_authored").AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +201,8 @@ public static class ActivityHappinessHarness
|
|||
&& emptyPhrase == 0
|
||||
&& orphanAuthored == 0
|
||||
&& missingPolicy == 0
|
||||
&& requiredContextFailures == 0;
|
||||
&& requiredContextFailures == 0
|
||||
&& missingEventStrength == 0;
|
||||
|
||||
return new ActivityHappinessAuditResult
|
||||
{
|
||||
|
|
@ -202,10 +215,12 @@ public static class ActivityHappinessHarness
|
|||
OrphanAuthored = orphanAuthored,
|
||||
MissingPolicy = missingPolicy,
|
||||
RequiredContextFailures = requiredContextFailures,
|
||||
MissingEventStrength = missingEventStrength,
|
||||
Detail =
|
||||
$"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} "
|
||||
+ $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} "
|
||||
+ $"missingPolicy={missingPolicy} requiredContextFailures={requiredContextFailures}"
|
||||
+ $"missingPolicy={missingPolicy} requiredContextFailures={requiredContextFailures} "
|
||||
+ $"missingEventStrength={missingEventStrength}"
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ public static class ActivityHappinessProse
|
|||
out bool authored)
|
||||
{
|
||||
usedRelated = false;
|
||||
authored = entry != null && !entry.IsFallback && HappinessEventCatalog.HasAuthored(entry.Id);
|
||||
authored = entry != null && !entry.IsFallback && EventCatalog.Happiness.HasAuthored(entry.Id);
|
||||
if (entry == null)
|
||||
{
|
||||
return "feels a change in happiness";
|
||||
|
|
|
|||
|
|
@ -750,7 +750,7 @@ public static class ActivityLog
|
|||
if (published != null && !string.IsNullOrEmpty(relatedName))
|
||||
{
|
||||
published.RelatedName = relatedName;
|
||||
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId);
|
||||
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(effectId);
|
||||
string clause = ActivityHappinessProse.Clause(entry, relatedName, out bool usedRelated, out _);
|
||||
published.PlainClause = clause;
|
||||
published.RichClause = usedRelated
|
||||
|
|
@ -770,7 +770,7 @@ public static class ActivityLog
|
|||
return published != null;
|
||||
}
|
||||
|
||||
HappinessCatalogEntry fallbackEntry = HappinessEventCatalog.GetOrFallback(effectId);
|
||||
HappinessCatalogEntry fallbackEntry = EventCatalog.Happiness.GetOrFallback(effectId);
|
||||
string fallbackClause = ActivityHappinessProse.Clause(
|
||||
fallbackEntry, relatedName, out bool usedRel, out _);
|
||||
string rich = usedRel && !string.IsNullOrEmpty(relatedName)
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ public sealed class ActivityStatusAuditResult
|
|||
public int LocaleLeaks;
|
||||
public int EmptyPhrase;
|
||||
public int OrphanAuthored;
|
||||
public int MissingInterest;
|
||||
public int OrphanInterest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exhaustive live-library audit for status Activity prose and icons.
|
||||
/// Exhaustive live-library audit for status Activity prose, icons, and interest policy.
|
||||
/// </summary>
|
||||
public static class ActivityStatusHarness
|
||||
{
|
||||
|
|
@ -37,11 +39,19 @@ public static class ActivityStatusHarness
|
|||
int localeLeaks = 0;
|
||||
int emptyPhrase = 0;
|
||||
int orphanAuthored = 0;
|
||||
int missingInterest = 0;
|
||||
int orphanInterest = 0;
|
||||
var tsv = new StringBuilder();
|
||||
tsv.AppendLine(
|
||||
"id\tgain\tloss\tauthored\ticon\tlocale_leak\tduration\tallow_timer_reset\tnotes");
|
||||
"id\tgain\tloss\tauthored\tinterest\tevent_strength\tcreates_interest\ticon\tlocale_leak\tduration\tallow_timer_reset\tnotes");
|
||||
|
||||
var liveSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var interestAuthored = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string id in EventCatalog.Status.AuthoredIds)
|
||||
{
|
||||
interestAuthored.Add(id);
|
||||
}
|
||||
|
||||
for (int i = 0; i < liveIds.Count; i++)
|
||||
{
|
||||
string id = liveIds[i];
|
||||
|
|
@ -55,6 +65,13 @@ public static class ActivityStatusHarness
|
|||
missingAuthored++;
|
||||
}
|
||||
|
||||
StatusInterestEntry interest = EventCatalog.Status.GetOrFallback(id);
|
||||
bool hasInterest = EventCatalog.Status.HasAuthored(id) && !interest.IsFallback;
|
||||
if (!hasInterest)
|
||||
{
|
||||
missingInterest++;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(gain) || string.IsNullOrEmpty(loss))
|
||||
{
|
||||
emptyPhrase++;
|
||||
|
|
@ -90,11 +107,15 @@ public static class ActivityStatusHarness
|
|||
// ignore
|
||||
}
|
||||
|
||||
string notes = "";
|
||||
string notes = "ok";
|
||||
if (!hasAuthored)
|
||||
{
|
||||
notes = "missing_authored";
|
||||
}
|
||||
else if (!hasInterest)
|
||||
{
|
||||
notes = "missing_interest";
|
||||
}
|
||||
else if (!iconOk)
|
||||
{
|
||||
notes = "missing_icon";
|
||||
|
|
@ -107,22 +128,20 @@ public static class ActivityStatusHarness
|
|||
{
|
||||
notes = "empty_phrase";
|
||||
}
|
||||
else
|
||||
{
|
||||
notes = "ok";
|
||||
}
|
||||
|
||||
tsv.Append(id).Append('\t')
|
||||
.Append(Escape(gain)).Append('\t')
|
||||
.Append(Escape(loss)).Append('\t')
|
||||
.Append(hasAuthored ? "1" : "0").Append('\t')
|
||||
.Append(hasInterest ? "1" : "0").Append('\t')
|
||||
.Append(interest.EventStrength.ToString("0.#")).Append('\t')
|
||||
.Append(interest.CreatesInterest ? "1" : "0").Append('\t')
|
||||
.Append(iconOk ? "1" : "0").Append('\t')
|
||||
.Append(leak ? "1" : "0").Append('\t')
|
||||
.Append(duration).Append('\t')
|
||||
.Append(allowReset).Append('\t')
|
||||
.Append(notes).AppendLine();
|
||||
|
||||
// Smoke the milestone formatting + keys.
|
||||
string gainKey = ActivityStatusProse.TaskKey(id, true);
|
||||
string lossKey = ActivityStatusProse.TaskKey(id, false);
|
||||
if (string.IsNullOrEmpty(gainKey) || string.IsNullOrEmpty(lossKey))
|
||||
|
|
@ -136,9 +155,16 @@ public static class ActivityStatusHarness
|
|||
if (!liveSet.Contains(id))
|
||||
{
|
||||
orphanAuthored++;
|
||||
tsv.Append(id).Append('\t')
|
||||
.Append('\t').Append('\t')
|
||||
.Append("1\t0\t0\t\t\torphan_authored").AppendLine();
|
||||
tsv.Append(id).Append("\t\t\t1\t0\t0\t0\t0\t0\t\t\torphan_authored").AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string id in interestAuthored)
|
||||
{
|
||||
if (!liveSet.Contains(id))
|
||||
{
|
||||
orphanInterest++;
|
||||
tsv.Append(id).Append("\t\t\t0\t1\t0\t0\t0\t0\t\t\torphan_interest").AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +189,9 @@ public static class ActivityStatusHarness
|
|||
&& missingIcon == 0
|
||||
&& localeLeaks == 0
|
||||
&& emptyPhrase == 0
|
||||
&& orphanAuthored == 0;
|
||||
&& orphanAuthored == 0
|
||||
&& missingInterest == 0
|
||||
&& orphanInterest == 0;
|
||||
|
||||
return new ActivityStatusAuditResult
|
||||
{
|
||||
|
|
@ -174,10 +202,12 @@ public static class ActivityStatusHarness
|
|||
LocaleLeaks = localeLeaks,
|
||||
EmptyPhrase = emptyPhrase,
|
||||
OrphanAuthored = orphanAuthored,
|
||||
MissingInterest = missingInterest,
|
||||
OrphanInterest = orphanInterest,
|
||||
Detail =
|
||||
$"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} "
|
||||
+ $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} "
|
||||
+ $"passed={passed}"
|
||||
+ $"missingInterest={missingInterest} orphanInterest={orphanInterest} passed={passed}"
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,17 +26,17 @@ public static class ActivityStatusProse
|
|||
["dash"] = ("Dashes forward", "Ends the dash"),
|
||||
["dodge"] = ("Dodges aside", "Stops dodging"),
|
||||
["drowning"] = ("Starts drowning", "Gets air again"),
|
||||
["egg"] = ("Turns into an egg", "Hatches from the egg"),
|
||||
["egg"] = ("Becomes an egg", "Hatches from an egg"),
|
||||
["enchanted"] = ("Becomes enchanted", "Loses the enchantment"),
|
||||
["fell_in_love"] = ("Falls in love", "Lets the infatuation pass"),
|
||||
["festive_spirit"] = ("Catches the festive spirit", "Settles out of the festive mood"),
|
||||
["flicked"] = ("Gets flicked", "Recovers from the flick"),
|
||||
["frozen"] = ("Freezes solid", "Thaws out"),
|
||||
["had_bad_dream"] = ("Wakes from a bad dream", "Shakes off the bad dream"),
|
||||
["had_good_dream"] = ("Wakes from a good dream", "Lets the good dream fade"),
|
||||
["had_nightmare"] = ("Wakes from a nightmare", "Shakes off the nightmare"),
|
||||
["had_bad_dream"] = ("Has a bad dream", "Lets a bad dream fade"),
|
||||
["had_good_dream"] = ("Has a good dream", "Lets a good dream fade"),
|
||||
["had_nightmare"] = ("Has a nightmare", "Lets a nightmare fade"),
|
||||
["handsome_migrant"] = ("Turns heads as a handsome migrant", "Stops drawing migrant attention"),
|
||||
["inspired"] = ("Feels inspired", "Loses the spark of inspiration"),
|
||||
["inspired"] = ("Feels inspired", "Comes down from inspiration"),
|
||||
["invincible"] = ("Becomes invincible", "Loses invincibility"),
|
||||
["just_ate"] = ("Just finished eating", "Is ready to eat again"),
|
||||
["laughing"] = ("Bursts out laughing", "Stops laughing"),
|
||||
|
|
|
|||
368
IdleSpectator/ActorRelation.cs
Normal file
368
IdleSpectator/ActorRelation.cs
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Live-game relation resolver. Labels that claim a second party must resolve through here.
|
||||
/// </summary>
|
||||
public static class ActorRelation
|
||||
{
|
||||
public static Actor GetLover(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!actor.hasLover())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through to field/property
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Actor lover = null;
|
||||
try
|
||||
{
|
||||
lover = actor.lover;
|
||||
}
|
||||
catch
|
||||
{
|
||||
lover = null;
|
||||
}
|
||||
|
||||
if (lover == null)
|
||||
{
|
||||
lover = ReadActorMember(actor, "lover", "get_lover", "getLover");
|
||||
}
|
||||
|
||||
if (lover != null && lover.isAlive() && lover != actor)
|
||||
{
|
||||
return lover;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool TryGetLover(Actor actor, out Actor lover)
|
||||
{
|
||||
lover = GetLover(actor);
|
||||
return lover != null;
|
||||
}
|
||||
|
||||
public static Actor GetBestFriend(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
long id = 0;
|
||||
object data = actor.data;
|
||||
if (data != null)
|
||||
{
|
||||
object raw = data.GetType().GetProperty("best_friend_id")?.GetValue(data, null)
|
||||
?? data.GetType().GetField("best_friend_id")?.GetValue(data);
|
||||
if (raw is long l)
|
||||
{
|
||||
id = l;
|
||||
}
|
||||
else if (raw is int i)
|
||||
{
|
||||
id = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (id == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindUnitById(id);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>First living peer in the same family (not self).</summary>
|
||||
public static Actor FirstFamilyPeer(Actor actor)
|
||||
{
|
||||
foreach (Actor peer in EnumerateFamilyMembers(actor, max: 8))
|
||||
{
|
||||
if (peer != null && peer != actor && peer.isAlive())
|
||||
{
|
||||
return peer;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static IEnumerable<Actor> EnumerateFamilyMembers(Actor actor, int max = 12)
|
||||
{
|
||||
if (actor == null || max <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
object family = ReadMember(actor, "family", "get_family", "getFamily");
|
||||
if (family == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
int yielded = 0;
|
||||
foreach (Actor member in EnumerateActorsFromMeta(family))
|
||||
{
|
||||
if (member == null || !member.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return member;
|
||||
yielded++;
|
||||
if (yielded >= max)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<Actor> EnumerateChildren(Actor actor, int max = 12)
|
||||
{
|
||||
if (actor == null || max <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
IEnumerable children = null;
|
||||
try
|
||||
{
|
||||
MethodInfo getChildren = typeof(Actor).GetMethod("getChildren", Type.EmptyTypes);
|
||||
if (getChildren != null)
|
||||
{
|
||||
children = getChildren.Invoke(actor, null) as IEnumerable;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
children = null;
|
||||
}
|
||||
|
||||
if (children == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
int yielded = 0;
|
||||
foreach (object raw in children)
|
||||
{
|
||||
Actor child = raw as Actor ?? ResolveActor(raw);
|
||||
if (child == null || !child.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return child;
|
||||
yielded++;
|
||||
if (yielded >= max)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a related unit for pack/family labels. Prefers lover, then family peer, then child.
|
||||
/// </summary>
|
||||
public static Actor ResolvePackRelated(Actor subject)
|
||||
{
|
||||
if (subject == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Actor lover = GetLover(subject);
|
||||
if (lover != null)
|
||||
{
|
||||
return lover;
|
||||
}
|
||||
|
||||
Actor peer = FirstFamilyPeer(subject);
|
||||
if (peer != null)
|
||||
{
|
||||
return peer;
|
||||
}
|
||||
|
||||
foreach (Actor child in EnumerateChildren(subject, max: 1))
|
||||
{
|
||||
return child;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Actor ResolveActor(object raw)
|
||||
{
|
||||
if (raw == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw is Actor actor)
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (raw is BaseSimObject sim && sim.isActor())
|
||||
{
|
||||
return sim.a;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
return ReadActorMember(raw, "actor", "Actor", "a", "getActor", "GetActor");
|
||||
}
|
||||
|
||||
private static IEnumerable<Actor> EnumerateActorsFromMeta(object meta)
|
||||
{
|
||||
if (meta == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Common: units / members / getUnits / getSimpleList
|
||||
object list = ReadMember(meta, "units", "members", "getUnits", "getSimpleList", "getList");
|
||||
if (list is IEnumerable enumerable)
|
||||
{
|
||||
foreach (object raw in enumerable)
|
||||
{
|
||||
Actor a = ResolveActor(raw);
|
||||
if (a != null)
|
||||
{
|
||||
yield return a;
|
||||
}
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
Actor founder = ReadActorMember(meta, "founder", "creator", "leader", "getFounder");
|
||||
if (founder != null)
|
||||
{
|
||||
yield return founder;
|
||||
}
|
||||
}
|
||||
|
||||
private static Actor FindUnitById(long id)
|
||||
{
|
||||
if (id == 0 || World.world?.units == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Actor unit in World.world.units)
|
||||
{
|
||||
if (unit == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (unit.getID() == id && unit.isAlive())
|
||||
{
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Actor ReadActorMember(object target, params string[] names)
|
||||
{
|
||||
object raw = ReadMember(target, names);
|
||||
return raw as Actor;
|
||||
}
|
||||
|
||||
private static object ReadMember(object target, params string[] names)
|
||||
{
|
||||
if (target == null || names == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Type type = target as Type ?? target.GetType();
|
||||
object instance = target is Type ? null : target;
|
||||
foreach (string name in names)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PropertyInfo prop = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
if (prop != null)
|
||||
{
|
||||
return prop.GetValue(instance, null);
|
||||
}
|
||||
|
||||
FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
if (field != null)
|
||||
{
|
||||
return field.GetValue(instance);
|
||||
}
|
||||
|
||||
MethodInfo method = type.GetMethod(name, Type.EmptyTypes);
|
||||
if (method != null)
|
||||
{
|
||||
return method.Invoke(instance, null);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// try next
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -648,6 +648,39 @@ public static class AgentHarness
|
|||
break;
|
||||
}
|
||||
|
||||
case "hatch_emit":
|
||||
{
|
||||
Actor focus = ResolveUnit(
|
||||
string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset);
|
||||
if (focus == null)
|
||||
{
|
||||
focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
}
|
||||
|
||||
bool alive = false;
|
||||
try
|
||||
{
|
||||
alive = focus != null && focus.isAlive();
|
||||
}
|
||||
catch
|
||||
{
|
||||
alive = false;
|
||||
}
|
||||
|
||||
if (alive)
|
||||
{
|
||||
InterestFeeds.EmitHatch(focus, "harness");
|
||||
_cmdOk++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_cmdFail++;
|
||||
}
|
||||
|
||||
Emit(cmd, alive, detail: alive ? EventReason.Hatch(focus) : "no unit");
|
||||
break;
|
||||
}
|
||||
|
||||
case "status_clear_all":
|
||||
{
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
|
|
@ -1797,6 +1830,14 @@ public static class AgentHarness
|
|||
DoTriggerInterest(cmd);
|
||||
break;
|
||||
|
||||
case "world_log_feed":
|
||||
DoWorldLogFeed(cmd);
|
||||
break;
|
||||
|
||||
case "domain_feed":
|
||||
DoDomainFeed(cmd);
|
||||
break;
|
||||
|
||||
case "interest_inject":
|
||||
DoInterestInject(cmd);
|
||||
break;
|
||||
|
|
@ -1877,11 +1918,17 @@ public static class AgentHarness
|
|||
{
|
||||
float ago = ParseFloat(cmd.value, 1f);
|
||||
InterestDirector.HarnessMarkInactive(ago);
|
||||
// Rebuild dossier so orange reason clears under quiet_grace / inactive dwell.
|
||||
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
|
||||
{
|
||||
WatchCaption.SetFromActor(MoveCamera._focus_unit);
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"inactive_for={ago:0.##}s active={InterestDirector.CurrentIsActive} key={InterestDirector.CurrentKey}");
|
||||
detail: $"inactive_for={ago:0.##}s active={InterestDirector.CurrentIsActive} key={InterestDirector.CurrentKey} reason='{WatchCaption.Current?.ReasonLine}'");
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -2418,33 +2465,428 @@ public static class AgentHarness
|
|||
AssetId = follow.asset != null ? follow.asset.id : assetId
|
||||
};
|
||||
|
||||
bool forceCamera = tierLead == InterestLeadKind.EventLed
|
||||
&& tierEvt >= InterestScoringConfig.W.noticeScoreMin;
|
||||
bool eventLedNotice = tierLead == InterestLeadKind.EventLed
|
||||
&& tierEvt >= InterestScoringConfig.W.noticeScoreMin;
|
||||
InterestCandidate registered = InterestFeeds.RegisterHarness(
|
||||
follow,
|
||||
interest.Label,
|
||||
keySuffix: (interest.Label ?? "scene").Replace(" ", "_"),
|
||||
lead: tierLead,
|
||||
completion: InterestCompletionKind.FixedDwell,
|
||||
forceActive: forceCamera,
|
||||
forceActive: false,
|
||||
eventStrength: tierEvt,
|
||||
maxWatch: tierEvt >= 95f ? 40f : 25f);
|
||||
if (registered == null)
|
||||
{
|
||||
InterestCollector.EnqueueDirect(interest);
|
||||
}
|
||||
else if (forceCamera)
|
||||
else if (eventLedNotice)
|
||||
{
|
||||
// Event-led Action/Epic tips may take the camera; character-led Story/curiosity stay pending.
|
||||
InterestDirector.HarnessForceSession(registered);
|
||||
CameraDirector.Watch(registered.ToInterestEvent());
|
||||
// Only force the camera when score-margin cut-in would allow it (matches director policy).
|
||||
InterestScoring.ScoreCheap(registered);
|
||||
float cur = InterestDirector.CurrentScore;
|
||||
bool noCurrent = InterestDirector.CurrentCandidate == null;
|
||||
bool marginCut = registered.TotalScore >= cur + InterestScoringConfig.W.cutInMargin;
|
||||
if (noCurrent || marginCut)
|
||||
{
|
||||
InterestDirector.HarnessForceSession(registered);
|
||||
CameraDirector.Watch(registered.ToInterestEvent());
|
||||
}
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
Emit(
|
||||
cmd,
|
||||
ok: true,
|
||||
detail: $"triggered label={tipLabel} evt={tierEvt:0.#} forced={forceCamera}");
|
||||
detail: $"triggered label={tipLabel} evt={tierEvt:0.#} forced={InterestDirector.CurrentKey != null && InterestDirector.CurrentKey.IndexOf((interest.Label ?? "").Replace(" ", "_"), System.StringComparison.OrdinalIgnoreCase) >= 0}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed a live WorldLog asset id through InterestFeeds as if WorldLog fired (coverage of authored catalog).
|
||||
/// </summary>
|
||||
private static void DoWorldLogFeed(HarnessCommand cmd)
|
||||
{
|
||||
if (!WorldReady())
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "world_not_ready");
|
||||
return;
|
||||
}
|
||||
|
||||
string assetId = string.IsNullOrEmpty(cmd.asset) ? (cmd.value ?? "") : cmd.asset;
|
||||
if (string.IsNullOrEmpty(assetId) || assetId == "auto")
|
||||
{
|
||||
assetId = "disaster_tornado";
|
||||
}
|
||||
|
||||
if (!EventCatalog.WorldLog.HasAuthored(assetId))
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "not_authored:" + assetId);
|
||||
return;
|
||||
}
|
||||
|
||||
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(assetId);
|
||||
Actor follow = ResolveUnit(null) ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
||||
if (follow == null && entry.CreatesInterest)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no unit for world_log_feed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"skipped_no_interest id={assetId}");
|
||||
return;
|
||||
}
|
||||
|
||||
string kingdom = follow.kingdom != null ? (follow.kingdom.name ?? "Alpha") : "Alpha";
|
||||
string key = "worldlog:" + assetId + ":" + kingdom;
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = entry.Category,
|
||||
Source = "worldlog",
|
||||
EventStrength = entry.EventStrength,
|
||||
VisualConfidence = 0.7f,
|
||||
Position = follow.current_position,
|
||||
FollowUnit = follow,
|
||||
SubjectId = follow.getID(),
|
||||
Label = entry.MakeLabel(kingdom, "Beta"),
|
||||
AssetId = assetId,
|
||||
KingdomKey = kingdom,
|
||||
SpeciesId = follow.asset != null ? follow.asset.id : "",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 40f,
|
||||
MinWatch = entry.MinWatch,
|
||||
MaxWatch = entry.MaxWatch,
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"fed id={assetId} key={key} evt={entry.EventStrength:0.#}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Injects a Signal from a domain catalog while the harness is Busy
|
||||
/// (domain feeds themselves early-out on Busy, so register here).
|
||||
/// asset = domain, value = event/asset id.
|
||||
/// </summary>
|
||||
private static void DoDomainFeed(HarnessCommand cmd)
|
||||
{
|
||||
if (!WorldReady())
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "world_not_ready");
|
||||
return;
|
||||
}
|
||||
|
||||
string domain = (cmd.asset ?? "").Trim().ToLowerInvariant();
|
||||
string id = (cmd.value ?? cmd.expect ?? "").Trim();
|
||||
Actor unit = ResolveUnit(null) ?? WorldActivityScanner.FindNearestAliveUnit(CameraPos(), 200f);
|
||||
if (unit == null && domain != "era")
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "no unit for domain_feed");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DiscreteEventEntry entry;
|
||||
string key;
|
||||
string label;
|
||||
float strength;
|
||||
string category;
|
||||
string source;
|
||||
|
||||
switch (domain)
|
||||
{
|
||||
case "relationship":
|
||||
case "rel":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "set_lover";
|
||||
}
|
||||
|
||||
entry = EventCatalog.Relationship.GetOrFallback(id);
|
||||
Actor related = ActorRelation.ResolvePackRelated(unit)
|
||||
?? WorldActivityScanner.FindNearestAliveUnit(unit.current_position, 40f);
|
||||
if (related == unit)
|
||||
{
|
||||
related = null;
|
||||
}
|
||||
|
||||
key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(unit) + ":"
|
||||
+ EventFeedUtil.SafeId(related);
|
||||
label = entry.MakeLabel(
|
||||
EventFeedUtil.SafeName(unit),
|
||||
EventFeedUtil.SafeName(related));
|
||||
strength = entry.EventStrength;
|
||||
category = entry.Category;
|
||||
source = "relationship";
|
||||
InterestCandidate relReg = EventFeedUtil.Register(
|
||||
key,
|
||||
source,
|
||||
category,
|
||||
label,
|
||||
id,
|
||||
strength,
|
||||
unit.current_position,
|
||||
unit,
|
||||
related: related);
|
||||
if (relReg == null)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "register_failed domain=relationship id=" + id);
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"domain=relationship id={id} key={key}");
|
||||
return;
|
||||
case "plot":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "new_war";
|
||||
}
|
||||
|
||||
entry = EventCatalog.Plot.GetOrFallback(id);
|
||||
key = "plot:" + entry.Id + ":new:" + EventFeedUtil.SafeId(unit);
|
||||
label = entry.MakeLabel(EventFeedUtil.SafeName(unit), "new");
|
||||
strength = entry.EventStrength;
|
||||
category = entry.Category;
|
||||
source = "plot";
|
||||
break;
|
||||
case "era":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "age_chaos";
|
||||
}
|
||||
|
||||
entry = EventCatalog.Era.GetOrFallback(id);
|
||||
key = "era:" + entry.Id;
|
||||
label = entry.MakeLabel("", "");
|
||||
strength = entry.EventStrength;
|
||||
category = entry.Category;
|
||||
source = "era";
|
||||
Vector3 eraPos = unit != null
|
||||
? unit.current_position
|
||||
: (Camera.main != null
|
||||
? new Vector3(Camera.main.transform.position.x, Camera.main.transform.position.y, 0f)
|
||||
: new Vector3(1f, 1f, 0f));
|
||||
InterestCandidate eraReg = EventFeedUtil.Register(
|
||||
key,
|
||||
source,
|
||||
category,
|
||||
label,
|
||||
id,
|
||||
strength,
|
||||
eraPos,
|
||||
follow: unit,
|
||||
locationOnly: unit == null);
|
||||
if (eraReg == null)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "register_failed domain=era id=" + id);
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"domain=era id={id} key={key}");
|
||||
return;
|
||||
case "book":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "history_book";
|
||||
}
|
||||
|
||||
entry = EventCatalog.Book.GetOrFallback(id);
|
||||
key = "book:" + entry.Id + ":new:" + EventFeedUtil.SafeId(unit);
|
||||
label = entry.MakeLabel(EventFeedUtil.SafeName(unit), "new");
|
||||
strength = entry.EventStrength;
|
||||
category = entry.Category;
|
||||
source = "book";
|
||||
break;
|
||||
case "trait":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "immortal";
|
||||
}
|
||||
|
||||
entry = EventCatalog.Trait.GetOrFallback(id);
|
||||
key = "trait:" + entry.Id + ":gains:" + EventFeedUtil.SafeId(unit);
|
||||
label = EventFeedUtil.SafeName(unit) + " gains " + entry.Id;
|
||||
strength = entry.EventStrength;
|
||||
category = entry.Category;
|
||||
source = "trait";
|
||||
break;
|
||||
case "meta":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "new_city";
|
||||
}
|
||||
|
||||
key = "meta:" + id + ":" + EventFeedUtil.SafeId(unit);
|
||||
label = "Meta: " + id + " (" + EventFeedUtil.SafeName(unit) + ")";
|
||||
strength = 74f;
|
||||
category = "Politics";
|
||||
source = "meta";
|
||||
break;
|
||||
case "boat":
|
||||
id = "boat_unload";
|
||||
key = "boat:boat_unload:" + EventFeedUtil.SafeId(unit);
|
||||
label = "Boat unloads: " + EventFeedUtil.SafeName(unit);
|
||||
strength = 72f;
|
||||
category = "Travel";
|
||||
source = "boat";
|
||||
break;
|
||||
case "building":
|
||||
id = "building_damage";
|
||||
key = "building:building_damage:" + EventFeedUtil.SafeId(unit);
|
||||
label = "Damages a building: " + EventFeedUtil.SafeName(unit);
|
||||
strength = 70f;
|
||||
category = "Spectacle";
|
||||
source = "building";
|
||||
break;
|
||||
case "spell":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "summon_lightning";
|
||||
}
|
||||
|
||||
{
|
||||
DiscreteEventEntry spellEntry = EventCatalog.Spell.GetOrFallback(id);
|
||||
key = "spell:" + spellEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
||||
label = spellEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
||||
strength = spellEntry.EventStrength;
|
||||
category = spellEntry.Category;
|
||||
source = "spell";
|
||||
id = spellEntry.Id;
|
||||
}
|
||||
|
||||
break;
|
||||
case "item":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "sword";
|
||||
}
|
||||
|
||||
{
|
||||
DiscreteEventEntry itemEntry = EventCatalog.Item.GetOrFallback(id);
|
||||
key = "item:" + itemEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
||||
label = itemEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
||||
strength = itemEntry.EventStrength;
|
||||
category = itemEntry.Category;
|
||||
source = "item";
|
||||
id = itemEntry.Id;
|
||||
}
|
||||
|
||||
break;
|
||||
case "decision":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "declare_war";
|
||||
}
|
||||
|
||||
{
|
||||
DiscreteEventEntry decEntry = EventCatalog.Decision.GetOrFallback(id);
|
||||
key = "decision:" + decEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
||||
label = decEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
||||
strength = decEntry.EventStrength;
|
||||
category = decEntry.Category;
|
||||
source = "decision";
|
||||
id = decEntry.Id;
|
||||
}
|
||||
|
||||
break;
|
||||
case "power":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "lightning";
|
||||
}
|
||||
|
||||
{
|
||||
DiscreteEventEntry powEntry = EventCatalog.Power.GetOrFallback(id);
|
||||
key = "power:" + powEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
||||
label = powEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
||||
strength = powEntry.EventStrength;
|
||||
category = powEntry.Category;
|
||||
source = "power";
|
||||
id = powEntry.Id;
|
||||
}
|
||||
|
||||
break;
|
||||
case "subspecies_trait":
|
||||
case "subspecies":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "biome_trait";
|
||||
}
|
||||
|
||||
{
|
||||
DiscreteEventEntry stEntry = EventCatalog.SubspeciesTrait.GetOrFallback(id);
|
||||
key = "subspecies_trait:" + stEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
||||
label = stEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
||||
strength = stEntry.EventStrength;
|
||||
category = stEntry.Category;
|
||||
source = "subspecies_trait";
|
||||
id = stEntry.Id;
|
||||
}
|
||||
|
||||
break;
|
||||
case "gene":
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "warfare_1";
|
||||
}
|
||||
|
||||
{
|
||||
DiscreteEventEntry geneEntry = EventCatalog.Gene.GetOrFallback(id);
|
||||
key = "gene:" + geneEntry.Id + ":" + EventFeedUtil.SafeId(unit);
|
||||
label = geneEntry.MakeLabel(EventFeedUtil.SafeName(unit), "");
|
||||
strength = geneEntry.EventStrength;
|
||||
category = geneEntry.Category;
|
||||
source = "gene";
|
||||
id = geneEntry.Id;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "unknown domain=" + domain);
|
||||
return;
|
||||
}
|
||||
|
||||
InterestCandidate registered = EventFeedUtil.Register(
|
||||
key,
|
||||
source,
|
||||
category,
|
||||
label,
|
||||
id,
|
||||
strength,
|
||||
unit.current_position,
|
||||
unit);
|
||||
if (registered == null)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "register_failed domain=" + domain + " id=" + id);
|
||||
return;
|
||||
}
|
||||
|
||||
_cmdOk++;
|
||||
Emit(cmd, ok: true, detail: $"domain={domain} id={id} key={key}");
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
_cmdFail++;
|
||||
Emit(cmd, ok: false, detail: "domain_feed error: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DoInterestInject(HarnessCommand cmd)
|
||||
|
|
@ -2691,18 +3133,48 @@ public static class AgentHarness
|
|||
|
||||
float tierEvt = EventStrengthFromTierHint(cmd.tier, 70f);
|
||||
InterestLeadKind tierLead = LeadFromTierHint(cmd.tier, InterestLeadKind.EventLed);
|
||||
ParseInterestInjectOptions(
|
||||
cmd.value,
|
||||
out bool forceIgnored,
|
||||
out InterestLeadKind? leadOpt,
|
||||
out float eventStrength,
|
||||
out float charSigIgnored,
|
||||
out float maxWatchIgnored,
|
||||
out float ttlIgnored,
|
||||
out bool resumableIgnored,
|
||||
out int participantsIgnored,
|
||||
out int notablesIgnored);
|
||||
_ = forceIgnored;
|
||||
_ = charSigIgnored;
|
||||
_ = maxWatchIgnored;
|
||||
_ = ttlIgnored;
|
||||
_ = resumableIgnored;
|
||||
_ = participantsIgnored;
|
||||
_ = notablesIgnored;
|
||||
InterestLeadKind lead = leadOpt ?? tierLead;
|
||||
if (eventStrength < 0f)
|
||||
{
|
||||
eventStrength = tierEvt;
|
||||
}
|
||||
|
||||
string label = string.IsNullOrEmpty(cmd.label) ? ("Force " + (cmd.tier ?? "scene")) : cmd.label;
|
||||
string focusName = SafeName(follow);
|
||||
if (!string.IsNullOrEmpty(focusName))
|
||||
{
|
||||
label = label.Replace("{a}", focusName);
|
||||
}
|
||||
|
||||
string keySuffix = string.IsNullOrEmpty(cmd.expect) ? "force_" + label.Replace(" ", "_") : cmd.expect;
|
||||
InterestCandidate c = InterestFeeds.RegisterHarness(
|
||||
follow,
|
||||
label,
|
||||
keySuffix,
|
||||
lead: tierLead,
|
||||
lead: lead,
|
||||
completion: InterestCompletionKind.Manual,
|
||||
forceActive: true,
|
||||
eventStrength: tierEvt > 0f ? tierEvt : 70f,
|
||||
eventStrength: eventStrength > 0f ? eventStrength : 70f,
|
||||
maxWatch: 60f);
|
||||
if (c != null && tierLead == InterestLeadKind.CharacterLed)
|
||||
if (c != null && lead == InterestLeadKind.CharacterLed)
|
||||
{
|
||||
c.Category = "CharacterVignette";
|
||||
InterestScoring.ScoreCheap(c);
|
||||
|
|
@ -3249,6 +3721,46 @@ public static class AgentHarness
|
|||
detail = $"needle_absent_in_reason='{needle}' reason='{reason}' hit={reasonHit}";
|
||||
break;
|
||||
}
|
||||
case "reason_owns_focus":
|
||||
{
|
||||
// Reason must not name a different live unit than the owned active candidate.
|
||||
UnitDossier dossier = WatchCaption.Current;
|
||||
string reason = dossier?.ReasonLine ?? "";
|
||||
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
|
||||
InterestCandidate owned = focus != null
|
||||
? InterestDirector.TryGetOwnedReasonCandidate(focus)
|
||||
: null;
|
||||
if (string.IsNullOrEmpty(reason))
|
||||
{
|
||||
pass = true;
|
||||
detail = "empty_reason_ok active=" + InterestDirector.CurrentIsActive;
|
||||
break;
|
||||
}
|
||||
|
||||
if (owned == null || focus == null)
|
||||
{
|
||||
pass = false;
|
||||
detail = $"reason_without_owned_active focus={(focus != null)} active={InterestDirector.CurrentIsActive} reason='{reason}'";
|
||||
break;
|
||||
}
|
||||
|
||||
string stranger = FindStrangerNamedInReason(reason, owned);
|
||||
pass = string.IsNullOrEmpty(stranger);
|
||||
detail = pass
|
||||
? $"owned reason='{reason}'"
|
||||
: $"stranger_in_reason='{stranger}' reason='{reason}'";
|
||||
break;
|
||||
}
|
||||
case "reason_empty":
|
||||
{
|
||||
UnitDossier dossier = WatchCaption.Current;
|
||||
string reason = dossier?.ReasonLine ?? "";
|
||||
bool wantEmpty = string.IsNullOrEmpty(cmd.value) || ParseBool(cmd.value, true);
|
||||
bool isEmpty = string.IsNullOrEmpty(reason);
|
||||
pass = isEmpty == wantEmpty;
|
||||
detail = $"reason='{reason}' empty={isEmpty} wantEmpty={wantEmpty} active={InterestDirector.CurrentIsActive} quiet={InterestDirector.InQuietGrace}";
|
||||
break;
|
||||
}
|
||||
case "dossier_task_refresh":
|
||||
{
|
||||
// Stale focus snapshot must recover: blank dossier TaskText, then sync from live AI.
|
||||
|
|
@ -3970,6 +4482,36 @@ public static class AgentHarness
|
|||
detail = audit.Detail;
|
||||
break;
|
||||
}
|
||||
case "world_log_audit":
|
||||
{
|
||||
WorldLogEventAuditResult audit = WorldLogEventHarness.RunAudit(HarnessDir());
|
||||
pass = audit.Passed;
|
||||
detail = audit.Detail;
|
||||
break;
|
||||
}
|
||||
case "disaster_war_inventory":
|
||||
case "disaster_war_audit":
|
||||
{
|
||||
DisasterWarAuditResult audit = DisasterWarHarness.RunAudit(HarnessDir());
|
||||
WorldLogEventHarness.DumpDisasterWarInventory(HarnessDir());
|
||||
pass = audit.Passed;
|
||||
detail = audit.Detail;
|
||||
break;
|
||||
}
|
||||
case "domain_event_audit":
|
||||
{
|
||||
DomainEventAuditResult audit = DomainEventHarness.RunAudit(HarnessDir());
|
||||
pass = audit.Passed;
|
||||
detail = audit.Detail;
|
||||
break;
|
||||
}
|
||||
case "mutation_discovery":
|
||||
{
|
||||
MutationDiscoveryResult discovery = MutationDiscoveryHarness.Run(HarnessDir());
|
||||
pass = discovery.Passed;
|
||||
detail = discovery.Detail;
|
||||
break;
|
||||
}
|
||||
case "activity_happiness_count":
|
||||
{
|
||||
int want = ParseCountExpect(cmd, defaultValue: 1);
|
||||
|
|
@ -5613,6 +6155,64 @@ public static class AgentHarness
|
|||
return actor.asset != null ? actor.asset.id : "unit";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the reason line names a living unit that is not the scene subject/related, return that name.
|
||||
/// </summary>
|
||||
private static string FindStrangerNamedInReason(string reason, InterestCandidate scene)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
|
||||
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
|
||||
try
|
||||
{
|
||||
foreach (Actor unit in World.world.units)
|
||||
{
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string name = SafeName(unit);
|
||||
if (string.IsNullOrEmpty(name) || name.Length < 3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(subjectName)
|
||||
&& name.Equals(subjectName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(relatedName)
|
||||
&& name.Equals(relatedName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reason.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
private static float EventStrengthFromTierHint(string raw, float fallback)
|
||||
{
|
||||
|
|
@ -5628,11 +6228,13 @@ public static class AgentHarness
|
|||
case "curiosity":
|
||||
return 40f;
|
||||
case "action":
|
||||
return 70f;
|
||||
// Clears cutInMargin (35) over enriched Curiosity CharacterLed (~64).
|
||||
return 100f;
|
||||
case "story":
|
||||
return 80f;
|
||||
case "epic":
|
||||
return 100f;
|
||||
// Clears cutInMargin over Action (~108 TotalScore).
|
||||
return 150f;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public static class CameraDirector
|
|||
}
|
||||
|
||||
string tip = FormatWatchTip(interest);
|
||||
// LastWatchLabel is tip/harness telemetry only - never dossier reason truth.
|
||||
LastWatchLabel = interest.Label ?? "";
|
||||
LastWatchAssetId = interest.AssetId ?? "";
|
||||
LogService.LogInfo($"[IdleSpectator] {tip}");
|
||||
|
|
@ -42,17 +43,13 @@ public static class CameraDirector
|
|||
? interest.FollowUnit
|
||||
: null;
|
||||
|
||||
// Never attach a random nearby unit to a "new species" tip - that caused ghost focuses
|
||||
// (e.g. tip says crab, camera follows a cat).
|
||||
// Species discovery: only attach a species-matched unit, never a random stranger.
|
||||
if (follow == null && !string.IsNullOrEmpty(interest.AssetId) && interest.AssetId != "live_battle"
|
||||
&& interest.AssetId != "scored_unit" && interest.Label != null && interest.Label.StartsWith("New species:"))
|
||||
{
|
||||
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f, interest.AssetId);
|
||||
}
|
||||
else if (follow == null)
|
||||
{
|
||||
follow = WorldActivityScanner.FindNearestAliveUnit(interest.Position, 24f);
|
||||
}
|
||||
// Location-only civic: pan only. Do not invent a stranger FollowUnit.
|
||||
|
||||
if (follow != null && follow.isAlive())
|
||||
{
|
||||
|
|
@ -74,7 +71,7 @@ public static class CameraDirector
|
|||
return;
|
||||
}
|
||||
|
||||
// No unit available: pan only if we are not already following someone.
|
||||
// No owned unit: pan only if we are not already following someone.
|
||||
// Never clear an existing focus unit here - that flashes the power bar.
|
||||
if (!MoveCamera.hasFocusUnit())
|
||||
{
|
||||
|
|
|
|||
269
IdleSpectator/CatalogCoverageAudit.cs
Normal file
269
IdleSpectator/CatalogCoverageAudit.cs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public sealed class CatalogCoverageDiff
|
||||
{
|
||||
public string Domain = "";
|
||||
public int LiveTotal;
|
||||
public int AuthoredTotal;
|
||||
public int Missing;
|
||||
public int Orphan;
|
||||
public readonly List<string> MissingIds = new List<string>();
|
||||
public readonly List<string> OrphanIds = new List<string>();
|
||||
|
||||
public bool Passed => Missing == 0 && Orphan == 0;
|
||||
|
||||
public string Detail =>
|
||||
$"{Domain}: live={LiveTotal} authored={AuthoredTotal} missing={Missing} orphan={Orphan}";
|
||||
}
|
||||
|
||||
/// <summary>Shared live-vs-authored coverage audit used by domain event catalogs.</summary>
|
||||
public static class CatalogCoverageAudit
|
||||
{
|
||||
public static CatalogCoverageDiff Diff(
|
||||
string domain,
|
||||
IEnumerable<string> liveIds,
|
||||
IEnumerable<string> authoredIds)
|
||||
{
|
||||
var live = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var authored = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var diff = new CatalogCoverageDiff { Domain = domain ?? "domain" };
|
||||
|
||||
if (liveIds != null)
|
||||
{
|
||||
foreach (string id in liveIds)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
live.Add(id.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (authoredIds != null)
|
||||
{
|
||||
foreach (string id in authoredIds)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
authored.Add(id.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
diff.LiveTotal = live.Count;
|
||||
diff.AuthoredTotal = authored.Count;
|
||||
|
||||
foreach (string id in live)
|
||||
{
|
||||
if (!authored.Contains(id))
|
||||
{
|
||||
diff.Missing++;
|
||||
diff.MissingIds.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string id in authored)
|
||||
{
|
||||
if (!live.Contains(id))
|
||||
{
|
||||
diff.Orphan++;
|
||||
diff.OrphanIds.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
public static void WriteReport(string outputDirectory, string fileName, string body)
|
||||
{
|
||||
if (string.IsNullOrEmpty(outputDirectory) || string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
File.WriteAllText(Path.Combine(outputDirectory, fileName), body ?? "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore IO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DomainEventAuditResult
|
||||
{
|
||||
public bool Passed;
|
||||
public string Detail = "";
|
||||
}
|
||||
|
||||
/// <summary>Coverage audits for relationship discrete events + live plot/era/book libraries.</summary>
|
||||
public static class DomainEventHarness
|
||||
{
|
||||
public static DomainEventAuditResult RunAudit(string outputDirectory)
|
||||
{
|
||||
var tsv = new StringBuilder();
|
||||
tsv.AppendLine("domain\tid\tauthored\towns_camera\tevent_strength\tnotes");
|
||||
|
||||
CatalogCoverageDiff plots = CatalogCoverageAudit.Diff(
|
||||
"plots",
|
||||
ActivityAssetCatalog.EnumerateLivePlotIds(),
|
||||
EventCatalog.Plot.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "plots", ActivityAssetCatalog.EnumerateLivePlotIds(), plots, EventCatalog.Plot.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff eras = CatalogCoverageAudit.Diff(
|
||||
"eras",
|
||||
ActivityAssetCatalog.EnumerateLiveEraIds(),
|
||||
EventCatalog.Era.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "eras", ActivityAssetCatalog.EnumerateLiveEraIds(), eras, EventCatalog.Era.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff books = CatalogCoverageAudit.Diff(
|
||||
"books",
|
||||
ActivityAssetCatalog.EnumerateLiveBookTypeIds(),
|
||||
EventCatalog.Book.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "books", ActivityAssetCatalog.EnumerateLiveBookTypeIds(), books, EventCatalog.Book.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff traits = CatalogCoverageAudit.Diff(
|
||||
"traits",
|
||||
ActivityAssetCatalog.EnumerateLiveTraitIds(),
|
||||
EventCatalog.Trait.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "traits", ActivityAssetCatalog.EnumerateLiveTraitIds(), traits, EventCatalog.Trait.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff spells = CatalogCoverageAudit.Diff(
|
||||
"spells",
|
||||
ActivityAssetCatalog.EnumerateLiveSpellIds(),
|
||||
EventCatalog.Spell.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "spells", ActivityAssetCatalog.EnumerateLiveSpellIds(), spells, EventCatalog.Spell.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff powers = CatalogCoverageAudit.Diff(
|
||||
"powers",
|
||||
ActivityAssetCatalog.EnumerateLivePowerIds(),
|
||||
EventCatalog.Power.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "powers", ActivityAssetCatalog.EnumerateLivePowerIds(), powers, EventCatalog.Power.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff decisions = CatalogCoverageAudit.Diff(
|
||||
"decisions",
|
||||
ActivityAssetCatalog.EnumerateLiveDecisionIds(),
|
||||
EventCatalog.Decision.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "decisions", ActivityAssetCatalog.EnumerateLiveDecisionIds(), decisions, EventCatalog.Decision.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff items = CatalogCoverageAudit.Diff(
|
||||
"items",
|
||||
ActivityAssetCatalog.EnumerateLiveItemIds(),
|
||||
EventCatalog.Item.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "items", ActivityAssetCatalog.EnumerateLiveItemIds(), items, EventCatalog.Item.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff subspTraits = CatalogCoverageAudit.Diff(
|
||||
"subspecies_traits",
|
||||
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds(),
|
||||
EventCatalog.SubspeciesTrait.AuthoredIds);
|
||||
AppendLibraryDomain(
|
||||
tsv,
|
||||
"subspecies_traits",
|
||||
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds(),
|
||||
subspTraits,
|
||||
EventCatalog.SubspeciesTrait.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff genes = CatalogCoverageAudit.Diff(
|
||||
"genes",
|
||||
ActivityAssetCatalog.EnumerateLiveGeneIds(),
|
||||
EventCatalog.Gene.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "genes", ActivityAssetCatalog.EnumerateLiveGeneIds(), genes, EventCatalog.Gene.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff phenotypes = CatalogCoverageAudit.Diff(
|
||||
"phenotypes",
|
||||
ActivityAssetCatalog.EnumerateLivePhenotypeIds(),
|
||||
EventCatalog.Phenotype.AuthoredIds);
|
||||
AppendLibraryDomain(
|
||||
tsv,
|
||||
"phenotypes",
|
||||
ActivityAssetCatalog.EnumerateLivePhenotypeIds(),
|
||||
phenotypes,
|
||||
EventCatalog.Phenotype.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff worldLaws = CatalogCoverageAudit.Diff(
|
||||
"world_laws",
|
||||
ActivityAssetCatalog.EnumerateLiveWorldLawIds(),
|
||||
EventCatalog.WorldLaw.AuthoredIds);
|
||||
AppendLibraryDomain(
|
||||
tsv,
|
||||
"world_laws",
|
||||
ActivityAssetCatalog.EnumerateLiveWorldLawIds(),
|
||||
worldLaws,
|
||||
EventCatalog.WorldLaw.GetOrFallback);
|
||||
|
||||
CatalogCoverageDiff biomes = CatalogCoverageAudit.Diff(
|
||||
"biomes",
|
||||
ActivityAssetCatalog.EnumerateLiveBiomeIds(),
|
||||
EventCatalog.Biome.AuthoredIds);
|
||||
AppendLibraryDomain(tsv, "biomes", ActivityAssetCatalog.EnumerateLiveBiomeIds(), biomes, EventCatalog.Biome.GetOrFallback);
|
||||
|
||||
int relMissing = 0;
|
||||
int relTotal = 0;
|
||||
foreach (string id in EventCatalog.Relationship.AuthoredIds)
|
||||
{
|
||||
relTotal++;
|
||||
DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback(id);
|
||||
bool ok = EventCatalog.Relationship.HasAuthored(id) && !entry.IsFallback;
|
||||
if (!ok)
|
||||
{
|
||||
relMissing++;
|
||||
}
|
||||
|
||||
tsv.Append("relationship\t").Append(id).Append('\t')
|
||||
.Append(ok ? "1" : "0").Append('\t')
|
||||
.Append(entry.CreatesInterest).Append('\t')
|
||||
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
|
||||
.Append(ok ? "ok" : "missing_authored").AppendLine();
|
||||
}
|
||||
|
||||
CatalogCoverageAudit.WriteReport(outputDirectory, "domain_event_audit.tsv", tsv.ToString());
|
||||
|
||||
bool passed = plots.Passed && eras.Passed && books.Passed && traits.Passed
|
||||
&& spells.Passed && powers.Passed && decisions.Passed && items.Passed
|
||||
&& subspTraits.Passed && genes.Passed && phenotypes.Passed
|
||||
&& worldLaws.Passed && biomes.Passed
|
||||
&& relMissing == 0 && relTotal > 0;
|
||||
string detail =
|
||||
$"{plots.Detail}; {eras.Detail}; {books.Detail}; {traits.Detail}; "
|
||||
+ $"{spells.Detail}; {powers.Detail}; {decisions.Detail}; {items.Detail}; "
|
||||
+ $"{subspTraits.Detail}; {genes.Detail}; {phenotypes.Detail}; "
|
||||
+ $"{worldLaws.Detail}; {biomes.Detail}; relationship: authored={relTotal} missing={relMissing}";
|
||||
return new DomainEventAuditResult { Passed = passed, Detail = detail };
|
||||
}
|
||||
|
||||
private static void AppendLibraryDomain(
|
||||
StringBuilder tsv,
|
||||
string domain,
|
||||
IEnumerable<string> liveIds,
|
||||
CatalogCoverageDiff diff,
|
||||
Func<string, DiscreteEventEntry> lookup)
|
||||
{
|
||||
foreach (string id in liveIds)
|
||||
{
|
||||
DiscreteEventEntry entry = lookup(id);
|
||||
bool has = !entry.IsFallback;
|
||||
tsv.Append(domain).Append('\t').Append(id).Append('\t')
|
||||
.Append(has ? "1" : "0").Append('\t')
|
||||
.Append(entry.CreatesInterest).Append('\t')
|
||||
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
|
||||
.Append(has ? "ok" : "missing_authored").AppendLine();
|
||||
}
|
||||
|
||||
foreach (string id in diff.OrphanIds)
|
||||
{
|
||||
tsv.Append(domain).Append('\t').Append(id).Append("\t1\t\t0\torphan_authored").AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
139
IdleSpectator/DisasterWarHarness.cs
Normal file
139
IdleSpectator/DisasterWarHarness.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public sealed class DisasterWarAuditResult
|
||||
{
|
||||
public bool Passed;
|
||||
public string Detail = "";
|
||||
public int DisasterTotal;
|
||||
public int WarTypeTotal;
|
||||
public int MissingDisaster;
|
||||
public int OrphanDisaster;
|
||||
public int MissingWarType;
|
||||
public int OrphanWarType;
|
||||
}
|
||||
|
||||
/// <summary>Live disasters + war_types library coverage audit.</summary>
|
||||
public static class DisasterWarHarness
|
||||
{
|
||||
public static DisasterWarAuditResult RunAudit(string outputDirectory)
|
||||
{
|
||||
List<string> disasters = ActivityAssetCatalog.EnumerateLiveDisasterIds();
|
||||
List<string> warTypes = ActivityAssetCatalog.EnumerateLiveWarTypeIds();
|
||||
|
||||
var authoredDisasters = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string id in EventCatalog.Disaster.AuthoredIds)
|
||||
{
|
||||
authoredDisasters.Add(id);
|
||||
}
|
||||
|
||||
var authoredWars = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string id in EventCatalog.WarType.AuthoredIds)
|
||||
{
|
||||
authoredWars.Add(id);
|
||||
}
|
||||
|
||||
int missingDisaster = 0;
|
||||
int orphanDisaster = 0;
|
||||
int missingWarType = 0;
|
||||
int orphanWarType = 0;
|
||||
var tsv = new StringBuilder();
|
||||
tsv.AppendLine("kind\tid\tauthored\tevent_strength\tworld_log_id\tnotes");
|
||||
|
||||
var liveDisasters = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < disasters.Count; i++)
|
||||
{
|
||||
string id = disasters[i];
|
||||
liveDisasters.Add(id);
|
||||
DisasterInterestEntry entry = EventCatalog.Disaster.GetOrFallback(id);
|
||||
bool has = EventCatalog.Disaster.HasAuthored(id) && !entry.IsFallback;
|
||||
if (!has)
|
||||
{
|
||||
missingDisaster++;
|
||||
}
|
||||
|
||||
tsv.Append("disaster\t").Append(id).Append('\t')
|
||||
.Append(has ? "1" : "0").Append('\t')
|
||||
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
|
||||
.Append(entry.WorldLogId ?? "").Append('\t')
|
||||
.Append(has ? "ok" : "missing_authored").AppendLine();
|
||||
}
|
||||
|
||||
foreach (string id in authoredDisasters)
|
||||
{
|
||||
if (!liveDisasters.Contains(id))
|
||||
{
|
||||
orphanDisaster++;
|
||||
tsv.Append("disaster\t").Append(id).Append("\t1\t0\t\torphan_authored").AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
var liveWars = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < warTypes.Count; i++)
|
||||
{
|
||||
string id = warTypes[i];
|
||||
liveWars.Add(id);
|
||||
WarTypeInterestEntry entry = EventCatalog.WarType.GetOrFallback(id);
|
||||
bool has = EventCatalog.WarType.HasAuthored(id) && !entry.IsFallback;
|
||||
if (!has)
|
||||
{
|
||||
missingWarType++;
|
||||
}
|
||||
|
||||
tsv.Append("war_type\t").Append(id).Append('\t')
|
||||
.Append(has ? "1" : "0").Append('\t')
|
||||
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
|
||||
.Append('\t')
|
||||
.Append(has ? "ok" : "missing_authored").AppendLine();
|
||||
}
|
||||
|
||||
foreach (string id in authoredWars)
|
||||
{
|
||||
if (!liveWars.Contains(id))
|
||||
{
|
||||
orphanWarType++;
|
||||
tsv.Append("war_type\t").Append(id).Append("\t1\t0\t\torphan_authored").AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
File.WriteAllText(
|
||||
Path.Combine(outputDirectory, "disaster-war-audit.tsv"),
|
||||
tsv.ToString());
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
bool passed = disasters.Count > 0
|
||||
&& warTypes.Count > 0
|
||||
&& missingDisaster == 0
|
||||
&& orphanDisaster == 0
|
||||
&& missingWarType == 0
|
||||
&& orphanWarType == 0;
|
||||
|
||||
return new DisasterWarAuditResult
|
||||
{
|
||||
Passed = passed,
|
||||
DisasterTotal = disasters.Count,
|
||||
WarTypeTotal = warTypes.Count,
|
||||
MissingDisaster = missingDisaster,
|
||||
OrphanDisaster = orphanDisaster,
|
||||
MissingWarType = missingWarType,
|
||||
OrphanWarType = orphanWarType,
|
||||
Detail =
|
||||
$"disasters={disasters.Count} missingDisaster={missingDisaster} orphanDisaster={orphanDisaster} "
|
||||
+ $"war_types={warTypes.Count} missingWarType={missingWarType} orphanWarType={orphanWarType}"
|
||||
};
|
||||
}
|
||||
}
|
||||
69
IdleSpectator/Events/Catalogs/EventCatalog.Book.cs
Normal file
69
IdleSpectator/Events/Catalogs/EventCatalog.Book.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Authored interest overlay for every live book_types asset.</summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class Book
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static Book()
|
||||
{
|
||||
Add("family_story", 52f, "Culture", "{a} writes a family story");
|
||||
Add("love_story", 58f, "Culture", "{a} writes a love story");
|
||||
Add("friendship_story", 50f, "Culture", "{a} writes a friendship story");
|
||||
Add("bad_story_about_king", 62f, "Culture", "{a} writes a scandalous book");
|
||||
Add("fable", 48f, "Culture", "{a} writes a fable");
|
||||
Add("warfare_manual", 60f, "Culture", "{a} writes a warfare manual");
|
||||
Add("economy_manual", 48f, "Culture", "{a} writes an economy manual");
|
||||
Add("stewardship_manual", 48f, "Culture", "{a} writes a stewardship manual");
|
||||
Add("diplomacy_manual", 55f, "Culture", "{a} writes a diplomacy manual");
|
||||
Add("mathbook", 45f, "Culture", "{a} writes a math book");
|
||||
Add("biology_book", 45f, "Culture", "{a} writes a biology book");
|
||||
Add("history_book", 55f, "Culture", "{a} writes a history book");
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
string id,
|
||||
float strength,
|
||||
string category,
|
||||
string label)
|
||||
{
|
||||
Entries[id] = new DiscreteEventEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
Category = category,
|
||||
CreatesInterest = true,
|
||||
LabelTemplate = label
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => Entries.Keys;
|
||||
|
||||
public static bool HasAuthored(string id) =>
|
||||
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
|
||||
return new DiscreteEventEntry
|
||||
{
|
||||
Id = key,
|
||||
EventStrength = 45f,
|
||||
Category = "Culture",
|
||||
LabelTemplate = "{a} writes a book",
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
94
IdleSpectator/Events/Catalogs/EventCatalog.Disaster.cs
Normal file
94
IdleSpectator/Events/Catalogs/EventCatalog.Disaster.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public sealed class DisasterInterestEntry
|
||||
{
|
||||
public string Id = "";
|
||||
public float EventStrength = 90f;
|
||||
public string Category = "Disaster";
|
||||
public string WorldLogId = "";
|
||||
public bool CreatesInterest = true;
|
||||
public bool IsFallback;
|
||||
}
|
||||
|
||||
/// <summary>Authored interest overlay for every live disasters library asset.</summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class Disaster
|
||||
{
|
||||
private static readonly Dictionary<string, DisasterInterestEntry> Entries =
|
||||
new Dictionary<string, DisasterInterestEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static Disaster()
|
||||
{
|
||||
Add("tornado", 95f, "disaster_tornado");
|
||||
Add("heatwave", 88f, "disaster_heatwave");
|
||||
Add("small_meteorite", 95f, "disaster_meteorite");
|
||||
Add("small_earthquake", 95f, "disaster_earthquake");
|
||||
Add("hellspawn", 96f, "disaster_hellspawn");
|
||||
Add("ice_ones_awoken", 95f, "disaster_ice_ones");
|
||||
Add("sudden_snowman", 90f, "disaster_sudden_snowman");
|
||||
Add("garden_surprise", 90f, "disaster_garden_surprise");
|
||||
Add("dragon_from_farlands", 98f, "disaster_dragon_from_farlands");
|
||||
Add("ash_bandits", 92f, "disaster_bandits");
|
||||
Add("alien_invasion", 97f, "disaster_alien_invasion");
|
||||
Add("biomass", 96f, "disaster_biomass");
|
||||
Add("tumor", 96f, "disaster_tumor");
|
||||
Add("wild_mage", 94f, "disaster_evil_mage");
|
||||
Add("underground_necromancer", 95f, "disaster_underground_necromancer");
|
||||
Add("mad_thoughts", 90f, "disaster_mad_thoughts");
|
||||
Add("greg_abominations", 96f, "disaster_greg_abominations");
|
||||
}
|
||||
|
||||
private static void Add(string id, float strength, string worldLogId)
|
||||
{
|
||||
Entries[id] = new DisasterInterestEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
WorldLogId = worldLogId,
|
||||
CreatesInterest = true,
|
||||
Category = "Disaster"
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => Entries.Keys;
|
||||
|
||||
public static bool HasAuthored(string disasterId)
|
||||
{
|
||||
return !string.IsNullOrEmpty(disasterId) && Entries.ContainsKey(disasterId.Trim());
|
||||
}
|
||||
|
||||
public static bool TryGet(string disasterId, out DisasterInterestEntry entry)
|
||||
{
|
||||
entry = null;
|
||||
if (string.IsNullOrEmpty(disasterId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Entries.TryGetValue(disasterId.Trim(), out entry);
|
||||
}
|
||||
|
||||
public static DisasterInterestEntry GetOrFallback(string disasterId)
|
||||
{
|
||||
if (TryGet(disasterId, out DisasterInterestEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string id = string.IsNullOrEmpty(disasterId) ? "unknown" : disasterId.Trim();
|
||||
return new DisasterInterestEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = 90f,
|
||||
Category = "Disaster",
|
||||
WorldLogId = "",
|
||||
CreatesInterest = true,
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
68
IdleSpectator/Events/Catalogs/EventCatalog.Era.cs
Normal file
68
IdleSpectator/Events/Catalogs/EventCatalog.Era.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Authored interest overlay for every live era_library asset.</summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class Era
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static Era()
|
||||
{
|
||||
Add("age_hope", 78f, "World", "Age of Hope");
|
||||
Add("age_sun", 80f, "World", "Age of Sun");
|
||||
Add("age_dark", 88f, "World", "Age of Dark");
|
||||
Add("age_tears", 86f, "World", "Age of Tears");
|
||||
Add("age_moon", 82f, "World", "Age of Moon");
|
||||
Add("age_chaos", 92f, "World", "Age of Chaos");
|
||||
Add("age_wonders", 84f, "World", "Age of Wonders");
|
||||
Add("age_ice", 87f, "World", "Age of Ice");
|
||||
Add("age_ash", 90f, "World", "Age of Ash");
|
||||
Add("age_despair", 91f, "World", "Age of Despair");
|
||||
Add("age_unknown", 70f, "World", "Unknown age");
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
string id,
|
||||
float strength,
|
||||
string category,
|
||||
string label)
|
||||
{
|
||||
Entries[id] = new DiscreteEventEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
Category = category,
|
||||
CreatesInterest = true,
|
||||
LabelTemplate = label
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => Entries.Keys;
|
||||
|
||||
public static bool HasAuthored(string id) =>
|
||||
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
|
||||
return new DiscreteEventEntry
|
||||
{
|
||||
Id = key,
|
||||
EventStrength = 70f,
|
||||
Category = "World",
|
||||
LabelTemplate = "Era ({id})",
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@ using System.Collections.Generic;
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Authored policy + prose for every live happiness effect.</summary>
|
||||
public static class HappinessEventCatalog
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class Happiness
|
||||
{
|
||||
private static readonly Dictionary<string, HappinessCatalogEntry> Entries =
|
||||
new Dictionary<string, HappinessCatalogEntry>(StringComparer.OrdinalIgnoreCase)
|
||||
|
|
@ -12,6 +14,7 @@ public static class HappinessEventCatalog
|
|||
["death_family_member"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "death_family_member",
|
||||
EventStrength = 76f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RequiresRelatedActor,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -24,6 +27,7 @@ public static class HappinessEventCatalog
|
|||
["death_lover"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "death_lover",
|
||||
EventStrength = 88f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RequiresRelatedActor,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -36,6 +40,7 @@ public static class HappinessEventCatalog
|
|||
["death_child"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "death_child",
|
||||
EventStrength = 95f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RequiresRelatedActor,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -48,6 +53,7 @@ public static class HappinessEventCatalog
|
|||
["death_best_friend"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "death_best_friend",
|
||||
EventStrength = 78f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RequiresRelatedActor,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -60,6 +66,7 @@ public static class HappinessEventCatalog
|
|||
["got_robbed"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "got_robbed",
|
||||
EventStrength = 50f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RelatedOptional,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -72,6 +79,7 @@ public static class HappinessEventCatalog
|
|||
["got_poked"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "got_poked",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -84,30 +92,34 @@ public static class HappinessEventCatalog
|
|||
["lost_fight"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "lost_fight",
|
||||
EventStrength = 55f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Emotion",
|
||||
VariantsWithRelated = new[] { "loses a fight", "is beaten in a fight" },
|
||||
VariantsWithoutRelated = new[] { "loses a fight", "is beaten in a fight" },
|
||||
VariantsWithRelated = new[] { "is forced to yield in a fight", "gives up mid-fight" },
|
||||
VariantsWithoutRelated = new[] { "is forced to yield in a fight", "gives up mid-fight" },
|
||||
},
|
||||
["got_caught"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "got_caught",
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
// Library-only id today - no changeHappiness("got_caught") call site in the game.
|
||||
EventStrength = 20f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Emotion",
|
||||
VariantsWithRelated = new[] { "is caught doing something shady", "gets caught" },
|
||||
VariantsWithoutRelated = new[] { "is caught doing something shady", "gets caught" },
|
||||
VariantsWithRelated = new[] { "gets caught", "is caught" },
|
||||
VariantsWithoutRelated = new[] { "gets caught", "is caught" },
|
||||
},
|
||||
["paid_tax"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "paid_tax",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -120,6 +132,7 @@ public static class HappinessEventCatalog
|
|||
["just_ate"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_ate",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -133,6 +146,7 @@ public static class HappinessEventCatalog
|
|||
["just_received_gift"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_received_gift",
|
||||
EventStrength = 50f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RelatedOptional,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -145,6 +159,7 @@ public static class HappinessEventCatalog
|
|||
["just_gave_gift"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_gave_gift",
|
||||
EventStrength = 50f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RelatedOptional,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -157,6 +172,7 @@ public static class HappinessEventCatalog
|
|||
["just_pooped"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_pooped",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -169,18 +185,20 @@ public static class HappinessEventCatalog
|
|||
["just_slept"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_slept",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Daily",
|
||||
VariantsWithRelated = new[] { "wakes from a restful sleep", "finishes sleeping" },
|
||||
VariantsWithoutRelated = new[] { "wakes from a restful sleep", "finishes sleeping" },
|
||||
VariantsWithRelated = new[] { "finishes sleeping", "ends a rest" },
|
||||
VariantsWithoutRelated = new[] { "finishes sleeping", "ends a rest" },
|
||||
},
|
||||
["had_bad_dream"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "had_bad_dream",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -188,12 +206,13 @@ public static class HappinessEventCatalog
|
|||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Daily",
|
||||
StatusOverlapId = "had_bad_dream",
|
||||
VariantsWithRelated = new[] { "wakes from a bad dream", "shakes off a bad dream" },
|
||||
VariantsWithoutRelated = new[] { "wakes from a bad dream", "shakes off a bad dream" },
|
||||
VariantsWithRelated = new[] { "has a bad dream", "is troubled by a bad dream" },
|
||||
VariantsWithoutRelated = new[] { "has a bad dream", "is troubled by a bad dream" },
|
||||
},
|
||||
["had_good_dream"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "had_good_dream",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -201,12 +220,13 @@ public static class HappinessEventCatalog
|
|||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Daily",
|
||||
StatusOverlapId = "had_good_dream",
|
||||
VariantsWithRelated = new[] { "wakes from a good dream", "smiles about a good dream" },
|
||||
VariantsWithoutRelated = new[] { "wakes from a good dream", "smiles about a good dream" },
|
||||
VariantsWithRelated = new[] { "has a good dream", "dreams pleasantly" },
|
||||
VariantsWithoutRelated = new[] { "has a good dream", "dreams pleasantly" },
|
||||
},
|
||||
["had_nightmare"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "had_nightmare",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -214,36 +234,41 @@ public static class HappinessEventCatalog
|
|||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Daily",
|
||||
StatusOverlapId = "had_nightmare",
|
||||
VariantsWithRelated = new[] { "wakes from a nightmare", "shakes off a nightmare" },
|
||||
VariantsWithoutRelated = new[] { "wakes from a nightmare", "shakes off a nightmare" },
|
||||
VariantsWithRelated = new[] { "has a nightmare", "is haunted by a nightmare" },
|
||||
VariantsWithoutRelated = new[] { "has a nightmare", "is haunted by a nightmare" },
|
||||
},
|
||||
["slept_outside"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "slept_outside",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Daily",
|
||||
VariantsWithRelated = new[] { "sleeps outside and feels worse for it", "spends a rough night outside" },
|
||||
VariantsWithoutRelated = new[] { "sleeps outside and feels worse for it", "spends a rough night outside" },
|
||||
VariantsWithRelated = new[] { "has to sleep without a house", "settles down outdoors, unhappy" },
|
||||
VariantsWithoutRelated = new[] { "has to sleep without a house", "settles down outdoors, unhappy" },
|
||||
},
|
||||
["just_kissed"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_kissed",
|
||||
// Game id is mating afterglow (BehCheckForBabiesFromSexualReproduction), not a kiss.
|
||||
EventStrength = 50f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RelatedOptional,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.Lover,
|
||||
InterestCategory = "Social",
|
||||
VariantsWithRelated = new[] { "shares a kiss with {related}", "kisses {related}" },
|
||||
VariantsWithoutRelated = new[] { "shares a kiss", "steals a kiss" },
|
||||
VariantsWithRelated = new[] { "mates with {related}", "shares intimacy with {related}" },
|
||||
VariantsWithoutRelated = new[] { "mates with their lover", "shares intimacy with their lover" },
|
||||
},
|
||||
["just_killed"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_killed",
|
||||
// Only applied in newKillAction when the killer has trait bloodlust.
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -251,24 +276,26 @@ public static class HappinessEventCatalog
|
|||
RelationRole = HappinessRelationRole.Victim,
|
||||
InterestCategory = "Emotion",
|
||||
CanonicalMilestoneKey = "milestone_kill",
|
||||
VariantsWithRelated = new[] { "kills {related} and feels a rush", "takes a life" },
|
||||
VariantsWithoutRelated = new[] { "takes a life", "feels a rush after a kill" },
|
||||
VariantsWithRelated = new[] { "kills {related} and feels bloodlust's rush", "bloodlust flares after killing {related}" },
|
||||
VariantsWithoutRelated = new[] { "bloodlust flares after a kill", "feels bloodlust's rush after a kill" },
|
||||
},
|
||||
["become_king"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "become_king",
|
||||
EventStrength = 72f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "LifeChapter",
|
||||
VariantsWithRelated = new[] { "is crowned king", "takes the throne" },
|
||||
VariantsWithoutRelated = new[] { "is crowned king", "takes the throne" },
|
||||
VariantsWithRelated = new[] { "takes the crown", "becomes ruler" },
|
||||
VariantsWithoutRelated = new[] { "takes the crown", "becomes ruler" },
|
||||
},
|
||||
["become_leader"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "become_leader",
|
||||
EventStrength = 65f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -281,6 +308,7 @@ public static class HappinessEventCatalog
|
|||
["just_won_war"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_won_war",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -293,6 +321,7 @@ public static class HappinessEventCatalog
|
|||
["just_made_peace"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_made_peace",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -305,6 +334,7 @@ public static class HappinessEventCatalog
|
|||
["just_lost_war"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_lost_war",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -317,6 +347,7 @@ public static class HappinessEventCatalog
|
|||
["was_conquered"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "was_conquered",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -329,6 +360,7 @@ public static class HappinessEventCatalog
|
|||
["kingdom_fell_apart"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "kingdom_fell_apart",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -341,30 +373,33 @@ public static class HappinessEventCatalog
|
|||
["just_started_war"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_started_war",
|
||||
EventStrength = 80f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Civic",
|
||||
VariantsWithRelated = new[] { "cheers as war begins", "is stirred by the start of war" },
|
||||
VariantsWithoutRelated = new[] { "cheers as war begins", "is stirred by the start of war" },
|
||||
VariantsWithRelated = new[] { "is glad war has started", "cheers a war their culture loves" },
|
||||
VariantsWithoutRelated = new[] { "is glad war has started", "cheers a war their culture loves" },
|
||||
},
|
||||
["just_rebelled"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_rebelled",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Civic",
|
||||
VariantsWithRelated = new[] { "joins a rebellion", "rises in rebellion" },
|
||||
VariantsWithoutRelated = new[] { "joins a rebellion", "rises in rebellion" },
|
||||
VariantsWithRelated = new[] { "is swept up as the city rebels", "lives through a rebellion" },
|
||||
VariantsWithoutRelated = new[] { "is swept up as the city rebels", "lives through a rebellion" },
|
||||
},
|
||||
["fallen_in_love"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "fallen_in_love",
|
||||
EventStrength = 50f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RelatedOptional,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -379,30 +414,34 @@ public static class HappinessEventCatalog
|
|||
["just_had_child"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_had_child",
|
||||
// birthEvent is on the parent; related newborn is not wired today.
|
||||
EventStrength = 70f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RelatedOptional,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.Newborn,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "LifeChapter",
|
||||
VariantsWithRelated = new[] { "welcomes newborn {related}", "has a child named {related}" },
|
||||
VariantsWithoutRelated = new[] { "welcomes a newborn child", "has a child" },
|
||||
VariantsWithRelated = new[] { "has a child", "feels the joy of parenthood" },
|
||||
VariantsWithoutRelated = new[] { "has a child", "feels the joy of parenthood" },
|
||||
},
|
||||
["just_read_book"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_read_book",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Daily",
|
||||
VariantsWithRelated = new[] { "finishes reading a book", "puts down a book feeling wiser" },
|
||||
VariantsWithoutRelated = new[] { "finishes reading a book", "puts down a book feeling wiser" },
|
||||
VariantsWithRelated = new[] { "finishes reading a book", "puts down a book" },
|
||||
VariantsWithoutRelated = new[] { "finishes reading a book", "puts down a book" },
|
||||
},
|
||||
["just_played"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_played",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -415,18 +454,20 @@ public static class HappinessEventCatalog
|
|||
["just_talked"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_talked",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.RelatedOptional,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.ConversationPartner,
|
||||
InterestCategory = "Social",
|
||||
VariantsWithRelated = new[] { "shares a talk with {related}", "finishes a conversation with {related}" },
|
||||
VariantsWithoutRelated = new[] { "shares a quiet talk", "finishes a conversation" },
|
||||
VariantsWithRelated = new[] { "finishes talking with {related}", "ends a conversation with {related}" },
|
||||
VariantsWithoutRelated = new[] { "finishes talking", "ends a conversation" },
|
||||
},
|
||||
["just_laughed"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_laughed",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -440,6 +481,7 @@ public static class HappinessEventCatalog
|
|||
["just_sang"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_sang",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -453,6 +495,7 @@ public static class HappinessEventCatalog
|
|||
["just_swore"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_swore",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -466,6 +509,7 @@ public static class HappinessEventCatalog
|
|||
["just_cried"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_cried",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -479,18 +523,20 @@ public static class HappinessEventCatalog
|
|||
["just_talked_gossip"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_talked_gossip",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.RelatedOptional,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.ConversationPartner,
|
||||
InterestCategory = "Social",
|
||||
VariantsWithRelated = new[] { "trades gossip with {related}", "shares juicy gossip with {related}" },
|
||||
VariantsWithoutRelated = new[] { "trades gossip", "shares juicy gossip" },
|
||||
VariantsWithRelated = new[] { "gossips with {related}", "shares lover-gossip with {related}" },
|
||||
VariantsWithoutRelated = new[] { "gossips about lovers", "shares lover-gossip" },
|
||||
},
|
||||
["just_surprised"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_surprised",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -503,19 +549,23 @@ public static class HappinessEventCatalog
|
|||
},
|
||||
["just_born"] = new HappinessCatalogEntry
|
||||
{
|
||||
// Actor.newCreature spawn bookkeeping - not live birth; may precede egg form.
|
||||
Id = "just_born",
|
||||
EventStrength = 48f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "LifeChapter",
|
||||
VariantsWithRelated = new[] { "is born into the world", "takes a first breath" },
|
||||
VariantsWithoutRelated = new[] { "is born into the world", "takes a first breath" },
|
||||
VariantsWithRelated = new[] { "appears in the world", "is brought into existence" },
|
||||
VariantsWithoutRelated = new[] { "appears in the world", "is brought into existence" },
|
||||
},
|
||||
["just_magnetised"] = new HappinessCatalogEntry
|
||||
{
|
||||
// Game id uses British spelling; status asset is magnetized.
|
||||
Id = "just_magnetised",
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -529,18 +579,20 @@ public static class HappinessEventCatalog
|
|||
["just_forced_power"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_forced_power",
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Emotion",
|
||||
VariantsWithRelated = new[] { "is forced by divine power", "suffers a forced power" },
|
||||
VariantsWithoutRelated = new[] { "is forced by divine power", "suffers a forced power" },
|
||||
VariantsWithRelated = new[] { "is flung by a god power", "is blasted aside by divine force" },
|
||||
VariantsWithoutRelated = new[] { "is flung by a god power", "is blasted aside by divine force" },
|
||||
},
|
||||
["just_possessed"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_possessed",
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -554,6 +606,7 @@ public static class HappinessEventCatalog
|
|||
["strange_urge"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "strange_urge",
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -567,6 +620,7 @@ public static class HappinessEventCatalog
|
|||
["just_had_tantrum"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_had_tantrum",
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -580,6 +634,7 @@ public static class HappinessEventCatalog
|
|||
["just_felt_the_divine"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_felt_the_divine",
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -592,6 +647,7 @@ public static class HappinessEventCatalog
|
|||
["just_enchanted"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_enchanted",
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -605,6 +661,7 @@ public static class HappinessEventCatalog
|
|||
["just_inspired"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_inspired",
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -612,12 +669,14 @@ public static class HappinessEventCatalog
|
|||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "Emotion",
|
||||
StatusOverlapId = "inspired",
|
||||
VariantsWithRelated = new[] { "feels inspired", "is struck by inspiration" },
|
||||
VariantsWithoutRelated = new[] { "feels inspired", "is struck by inspiration" },
|
||||
// Happiness fires on inspired status end (action_finish), not onset.
|
||||
VariantsWithRelated = new[] { "comes down from inspiration", "lets inspiration settle into contentment" },
|
||||
VariantsWithoutRelated = new[] { "comes down from inspiration", "lets inspiration settle into contentment" },
|
||||
},
|
||||
["wrote_book"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "wrote_book",
|
||||
EventStrength = 65f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -630,6 +689,7 @@ public static class HappinessEventCatalog
|
|||
["just_became_adult"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_became_adult",
|
||||
EventStrength = 65f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -642,6 +702,7 @@ public static class HappinessEventCatalog
|
|||
["just_got_out_of_egg"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_got_out_of_egg",
|
||||
EventStrength = 65f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -654,6 +715,7 @@ public static class HappinessEventCatalog
|
|||
["just_finished_plot"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_finished_plot",
|
||||
EventStrength = 65f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -666,6 +728,7 @@ public static class HappinessEventCatalog
|
|||
["just_found_house"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_found_house",
|
||||
EventStrength = 55f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -678,6 +741,7 @@ public static class HappinessEventCatalog
|
|||
["just_lost_house"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_lost_house",
|
||||
EventStrength = 65f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -690,6 +754,7 @@ public static class HappinessEventCatalog
|
|||
["just_made_friend"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_made_friend",
|
||||
EventStrength = 50f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.RelatedOptional,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -703,6 +768,7 @@ public static class HappinessEventCatalog
|
|||
["just_injured"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_injured",
|
||||
EventStrength = 65f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -715,6 +781,7 @@ public static class HappinessEventCatalog
|
|||
["just_cursed"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "just_cursed",
|
||||
EventStrength = 45f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -728,6 +795,7 @@ public static class HappinessEventCatalog
|
|||
["starving"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "starving",
|
||||
EventStrength = 30f,
|
||||
Presentation = HappinessPresentationTier.Ambient,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -741,6 +809,7 @@ public static class HappinessEventCatalog
|
|||
["conquered_city"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "conquered_city",
|
||||
EventStrength = 75f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -753,6 +822,7 @@ public static class HappinessEventCatalog
|
|||
["destroyed_city"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "destroyed_city",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -765,6 +835,7 @@ public static class HappinessEventCatalog
|
|||
["lost_crown"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "lost_crown",
|
||||
EventStrength = 65f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
|
|
@ -777,6 +848,7 @@ public static class HappinessEventCatalog
|
|||
["razed_capital"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "razed_capital",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -789,6 +861,7 @@ public static class HappinessEventCatalog
|
|||
["lost_capital"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "lost_capital",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -801,6 +874,7 @@ public static class HappinessEventCatalog
|
|||
["razed_city"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "razed_city",
|
||||
EventStrength = 35f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -813,6 +887,7 @@ public static class HappinessEventCatalog
|
|||
["lost_city"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "lost_city",
|
||||
EventStrength = 72f,
|
||||
Presentation = HappinessPresentationTier.Aggregate,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ActivityOnly,
|
||||
|
|
@ -825,14 +900,15 @@ public static class HappinessEventCatalog
|
|||
["become_alpha"] = new HappinessCatalogEntry
|
||||
{
|
||||
Id = "become_alpha",
|
||||
EventStrength = 65f,
|
||||
Presentation = HappinessPresentationTier.Signal,
|
||||
Context = HappinessContextRequirement.None,
|
||||
Life = HappinessLifePolicy.ProjectToLife,
|
||||
Overlap = HappinessOverlapPolicy.Independent,
|
||||
RelationRole = HappinessRelationRole.None,
|
||||
InterestCategory = "LifeChapter",
|
||||
VariantsWithRelated = new[] { "becomes the alpha", "rises as alpha" },
|
||||
VariantsWithoutRelated = new[] { "becomes the alpha", "rises as alpha" },
|
||||
VariantsWithRelated = new[] { "becomes the family alpha", "is named family alpha" },
|
||||
VariantsWithoutRelated = new[] { "becomes the family alpha", "is named family alpha" },
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -872,8 +948,9 @@ public static class HappinessEventCatalog
|
|||
InterestCategory = "Unknown",
|
||||
VariantsWithRelated = new[] { "feels a change in happiness" },
|
||||
VariantsWithoutRelated = new[] { "feels a change in happiness" },
|
||||
EventStrength = 40f,
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
418
IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs
Normal file
418
IdleSpectator/Events/Catalogs/EventCatalog.Libraries.cs
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Live AssetManager library event dials (powers, genes, …).</summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class Spell
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly HashSet<string> SignalIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"summon_lightning", "summon_tornado", "cast_curse", "cast_fire",
|
||||
"cast_blood_rain", "summon_meteor", "teleport"
|
||||
};
|
||||
|
||||
private static void Ensure() =>
|
||||
LiveLibraryInterest.EnsureSeeded(
|
||||
Entries,
|
||||
ActivityAssetCatalog.EnumerateLiveSpellIds,
|
||||
"Spectacle",
|
||||
"{a} casts {id}",
|
||||
ambientStrength: 48f,
|
||||
SignalIds,
|
||||
signalStrength: 88f,
|
||||
ambientCreatesInterest: false);
|
||||
|
||||
public static IEnumerable<string> AuthoredIds
|
||||
{
|
||||
get
|
||||
{
|
||||
Ensure();
|
||||
return Entries.Keys;
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
Ensure();
|
||||
return LiveLibraryInterest.Lookup(Entries, id, "Spectacle", "{a} casts {id}", 55f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>God powers library - mostly Ambient; Signal for disaster/spectacle ids.</summary>
|
||||
public static class Power
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsSpectaclePower(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string s = id.ToLowerInvariant();
|
||||
return s.Contains("meteor")
|
||||
|| s.Contains("earthquake")
|
||||
|| s.Contains("tornado")
|
||||
|| s.Contains("volcano")
|
||||
|| s.Contains("nuke")
|
||||
|| s.Contains("bomb")
|
||||
|| s.Contains("rain_blood")
|
||||
|| s.Contains("hell")
|
||||
|| s.Contains("lightning")
|
||||
|| s.Contains("storm")
|
||||
|| s.Contains("acid")
|
||||
|| s.Contains("fire")
|
||||
|| s.Contains("disaster");
|
||||
}
|
||||
|
||||
private static void Ensure() =>
|
||||
LiveLibraryInterest.EnsureSeeded(
|
||||
Entries,
|
||||
ActivityAssetCatalog.EnumerateLivePowerIds,
|
||||
"Spectacle",
|
||||
"God power: {id}",
|
||||
ambientStrength: 28f,
|
||||
signalIds: null,
|
||||
signalStrength: 92f,
|
||||
signalPredicate: IsSpectaclePower,
|
||||
ambientCreatesInterest: false);
|
||||
|
||||
public static IEnumerable<string> AuthoredIds
|
||||
{
|
||||
get
|
||||
{
|
||||
Ensure();
|
||||
return Entries.Keys;
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
Ensure();
|
||||
return LiveLibraryInterest.Lookup(Entries, id, "Spectacle", "God power: {id}", 30f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>AI decisions - Signal for war/rebellion/kingdom-affecting; Ambient otherwise.</summary>
|
||||
public static class Decision
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsKingdomDecision(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string s = id.ToLowerInvariant();
|
||||
// Routine AI ticks often include "king" in the name - those are not camera events.
|
||||
if (s.Contains("idle")
|
||||
|| s.Contains("walking")
|
||||
|| s.Contains("check")
|
||||
|| s.Contains("wait")
|
||||
|| s.Contains("sleep"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return s.Contains("war")
|
||||
|| s.Contains("rebellion")
|
||||
|| s.Contains("revolt")
|
||||
|| s.Contains("alliance")
|
||||
|| s.Contains("coup")
|
||||
|| s.Contains("betray")
|
||||
|| s.Contains("declare")
|
||||
|| s.Contains("invade")
|
||||
|| s.Contains("siege")
|
||||
|| s.Contains("found")
|
||||
|| s.Contains("city_foundation")
|
||||
|| (s.Contains("king") && (s.Contains("war") || s.Contains("city") || s.Contains("rebellion")));
|
||||
}
|
||||
|
||||
/// <summary>True when a decision id should be allowed to own the idle camera.</summary>
|
||||
public static bool IsCameraWorthy(string id) => IsKingdomDecision(id);
|
||||
|
||||
private static void Ensure() =>
|
||||
LiveLibraryInterest.EnsureSeeded(
|
||||
Entries,
|
||||
ActivityAssetCatalog.EnumerateLiveDecisionIds,
|
||||
"Politics",
|
||||
"{a} decides {id}",
|
||||
ambientStrength: 32f,
|
||||
signalIds: null,
|
||||
signalStrength: 86f,
|
||||
signalPredicate: IsKingdomDecision,
|
||||
ambientCreatesInterest: false);
|
||||
|
||||
public static IEnumerable<string> AuthoredIds
|
||||
{
|
||||
get
|
||||
{
|
||||
Ensure();
|
||||
return Entries.Keys;
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
Ensure();
|
||||
return LiveLibraryInterest.Lookup(Entries, id, "Politics", "{a} decides {id}", 35f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Items library - Signal for legendary/wonder subset.</summary>
|
||||
public static class Item
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsLegendaryItem(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string s = id.ToLowerInvariant();
|
||||
return s.Contains("legendary")
|
||||
|| s.Contains("mythic")
|
||||
|| s.Contains("artifact")
|
||||
|| s.Contains("relic")
|
||||
|| s.Contains("divine")
|
||||
|| s.Contains("demon")
|
||||
|| s.Contains("dragon")
|
||||
|| s.Contains("nuke")
|
||||
|| s.Contains("excalibur")
|
||||
|| s.Contains("wonder");
|
||||
}
|
||||
|
||||
private static void Ensure() =>
|
||||
LiveLibraryInterest.EnsureSeeded(
|
||||
Entries,
|
||||
ActivityAssetCatalog.EnumerateLiveItemIds,
|
||||
"Item",
|
||||
"{a} forges {id}",
|
||||
ambientStrength: 36f,
|
||||
signalIds: null,
|
||||
signalStrength: 80f,
|
||||
signalPredicate: IsLegendaryItem,
|
||||
ambientCreatesInterest: false);
|
||||
|
||||
public static IEnumerable<string> AuthoredIds
|
||||
{
|
||||
get
|
||||
{
|
||||
Ensure();
|
||||
return Entries.Keys;
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
Ensure();
|
||||
return LiveLibraryInterest.Lookup(Entries, id, "Item", "{a} forges {id}", 40f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Subspecies traits - Signal for dramatic; Ambient fill-capped.</summary>
|
||||
public static class SubspeciesTrait
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsDramatic(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string s = id.ToLowerInvariant();
|
||||
return s.Contains("immortal")
|
||||
|| s.Contains("giant")
|
||||
|| s.Contains("tiny")
|
||||
|| s.Contains("fire")
|
||||
|| s.Contains("frost")
|
||||
|| s.Contains("poison")
|
||||
|| s.Contains("plague")
|
||||
|| s.Contains("magic")
|
||||
|| s.Contains("divine")
|
||||
|| s.Contains("demon")
|
||||
|| s.Contains("undead")
|
||||
|| s.Contains("dragon")
|
||||
|| s.Contains("evolve")
|
||||
|| s.Contains("mutate");
|
||||
}
|
||||
|
||||
private static void Ensure() =>
|
||||
LiveLibraryInterest.EnsureSeeded(
|
||||
Entries,
|
||||
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds,
|
||||
"Evolution",
|
||||
"{a} evolves {id}",
|
||||
ambientStrength: 34f,
|
||||
signalIds: null,
|
||||
signalStrength: 78f,
|
||||
signalPredicate: IsDramatic);
|
||||
|
||||
public static IEnumerable<string> AuthoredIds
|
||||
{
|
||||
get
|
||||
{
|
||||
Ensure();
|
||||
return Entries.Keys;
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
Ensure();
|
||||
return LiveLibraryInterest.Lookup(Entries, id, "Evolution", "{a} evolves {id}", 36f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Genes - Ambient default.</summary>
|
||||
public static class Gene
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static void Ensure() =>
|
||||
LiveLibraryInterest.EnsureSeeded(
|
||||
Entries,
|
||||
ActivityAssetCatalog.EnumerateLiveGeneIds,
|
||||
"Genetics",
|
||||
"{a} gains gene {id}",
|
||||
ambientStrength: 30f,
|
||||
signalIds: null,
|
||||
signalStrength: 70f,
|
||||
signalPredicate: id => id != null && (id.Contains("warfare") || id.Contains("magic") || id.Contains("mutation")),
|
||||
ambientCreatesInterest: false);
|
||||
|
||||
public static IEnumerable<string> AuthoredIds
|
||||
{
|
||||
get
|
||||
{
|
||||
Ensure();
|
||||
return Entries.Keys;
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
Ensure();
|
||||
return LiveLibraryInterest.Lookup(Entries, id, "Genetics", "{a} gains gene {id}", 30f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Phenotypes - Ambient default.</summary>
|
||||
public static class Phenotype
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static void Ensure() =>
|
||||
LiveLibraryInterest.EnsureSeeded(
|
||||
Entries,
|
||||
ActivityAssetCatalog.EnumerateLivePhenotypeIds,
|
||||
"Genetics",
|
||||
"{a} shows {id}",
|
||||
ambientStrength: 28f,
|
||||
signalIds: null,
|
||||
signalStrength: 60f,
|
||||
ambientCreatesInterest: false);
|
||||
|
||||
public static IEnumerable<string> AuthoredIds
|
||||
{
|
||||
get
|
||||
{
|
||||
Ensure();
|
||||
return Entries.Keys;
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
Ensure();
|
||||
return LiveLibraryInterest.Lookup(Entries, id, "Genetics", "{a} shows {id}", 28f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>World laws - Ambient / location-only style.</summary>
|
||||
public static class WorldLaw
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static void Ensure() =>
|
||||
LiveLibraryInterest.EnsureSeeded(
|
||||
Entries,
|
||||
ActivityAssetCatalog.EnumerateLiveWorldLawIds,
|
||||
"WorldLaw",
|
||||
"Law changed: {id}",
|
||||
ambientStrength: 40f,
|
||||
signalIds: null,
|
||||
signalStrength: 70f,
|
||||
signalPredicate: id => id != null && (id.Contains("war") || id.Contains("death") || id.Contains("plague")),
|
||||
ambientCreatesInterest: false);
|
||||
|
||||
public static IEnumerable<string> AuthoredIds
|
||||
{
|
||||
get
|
||||
{
|
||||
Ensure();
|
||||
return Entries.Keys;
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
Ensure();
|
||||
return LiveLibraryInterest.Lookup(Entries, id, "WorldLaw", "Law changed: {id}", 40f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Biomes - Ambient.</summary>
|
||||
public static class Biome
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static void Ensure() =>
|
||||
LiveLibraryInterest.EnsureSeeded(
|
||||
Entries,
|
||||
ActivityAssetCatalog.EnumerateLiveBiomeIds,
|
||||
"Biome",
|
||||
"Biome shifts: {id}",
|
||||
ambientStrength: 26f,
|
||||
signalIds: null,
|
||||
signalStrength: 50f,
|
||||
ambientCreatesInterest: false);
|
||||
|
||||
public static IEnumerable<string> AuthoredIds
|
||||
{
|
||||
get
|
||||
{
|
||||
Ensure();
|
||||
return Entries.Keys;
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
Ensure();
|
||||
return LiveLibraryInterest.Lookup(Entries, id, "Biome", "Biome shifts: {id}", 26f);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
85
IdleSpectator/Events/Catalogs/EventCatalog.Plot.cs
Normal file
85
IdleSpectator/Events/Catalogs/EventCatalog.Plot.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Authored interest overlay for every live plots_library asset.</summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class Plot
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static Plot()
|
||||
{
|
||||
Add("rebellion", 92f, "Politics", "{a} is plotting a rebellion");
|
||||
Add("new_war", 94f, "Politics", "{a} is plotting a war");
|
||||
Add("alliance_create", 80f, "Politics", "{a} is plotting an alliance");
|
||||
Add("alliance_join", 72f, "Politics", "{a} is joining an alliance");
|
||||
Add("alliance_destroy", 86f, "Politics", "{a} is breaking an alliance");
|
||||
Add("attacker_stop_war", 78f, "Politics", "{a} is ending a war");
|
||||
Add("new_book", 55f, "Culture", "{a} is writing a book");
|
||||
Add("new_language", 62f, "Culture", "{a} is forging a language");
|
||||
Add("new_religion", 70f, "Culture", "{a} is founding a religion");
|
||||
Add("new_culture", 68f, "Culture", "{a} is founding a culture");
|
||||
Add("clan_ascension", 82f, "Politics", "{a} is ascending a clan");
|
||||
Add("culture_divide", 75f, "Culture", "{a} splits a culture");
|
||||
Add("religion_schism", 84f, "Culture", "{a} causes a religion schism");
|
||||
Add("language_divergence", 60f, "Culture", "{a} splits a language");
|
||||
Add("summon_meteor_rain", 96f, "Spectacle", "{a} summons a meteor rain");
|
||||
Add("summon_earthquake", 95f, "Spectacle", "{a} summons an earthquake");
|
||||
Add("summon_thunderstorm", 93f, "Spectacle", "{a} summons a thunderstorm");
|
||||
Add("summon_stormfront", 93f, "Spectacle", "{a} summons a stormfront");
|
||||
Add("summon_hellstorm", 97f, "Spectacle", "{a} summons a hellstorm");
|
||||
Add("summon_demons", 96f, "Spectacle", "{a} summons demons");
|
||||
Add("summon_angles", 96f, "Spectacle", "{a} summons angels");
|
||||
Add("summon_skeletons", 92f, "Spectacle", "{a} summons skeletons");
|
||||
Add("summon_living_plants", 90f, "Spectacle", "{a} summons living plants");
|
||||
Add("big_cast_coffee", 58f, "Spectacle", "{a} performs a coffee ritual");
|
||||
Add("big_cast_bubble_shield", 64f, "Spectacle", "{a} casts a bubble shield");
|
||||
Add("big_cast_madness", 80f, "Spectacle", "{a} casts madness");
|
||||
Add("big_cast_slowness", 70f, "Spectacle", "{a} casts slowness");
|
||||
Add("cause_rebellion", 90f, "Politics", "{a} causes a rebellion");
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
string id,
|
||||
float strength,
|
||||
string category,
|
||||
string label)
|
||||
{
|
||||
Entries[id] = new DiscreteEventEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
Category = category,
|
||||
CreatesInterest = true,
|
||||
LabelTemplate = label
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => Entries.Keys;
|
||||
|
||||
public static bool HasAuthored(string id) =>
|
||||
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
|
||||
return new DiscreteEventEntry
|
||||
{
|
||||
Id = key,
|
||||
EventStrength = 55f,
|
||||
Category = "Plot",
|
||||
LabelTemplate = "{a} · {id}",
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
69
IdleSpectator/Events/Catalogs/EventCatalog.Relationship.cs
Normal file
69
IdleSpectator/Events/Catalogs/EventCatalog.Relationship.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Authored discrete relationship lifecycle events (not a live asset library).</summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class Relationship
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static Relationship()
|
||||
{
|
||||
Add("set_lover", 72f, "Relationship", "{a} became lovers with {b}");
|
||||
Add("clear_lover", 55f, "Relationship", "{a} parted from a lover");
|
||||
Add("add_child", 68f, "Relationship", "{a} gains a child, {b}");
|
||||
Add("new_family", 70f, "Relationship", "{a} starts a new family");
|
||||
Add("family_removed", 50f, "Relationship", "{a}'s family ends");
|
||||
Add("find_lover", 45f, "Relationship", "{a} is seeking a lover");
|
||||
Add("family_group_new", 60f, "Relationship", "{a} forms a family pack");
|
||||
Add("family_group_join", 36f, "Relationship", "{a} joins a family pack", createsInterest: false);
|
||||
Add("family_group_leave", 36f, "Relationship", "{a} leaves a family pack", createsInterest: false);
|
||||
Add("baby_created", 70f, "Relationship", "{a} is created as a baby");
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
string id,
|
||||
float strength,
|
||||
string category,
|
||||
string label,
|
||||
bool createsInterest = true)
|
||||
{
|
||||
Entries[id] = new DiscreteEventEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
Category = category,
|
||||
CreatesInterest = createsInterest,
|
||||
LabelTemplate = label
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => Entries.Keys;
|
||||
|
||||
public static bool HasAuthored(string id) =>
|
||||
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
|
||||
return new DiscreteEventEntry
|
||||
{
|
||||
Id = key,
|
||||
EventStrength = 40f,
|
||||
Category = "Relationship",
|
||||
LabelTemplate = "{a} · {id}",
|
||||
CreatesInterest = false,
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
151
IdleSpectator/Events/Catalogs/EventCatalog.Status.cs
Normal file
151
IdleSpectator/Events/Catalogs/EventCatalog.Status.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public sealed class StatusInterestEntry
|
||||
{
|
||||
public string Id = "";
|
||||
public float EventStrength = 40f;
|
||||
public string Category = "Status";
|
||||
public bool CreatesInterest;
|
||||
public bool ExtendsGrief;
|
||||
public bool IsFallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authored interest policy for every live status asset. Prose stays in ActivityStatusProse.
|
||||
/// </summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class Status
|
||||
{
|
||||
private static readonly Dictionary<string, StatusInterestEntry> Entries =
|
||||
new Dictionary<string, StatusInterestEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static Status()
|
||||
{
|
||||
// Spectacle / camera-worthy transformations
|
||||
Add("burning", 70f, "StatusTransformation", true);
|
||||
Add("possessed", 72f, "StatusTransformation", true);
|
||||
Add("possessed_follower", 60f, "StatusTransformation", true);
|
||||
Add("frozen", 65f, "StatusTransformation", true);
|
||||
Add("poisoned", 58f, "StatusTransformation", true);
|
||||
Add("drowning", 50f, "StatusTransformation", true);
|
||||
Add("stunned", 50f, "StatusTransformation", true);
|
||||
Add("rage", 62f, "StatusTransformation", true);
|
||||
Add("angry", 48f, "StatusTransformation", true);
|
||||
Add("cursed", 60f, "StatusTransformation", true);
|
||||
Add("soul_harvested", 75f, "StatusTransformation", true);
|
||||
Add("voices_in_my_head", 55f, "StatusTransformation", true);
|
||||
Add("tantrum", 52f, "StatusTransformation", true);
|
||||
Add("egg", 40f, "StatusTransformation", false);
|
||||
Add("magnetized", 55f, "StatusTransformation", true);
|
||||
Add("invincible", 40f, "StatusTransformation", false);
|
||||
Add("powerup", 54f, "StatusTransformation", true);
|
||||
Add("enchanted", 52f, "StatusTransformation", true);
|
||||
Add("ash_fever", 50f, "StatusTransformation", true);
|
||||
Add("starving", 48f, "StatusTransformation", true);
|
||||
Add("surprised", 42f, "StatusTransformation", true);
|
||||
Add("confused", 40f, "StatusTransformation", true);
|
||||
Add("strange_urge", 45f, "StatusTransformation", true);
|
||||
Add("inspired", 42f, "Emotion", true);
|
||||
Add("motivated", 40f, "Emotion", true);
|
||||
Add("fell_in_love", 42f, "Relationship", false);
|
||||
Add("pregnant", 58f, "LifeChapter", true);
|
||||
Add("pregnant_parthenogenesis", 58f, "LifeChapter", true);
|
||||
Add("crying", 50f, "Grief", true, extendsGrief: true);
|
||||
|
||||
// Authored Ambient - never owns camera (task chip / enrichment only).
|
||||
Add("festive_spirit", 28f, "StatusAmbient", false);
|
||||
Add("laughing", 26f, "StatusAmbient", false);
|
||||
Add("singing", 26f, "StatusAmbient", false);
|
||||
Add("sleeping", 22f, "StatusAmbient", false);
|
||||
Add("handsome_migrant", 30f, "StatusAmbient", false);
|
||||
|
||||
// Authored but do not create interest candidates (cooldowns / ambient / brief FX)
|
||||
AddNoInterest("afterglow");
|
||||
AddNoInterest("being_suspicious");
|
||||
AddNoInterest("budding");
|
||||
AddNoInterest("caffeinated");
|
||||
AddNoInterest("cough");
|
||||
AddNoInterest("dash");
|
||||
AddNoInterest("dodge");
|
||||
AddNoInterest("flicked");
|
||||
AddNoInterest("had_bad_dream");
|
||||
AddNoInterest("had_good_dream");
|
||||
AddNoInterest("had_nightmare");
|
||||
AddNoInterest("just_ate");
|
||||
AddNoInterest("on_guard");
|
||||
AddNoInterest("recovery_combat_action");
|
||||
AddNoInterest("recovery_plot");
|
||||
AddNoInterest("recovery_social");
|
||||
AddNoInterest("recovery_spell");
|
||||
AddNoInterest("shield");
|
||||
AddNoInterest("slowness");
|
||||
AddNoInterest("spell_boost");
|
||||
AddNoInterest("spell_silence");
|
||||
AddNoInterest("swearing");
|
||||
AddNoInterest("taking_roots");
|
||||
AddNoInterest("uprooting");
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
string id,
|
||||
float strength,
|
||||
string category,
|
||||
bool createsInterest,
|
||||
bool extendsGrief = false)
|
||||
{
|
||||
Entries[id] = new StatusInterestEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
Category = category,
|
||||
CreatesInterest = createsInterest,
|
||||
ExtendsGrief = extendsGrief
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddNoInterest(string id)
|
||||
{
|
||||
Add(id, 20f, "StatusAmbient", false);
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => Entries.Keys;
|
||||
|
||||
public static bool HasAuthored(string statusId)
|
||||
{
|
||||
return !string.IsNullOrEmpty(statusId) && Entries.ContainsKey(statusId.Trim());
|
||||
}
|
||||
|
||||
public static bool TryGet(string statusId, out StatusInterestEntry entry)
|
||||
{
|
||||
entry = null;
|
||||
if (string.IsNullOrEmpty(statusId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Entries.TryGetValue(statusId.Trim(), out entry);
|
||||
}
|
||||
|
||||
public static StatusInterestEntry GetOrFallback(string statusId)
|
||||
{
|
||||
if (TryGet(statusId, out StatusInterestEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string id = string.IsNullOrEmpty(statusId) ? "unknown" : statusId.Trim();
|
||||
return new StatusInterestEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = 40f,
|
||||
Category = "Status",
|
||||
CreatesInterest = false,
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
169
IdleSpectator/Events/Catalogs/EventCatalog.Trait.cs
Normal file
169
IdleSpectator/Events/Catalogs/EventCatalog.Trait.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Authored interest overlay for every live traits library asset.</summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class Trait
|
||||
{
|
||||
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
|
||||
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static Trait()
|
||||
{
|
||||
Add("zombie", 82f);
|
||||
Add("wise", 42f);
|
||||
Add("whirlwind", 82f);
|
||||
Add("weightless", 42f);
|
||||
Add("weak", 42f);
|
||||
Add("veteran", 42f);
|
||||
Add("venomous", 82f);
|
||||
Add("unlucky", 42f);
|
||||
Add("ugly", 42f);
|
||||
Add("tumor_infection", 82f);
|
||||
Add("tough", 42f);
|
||||
Add("titan_lungs", 42f);
|
||||
Add("tiny", 42f);
|
||||
Add("thorns", 42f);
|
||||
Add("thief", 42f);
|
||||
Add("super_health", 42f);
|
||||
Add("sunblessed", 42f);
|
||||
Add("stupid", 42f);
|
||||
Add("strong_minded", 42f);
|
||||
Add("strong", 42f);
|
||||
Add("soft_skin", 42f);
|
||||
Add("slow", 42f);
|
||||
Add("skin_burns", 42f);
|
||||
Add("short_sighted", 42f);
|
||||
Add("shiny", 42f);
|
||||
Add("scar_of_divinity", 82f);
|
||||
Add("savage", 82f);
|
||||
Add("regeneration", 42f);
|
||||
Add("pyromaniac", 82f);
|
||||
Add("psychopath", 82f);
|
||||
Add("poisonous", 82f);
|
||||
Add("poison_immune", 42f);
|
||||
Add("plague", 82f);
|
||||
Add("peaceful", 42f);
|
||||
Add("paranoid", 42f);
|
||||
Add("pacifist", 42f);
|
||||
Add("nightchild", 42f);
|
||||
Add("mute", 42f);
|
||||
Add("mush_spores", 82f);
|
||||
Add("moonchild", 42f);
|
||||
Add("miracle_born", 82f);
|
||||
Add("miracle_bearer", 82f);
|
||||
Add("miner", 42f);
|
||||
Add("metamorphed", 42f);
|
||||
Add("mega_heartbeat", 42f);
|
||||
Add("mageslayer", 82f);
|
||||
Add("madness", 82f);
|
||||
Add("lustful", 42f);
|
||||
Add("lucky", 42f);
|
||||
Add("long_liver", 42f);
|
||||
Add("light_lamp", 42f);
|
||||
Add("kingslayer", 82f);
|
||||
Add("infertile", 42f);
|
||||
Add("infected", 82f);
|
||||
Add("immune", 42f);
|
||||
Add("immortal", 82f);
|
||||
Add("hotheaded", 42f);
|
||||
Add("honest", 42f);
|
||||
Add("heliophobia", 42f);
|
||||
Add("heart_of_wizard", 42f);
|
||||
Add("healing_aura", 42f);
|
||||
Add("hard_skin", 42f);
|
||||
Add("greedy", 42f);
|
||||
Add("golden_tooth", 42f);
|
||||
Add("gluttonous", 42f);
|
||||
Add("giant", 82f);
|
||||
Add("genius", 42f);
|
||||
Add("freeze_proof", 42f);
|
||||
Add("fragile_health", 42f);
|
||||
Add("flower_prints", 42f);
|
||||
Add("flesh_eater", 82f);
|
||||
Add("fire_proof", 42f);
|
||||
Add("fire_blood", 82f);
|
||||
Add("fertile", 42f);
|
||||
Add("fat", 42f);
|
||||
Add("fast", 42f);
|
||||
Add("eyepatch", 42f);
|
||||
Add("evil", 82f);
|
||||
Add("energized", 42f);
|
||||
Add("eagle_eyed", 42f);
|
||||
Add("dragonslayer", 82f);
|
||||
Add("dodge", 42f);
|
||||
Add("desire_harp", 42f);
|
||||
Add("desire_golden_egg", 42f);
|
||||
Add("desire_computer", 42f);
|
||||
Add("desire_alien_mold", 42f);
|
||||
Add("deflect_projectile", 42f);
|
||||
Add("deceitful", 42f);
|
||||
Add("death_nuke", 82f);
|
||||
Add("death_mark", 82f);
|
||||
Add("death_bomb", 82f);
|
||||
Add("dash", 42f);
|
||||
Add("crippled", 42f);
|
||||
Add("content", 42f);
|
||||
Add("contagious", 82f);
|
||||
Add("cold_aura", 82f);
|
||||
Add("clumsy", 42f);
|
||||
Add("clone", 42f);
|
||||
Add("chosen_one", 82f);
|
||||
Add("burning_feet", 82f);
|
||||
Add("bubble_defense", 42f);
|
||||
Add("boosted_vitality", 42f);
|
||||
Add("bomberman", 82f);
|
||||
Add("boat", 42f);
|
||||
Add("bloodlust", 82f);
|
||||
Add("block", 42f);
|
||||
Add("blessed", 42f);
|
||||
Add("battle_reflexes", 42f);
|
||||
Add("backstep", 42f);
|
||||
Add("attractive", 42f);
|
||||
Add("arcane_reflexes", 42f);
|
||||
Add("ambitious", 42f);
|
||||
Add("agile", 42f);
|
||||
Add("acid_touch", 82f);
|
||||
Add("acid_proof", 42f);
|
||||
Add("acid_blood", 42f);
|
||||
}
|
||||
|
||||
private static void Add(string id, float strength)
|
||||
{
|
||||
Entries[id] = new DiscreteEventEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
Category = "Trait",
|
||||
CreatesInterest = true,
|
||||
LabelTemplate = "Trait: {id} ({a})"
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => Entries.Keys;
|
||||
|
||||
public static bool HasAuthored(string id) =>
|
||||
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
|
||||
|
||||
public static DiscreteEventEntry GetOrFallback(string id)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
|
||||
return new DiscreteEventEntry
|
||||
{
|
||||
Id = key,
|
||||
EventStrength = 40f,
|
||||
Category = "Trait",
|
||||
LabelTemplate = "Trait: {id} ({a})",
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
95
IdleSpectator/Events/Catalogs/EventCatalog.WarType.cs
Normal file
95
IdleSpectator/Events/Catalogs/EventCatalog.WarType.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public sealed class WarTypeInterestEntry
|
||||
{
|
||||
public string Id = "";
|
||||
public float EventStrength = 85f;
|
||||
public string Category = "Politics";
|
||||
public string LabelTemplate = "War ({id})";
|
||||
public bool CreatesInterest = true;
|
||||
public bool IsFallback;
|
||||
}
|
||||
|
||||
/// <summary>Authored interest overlay for every live war_types_library asset.</summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class WarType
|
||||
{
|
||||
private static readonly Dictionary<string, WarTypeInterestEntry> Entries =
|
||||
new Dictionary<string, WarTypeInterestEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static WarType()
|
||||
{
|
||||
Add("normal", 90f, "War: {a}");
|
||||
Add("spite", 88f, "Spite war: {a}");
|
||||
Add("inspire", 86f, "Inspired war: {a}");
|
||||
Add("rebellion", 92f, "Rebellion: {a}");
|
||||
Add("whisper_of_war", 84f, "Whisper of war: {a}");
|
||||
}
|
||||
|
||||
private static void Add(string id, float strength, string labelTemplate)
|
||||
{
|
||||
Entries[id] = new WarTypeInterestEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
Category = "Politics",
|
||||
LabelTemplate = labelTemplate,
|
||||
CreatesInterest = true
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => Entries.Keys;
|
||||
|
||||
public static bool HasAuthored(string warTypeId)
|
||||
{
|
||||
return !string.IsNullOrEmpty(warTypeId) && Entries.ContainsKey(warTypeId.Trim());
|
||||
}
|
||||
|
||||
public static bool TryGet(string warTypeId, out WarTypeInterestEntry entry)
|
||||
{
|
||||
entry = null;
|
||||
if (string.IsNullOrEmpty(warTypeId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Entries.TryGetValue(warTypeId.Trim(), out entry);
|
||||
}
|
||||
|
||||
public static WarTypeInterestEntry GetOrFallback(string warTypeId)
|
||||
{
|
||||
if (TryGet(warTypeId, out WarTypeInterestEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string id = string.IsNullOrEmpty(warTypeId) ? "unknown" : warTypeId.Trim();
|
||||
return new WarTypeInterestEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = 85f,
|
||||
Category = "Politics",
|
||||
LabelTemplate = "War ({id}): {a}",
|
||||
CreatesInterest = true,
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
|
||||
public static string MakeLabel(WarTypeInterestEntry entry, string special1)
|
||||
{
|
||||
if (entry == null)
|
||||
{
|
||||
return "War";
|
||||
}
|
||||
|
||||
string template = string.IsNullOrEmpty(entry.LabelTemplate) ? "War ({id})" : entry.LabelTemplate;
|
||||
return template
|
||||
.Replace("{id}", entry.Id ?? "")
|
||||
.Replace("{a}", special1 ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
193
IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs
Normal file
193
IdleSpectator/Events/Catalogs/EventCatalog.WorldLog.cs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public sealed class WorldLogEventEntry
|
||||
{
|
||||
public string Id = "";
|
||||
public float EventStrength = 40f;
|
||||
public string Category = "WorldLog";
|
||||
public bool CreatesInterest = true;
|
||||
public bool ChronicleEligible;
|
||||
public float MinWatch = 6f;
|
||||
public float MaxWatch = 28f;
|
||||
public string LabelTemplate = "{id}";
|
||||
public bool IsFallback;
|
||||
|
||||
public string MakeLabel(string special1, string special2)
|
||||
{
|
||||
string a = special1 ?? "";
|
||||
string b = special2 ?? "";
|
||||
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
|
||||
return template
|
||||
.Replace("{id}", Id ?? "")
|
||||
.Replace("{a}", a)
|
||||
.Replace("{b}", b)
|
||||
.Replace("{special1}", a)
|
||||
.Replace("{special2}", b);
|
||||
}
|
||||
|
||||
public string MakeLabel(WorldLogMessage message)
|
||||
{
|
||||
if (message == null)
|
||||
{
|
||||
return MakeLabel("", "");
|
||||
}
|
||||
|
||||
return MakeLabel(message.special1, message.special2);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authored policy for every live WorldLog asset. Game library is inventory; this is presentation/scoring only.
|
||||
/// </summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
public static class WorldLog
|
||||
{
|
||||
private static readonly Dictionary<string, WorldLogEventEntry> Entries =
|
||||
new Dictionary<string, WorldLogEventEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static WorldLog()
|
||||
{
|
||||
// Kings
|
||||
Add("king_new", 78f, "Politics", true, "New king: {b} ({a})", 6f, 28f);
|
||||
Add("king_left", 65f, "Politics", true, "King left: {b} ({a})", 6f, 28f);
|
||||
Add("king_fled_capital", 70f, "Politics", true, "King fled capital: {b}", 6f, 28f);
|
||||
Add("king_fled_city", 68f, "Politics", true, "King fled city: {b}", 6f, 28f);
|
||||
Add("king_dead", 80f, "Politics", true, "King died: {b} ({a})", 6f, 28f);
|
||||
Add("king_killed", 85f, "Politics", true, "King killed: {b} ({a})", 6f, 28f);
|
||||
|
||||
// Favorites
|
||||
Add("favorite_dead", 82f, "Politics", true, "Favorite fell: {a}", 6f, 28f);
|
||||
Add("favorite_killed", 85f, "Politics", true, "Favorite fell: {a}", 6f, 28f);
|
||||
|
||||
// Cities / kingdoms / clans / wars / alliances
|
||||
Add("city_new", 72f, "Settlement", true, "New city: {a}", 6f, 28f);
|
||||
Add("log_city_revolted", 90f, "Politics", true, "Revolt: {a}", 8f, 35f);
|
||||
Add("city_destroyed", 92f, "Settlement", true, "City destroyed: {a}", 8f, 35f);
|
||||
Add("diplomacy_war_ended", 72f, "Politics", true, "War ended: {a}", 6f, 28f);
|
||||
Add("diplomacy_war_started", 100f, "Politics", true, "War: {a} vs {b}", 8f, 35f);
|
||||
Add("total_war_started", 100f, "Politics", true, "Total war: {a}", 8f, 35f);
|
||||
Add("alliance_new", 70f, "Politics", true, "Alliance: {a}", 6f, 28f);
|
||||
Add("alliance_dissolved", 68f, "Politics", true, "Alliance dissolved: {a}", 6f, 28f);
|
||||
Add("kingdom_new", 75f, "Settlement", true, "New kingdom: {a}", 6f, 28f);
|
||||
Add("kingdom_destroyed", 100f, "Politics", true, "Kingdom fell: {a}", 8f, 35f);
|
||||
Add("kingdom_shattered", 98f, "Politics", true, "Kingdom shattered: {a}", 8f, 35f);
|
||||
Add("kingdom_fractured", 95f, "Politics", true, "Kingdom fractured: {a}", 8f, 35f);
|
||||
Add("kingdom_royal_clan_new", 74f, "Politics", true, "Royal clan: {a}", 6f, 28f);
|
||||
Add("kingdom_royal_clan_changed", 72f, "Politics", true, "Royal clan: {a}", 6f, 28f);
|
||||
Add("kingdom_royal_clan_dead", 76f, "Politics", true, "Royal clan ended: {a}", 6f, 28f);
|
||||
|
||||
// Disasters (WorldLog ids; linked to disasters library by naming)
|
||||
Add("disaster_tornado", 95f, "Disaster", true, "Tornado: {a}", 8f, 35f);
|
||||
Add("disaster_meteorite", 95f, "Disaster", true, "Meteorite: {a}", 8f, 35f);
|
||||
Add("disaster_hellspawn", 96f, "Disaster", true, "Hellspawn: {a}", 8f, 35f);
|
||||
Add("disaster_earthquake", 95f, "Disaster", true, "Earthquake: {a}", 8f, 35f);
|
||||
Add("disaster_greg_abominations", 96f, "Disaster", true, "Greg abominations: {a}", 8f, 35f);
|
||||
Add("disaster_ice_ones", 95f, "Disaster", true, "Ice ones awaken: {a}", 8f, 35f);
|
||||
Add("disaster_sudden_snowman", 90f, "Disaster", true, "Sudden snowman: {a}", 8f, 35f);
|
||||
Add("disaster_garden_surprise", 90f, "Disaster", true, "Garden surprise: {a}", 8f, 35f);
|
||||
Add("disaster_dragon_from_farlands", 98f, "Disaster", true, "Dragon from the farlands: {a}", 8f, 35f);
|
||||
Add("disaster_bandits", 92f, "Disaster", true, "Bandit raid: {a}", 8f, 35f);
|
||||
Add("disaster_alien_invasion", 97f, "Disaster", true, "Alien invasion: {a}", 8f, 35f);
|
||||
Add("disaster_biomass", 96f, "Disaster", true, "Biomass outbreak: {a}", 8f, 35f);
|
||||
Add("disaster_tumor", 96f, "Disaster", true, "Tumor outbreak: {a}", 8f, 35f);
|
||||
Add("disaster_heatwave", 88f, "Disaster", true, "Heatwave: {a}", 8f, 35f);
|
||||
Add("disaster_evil_mage", 94f, "Disaster", true, "Evil mage: {a}", 8f, 35f);
|
||||
Add("disaster_underground_necromancer", 95f, "Disaster", true, "Underground necromancer: {a}", 8f, 35f);
|
||||
Add("disaster_mad_thoughts", 90f, "Disaster", true, "Mad thoughts: {a}", 8f, 35f);
|
||||
|
||||
// Harness / internal - authored but never owns camera interest
|
||||
Add("auto_tester", 10f, "Harness", false, "Auto tester: {a}", 2f, 6f);
|
||||
}
|
||||
|
||||
private static void Add(
|
||||
string id,
|
||||
float strength,
|
||||
string category,
|
||||
bool createsInterest,
|
||||
string labelTemplate,
|
||||
float minWatch,
|
||||
float maxWatch)
|
||||
{
|
||||
Entries[id] = new WorldLogEventEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
Category = category,
|
||||
CreatesInterest = createsInterest,
|
||||
ChronicleEligible = createsInterest && strength >= 65f,
|
||||
LabelTemplate = labelTemplate,
|
||||
MinWatch = minWatch,
|
||||
MaxWatch = maxWatch
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => Entries.Keys;
|
||||
|
||||
public static bool HasAuthored(string assetId)
|
||||
{
|
||||
return !string.IsNullOrEmpty(assetId) && Entries.ContainsKey(assetId.Trim());
|
||||
}
|
||||
|
||||
public static bool TryGet(string assetId, out WorldLogEventEntry entry)
|
||||
{
|
||||
entry = null;
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Entries.TryGetValue(assetId.Trim(), out entry);
|
||||
}
|
||||
|
||||
public static WorldLogEventEntry GetOrFallback(string assetId)
|
||||
{
|
||||
if (TryGet(assetId, out WorldLogEventEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string id = string.IsNullOrEmpty(assetId) ? "unknown" : assetId.Trim();
|
||||
float strength = 40f;
|
||||
string category = "WorldLog";
|
||||
if (id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
strength = 95f;
|
||||
category = "Disaster";
|
||||
}
|
||||
|
||||
return new WorldLogEventEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = strength,
|
||||
Category = category,
|
||||
CreatesInterest = true,
|
||||
ChronicleEligible = strength >= 65f,
|
||||
LabelTemplate = "{id}: {a}",
|
||||
MinWatch = strength >= 90f ? 8f : 6f,
|
||||
MaxWatch = strength >= 90f ? 35f : 28f,
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
|
||||
public static bool TryGetEventStrength(string assetId, out float eventStrength)
|
||||
{
|
||||
WorldLogEventEntry entry = GetOrFallback(assetId);
|
||||
eventStrength = entry.EventStrength;
|
||||
return !string.IsNullOrEmpty(assetId);
|
||||
}
|
||||
|
||||
public static string MakeLabel(WorldLogMessage message)
|
||||
{
|
||||
if (message == null)
|
||||
{
|
||||
return "event";
|
||||
}
|
||||
|
||||
return GetOrFallback(message.asset_id).MakeLabel(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
IdleSpectator/Events/Catalogs/LiveLibraryInterest.cs
Normal file
83
IdleSpectator/Events/Catalogs/LiveLibraryInterest.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Live-seeded interest catalogs: every live library id is authored (Ambient default),
|
||||
/// with curated Signal overlays for spectacle / kingdom-affecting entries.
|
||||
/// </summary>
|
||||
public static class LiveLibraryInterest
|
||||
{
|
||||
public static void EnsureSeeded(
|
||||
Dictionary<string, DiscreteEventEntry> entries,
|
||||
Func<List<string>> enumerateLive,
|
||||
string category,
|
||||
string labelTemplate,
|
||||
float ambientStrength,
|
||||
HashSet<string> signalIds,
|
||||
float signalStrength,
|
||||
Func<string, bool> signalPredicate = null,
|
||||
bool ambientCreatesInterest = true)
|
||||
{
|
||||
if (entries == null || enumerateLive == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<string> live;
|
||||
try
|
||||
{
|
||||
live = enumerateLive() ?? new List<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
live = new List<string>();
|
||||
}
|
||||
|
||||
for (int i = 0; i < live.Count; i++)
|
||||
{
|
||||
string id = live[i];
|
||||
if (string.IsNullOrEmpty(id) || entries.ContainsKey(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool signal = (signalIds != null && signalIds.Contains(id))
|
||||
|| (signalPredicate != null && signalPredicate(id));
|
||||
entries[id] = new DiscreteEventEntry
|
||||
{
|
||||
Id = id,
|
||||
EventStrength = signal ? signalStrength : ambientStrength,
|
||||
Category = category,
|
||||
CreatesInterest = signal || ambientCreatesInterest,
|
||||
LabelTemplate = labelTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscreteEventEntry Lookup(
|
||||
Dictionary<string, DiscreteEventEntry> entries,
|
||||
string id,
|
||||
string category,
|
||||
string labelTemplate,
|
||||
float fallbackStrength = 40f,
|
||||
bool fallbackCreatesInterest = false)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(id) && entries != null && entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
|
||||
return new DiscreteEventEntry
|
||||
{
|
||||
Id = key,
|
||||
EventStrength = fallbackStrength,
|
||||
Category = category,
|
||||
LabelTemplate = labelTemplate,
|
||||
CreatesInterest = fallbackCreatesInterest,
|
||||
IsFallback = true
|
||||
};
|
||||
}
|
||||
}
|
||||
29
IdleSpectator/Events/DiscreteEventEntry.cs
Normal file
29
IdleSpectator/Events/DiscreteEventEntry.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Shared catalog row for discrete / library-backed spectator events.
|
||||
/// Lives in <c>Events/</c> with <see cref="EventReason"/> - catalogs own dials; director only ranks.
|
||||
/// </summary>
|
||||
public sealed class DiscreteEventEntry
|
||||
{
|
||||
public string Id = "";
|
||||
public float EventStrength = 50f;
|
||||
public string Category = "Event";
|
||||
public bool CreatesInterest = true;
|
||||
public string LabelTemplate = "{a}";
|
||||
public bool IsFallback;
|
||||
|
||||
public string MakeLabel(Actor a, Actor b = null)
|
||||
{
|
||||
return EventReason.Apply(LabelTemplate, a, b, Id);
|
||||
}
|
||||
|
||||
public string MakeLabel(string a, string b)
|
||||
{
|
||||
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
|
||||
return template
|
||||
.Replace("{id}", Id ?? "")
|
||||
.Replace("{a}", a ?? "")
|
||||
.Replace("{b}", b ?? "");
|
||||
}
|
||||
}
|
||||
36
IdleSpectator/Events/EventCatalog.cs
Normal file
36
IdleSpectator/Events/EventCatalog.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// IdleSpectator event inventory (one type, split across partial files).
|
||||
/// Call sites: EventCatalog.Status / .Happiness / .Combat / …
|
||||
/// Director only ranks; feeds register from these dials.
|
||||
///
|
||||
/// Domain files under Events/Catalogs/:
|
||||
/// EventCatalog.Status.cs
|
||||
/// EventCatalog.Happiness.cs
|
||||
/// EventCatalog.WorldLog.cs
|
||||
/// EventCatalog.Trait.cs
|
||||
/// EventCatalog.Book.cs
|
||||
/// EventCatalog.Era.cs
|
||||
/// EventCatalog.Plot.cs
|
||||
/// EventCatalog.Relationship.cs
|
||||
/// EventCatalog.Disaster.cs
|
||||
/// EventCatalog.WarType.cs
|
||||
/// EventCatalog.Libraries.cs (Spell, Power, Decision, Item, SubspeciesTrait, Gene, Phenotype, WorldLaw, Biome)
|
||||
/// Combat policy lives in this file.
|
||||
/// </summary>
|
||||
public static partial class EventCatalog
|
||||
{
|
||||
/// <summary>Combat camera policy (activity + scanner share one key / dials).</summary>
|
||||
public static class Combat
|
||||
{
|
||||
public const float ActivityStrength = 88f;
|
||||
public const float ScannerStrengthFloor = 80f;
|
||||
public const float MinWatch = 4f;
|
||||
public const float MaxWatch = 60f;
|
||||
public const float ActivityTtl = 20f;
|
||||
public const float ScannerTtl = 18f;
|
||||
|
||||
public static string Key(long subjectId) => "combat:" + subjectId;
|
||||
}
|
||||
}
|
||||
264
IdleSpectator/Events/EventFeedUtil.cs
Normal file
264
IdleSpectator/Events/EventFeedUtil.cs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Sole unit-led publish API for the idle director.
|
||||
/// Every registration is EventLed and may own the camera (score ranks).
|
||||
/// FollowUnit owns the reason; RelatedUnit required when the label claims a second party;
|
||||
/// location-only civic events may omit FollowUnit but never invent a stranger subject.
|
||||
/// </summary>
|
||||
public static class EventFeedUtil
|
||||
{
|
||||
public static InterestCandidate Register(
|
||||
string key,
|
||||
string source,
|
||||
string category,
|
||||
string label,
|
||||
string assetId,
|
||||
float eventStrength,
|
||||
Vector3 position,
|
||||
Actor follow,
|
||||
Actor related = null,
|
||||
bool locationOnly = false,
|
||||
float minWatch = 4f,
|
||||
float maxWatch = 22f,
|
||||
InterestCompletionKind completion = InterestCompletionKind.FixedDwell)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Actor subject = follow;
|
||||
if (subject != null && !subject.isAlive())
|
||||
{
|
||||
subject = null;
|
||||
}
|
||||
|
||||
Actor relatedAlive = related;
|
||||
if (relatedAlive != null && !relatedAlive.isAlive())
|
||||
{
|
||||
relatedAlive = null;
|
||||
}
|
||||
|
||||
Vector3 pos = position;
|
||||
if (subject != null)
|
||||
{
|
||||
pos = subject.current_position;
|
||||
}
|
||||
|
||||
if (locationOnly)
|
||||
{
|
||||
if (pos == Vector3.zero || float.IsNaN(pos.x))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unit-led: require a living subject. Never invent a stranger.
|
||||
if (subject == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pos == Vector3.zero || float.IsNaN(pos.x))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
float strength = eventStrength;
|
||||
if (strength < 8f)
|
||||
{
|
||||
strength = 8f;
|
||||
}
|
||||
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = string.IsNullOrEmpty(category) ? "Event" : category,
|
||||
Source = source ?? "event",
|
||||
EventStrength = strength,
|
||||
CharacterSignificance = 0f,
|
||||
VisualConfidence = subject != null ? 0.7f : 0.35f,
|
||||
Position = pos,
|
||||
FollowUnit = subject,
|
||||
RelatedUnit = relatedAlive,
|
||||
SubjectId = subject != null ? SafeId(subject) : 0,
|
||||
RelatedId = relatedAlive != null ? SafeId(relatedAlive) : 0,
|
||||
Label = label ?? assetId ?? key,
|
||||
AssetId = assetId ?? "",
|
||||
SpeciesId = subject?.asset != null ? subject.asset.id : "",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 35f,
|
||||
MinWatch = minWatch,
|
||||
MaxWatch = maxWatch,
|
||||
Completion = completion
|
||||
};
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
return InterestRegistry.Upsert(candidate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publish a fully built candidate through the registry (still the sole intake).
|
||||
/// Prefer the typed Register overload for new feeds.
|
||||
/// </summary>
|
||||
public static InterestCandidate RegisterCandidate(InterestCandidate candidate)
|
||||
{
|
||||
if (candidate == null || string.IsNullOrEmpty(candidate.Key) || !candidate.HasValidPosition)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reject stranger subjects: dead follow with no position is already invalid via HasValidPosition.
|
||||
if (candidate.FollowUnit != null && !candidate.FollowUnit.isAlive())
|
||||
{
|
||||
candidate.FollowUnit = null;
|
||||
candidate.SubjectId = 0;
|
||||
if (!candidate.HasValidPosition)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.RelatedUnit != null && !candidate.RelatedUnit.isAlive())
|
||||
{
|
||||
candidate.RelatedUnit = null;
|
||||
candidate.RelatedId = 0;
|
||||
}
|
||||
|
||||
if (candidate.FollowUnit != null && candidate.SubjectId == 0)
|
||||
{
|
||||
candidate.SubjectId = SafeId(candidate.FollowUnit);
|
||||
}
|
||||
|
||||
if (candidate.RelatedUnit != null && candidate.RelatedId == 0)
|
||||
{
|
||||
candidate.RelatedId = SafeId(candidate.RelatedUnit);
|
||||
}
|
||||
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
return InterestRegistry.Upsert(candidate);
|
||||
}
|
||||
|
||||
public static long SafeId(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return actor.getID();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static string SafeName(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return actor.getName() ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public static Actor NearestUnit(Vector3 near, float radius = 200f)
|
||||
{
|
||||
try
|
||||
{
|
||||
return WorldActivityScanner.FindNearestAliveUnit(near, radius);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Harness / diagnostics only. Must not be used as a silent camera subject for EventLed events.
|
||||
/// </summary>
|
||||
public static Actor AnyAliveUnit()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (World.world?.units == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (Actor actor in World.world.units)
|
||||
{
|
||||
if (actor != null && actor.isAlive())
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a unit-led anchor without stranger fallback.
|
||||
/// Preferred unit if alive; else nearest at position; else fail (caller may location-only).
|
||||
/// </summary>
|
||||
public static bool TryResolveOwnedAnchor(
|
||||
Actor preferred,
|
||||
Vector3 position,
|
||||
out Actor follow,
|
||||
out Vector3 pos,
|
||||
bool allowNearestAtPosition = true)
|
||||
{
|
||||
follow = null;
|
||||
pos = position;
|
||||
|
||||
if (preferred != null && preferred.isAlive())
|
||||
{
|
||||
follow = preferred;
|
||||
pos = preferred.current_position;
|
||||
return pos != Vector3.zero && !float.IsNaN(pos.x);
|
||||
}
|
||||
|
||||
if (allowNearestAtPosition && position != Vector3.zero && !float.IsNaN(position.x))
|
||||
{
|
||||
follow = NearestUnit(position, 48f);
|
||||
if (follow != null && follow.isAlive())
|
||||
{
|
||||
pos = follow.current_position;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
follow = null;
|
||||
pos = position;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Obsolete stranger path. Prefer TryResolveOwnedAnchor or location-only Register.</summary>
|
||||
[System.Obsolete("Use TryResolveOwnedAnchor or location-only Register. Do not invent subjects.")]
|
||||
public static bool TryResolveAnchor(Actor preferred, Vector3 position, out Actor follow, out Vector3 pos)
|
||||
{
|
||||
return TryResolveOwnedAnchor(preferred, position, out follow, out pos, allowNearestAtPosition: true);
|
||||
}
|
||||
}
|
||||
371
IdleSpectator/Events/EventReason.cs
Normal file
371
IdleSpectator/Events/EventReason.cs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Sole factory for interest Labels shown as the orange dossier reason.
|
||||
/// Part of the <c>Events/</c> module with catalogs - feeds call these helpers (or Apply)
|
||||
/// instead of concatenating crumbs. Director never authors reasons.
|
||||
/// </summary>
|
||||
public static class EventReason
|
||||
{
|
||||
public static string Fight(Actor a, Actor b)
|
||||
{
|
||||
string name = Name(a);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
name = "Someone";
|
||||
}
|
||||
|
||||
string foe = Name(b);
|
||||
if (string.IsNullOrEmpty(foe))
|
||||
{
|
||||
return name + " is fighting";
|
||||
}
|
||||
|
||||
return name + " is fighting " + foe;
|
||||
}
|
||||
|
||||
public static string Battle(Actor focus, int fighters)
|
||||
{
|
||||
string name = Name(focus);
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
name = "Someone";
|
||||
}
|
||||
|
||||
int n = Math.Max(1, fighters);
|
||||
if (n <= 2)
|
||||
{
|
||||
return name + " is in a duel";
|
||||
}
|
||||
|
||||
return name + " is in a fight of " + n.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string SeekingLover(Actor a)
|
||||
{
|
||||
return NameOrSomeone(a) + " is seeking a lover";
|
||||
}
|
||||
|
||||
public static string Hatch(Actor a)
|
||||
{
|
||||
return NameOrSomeone(a) + " hatches from an egg";
|
||||
}
|
||||
|
||||
public static string Lovers(Actor a, Actor b)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
string bn = Name(b);
|
||||
if (string.IsNullOrEmpty(bn))
|
||||
{
|
||||
return an + " found a lover";
|
||||
}
|
||||
|
||||
return an + " became lovers with " + bn;
|
||||
}
|
||||
|
||||
public static string LoverParted(Actor a)
|
||||
{
|
||||
return NameOrSomeone(a) + " parted from a lover";
|
||||
}
|
||||
|
||||
public static string FamilyPack(Actor a, Actor peer, string phase)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
string bn = Name(peer);
|
||||
string p = (phase ?? "").Trim().ToLowerInvariant();
|
||||
switch (p)
|
||||
{
|
||||
case "join":
|
||||
case "family_group_join":
|
||||
return string.IsNullOrEmpty(bn)
|
||||
? an + " joins a family pack"
|
||||
: an + " joins a family pack with " + bn;
|
||||
case "leave":
|
||||
case "family_group_leave":
|
||||
return string.IsNullOrEmpty(bn)
|
||||
? an + " leaves a family pack"
|
||||
: an + " leaves a family pack with " + bn;
|
||||
case "form":
|
||||
case "new":
|
||||
case "family_group_new":
|
||||
return string.IsNullOrEmpty(bn)
|
||||
? an + " forms a family pack"
|
||||
: an + " forms a family pack with " + bn;
|
||||
default:
|
||||
return an + " gathers with kin";
|
||||
}
|
||||
}
|
||||
|
||||
public static string NewChild(Actor a, Actor b)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
string bn = Name(b);
|
||||
if (string.IsNullOrEmpty(bn))
|
||||
{
|
||||
return an + " has a new child";
|
||||
}
|
||||
|
||||
// related is the child (Actor.addChild), not a co-parent.
|
||||
return an + " gains a child, " + bn;
|
||||
}
|
||||
|
||||
public static string BabyBorn(Actor a)
|
||||
{
|
||||
// Subject is the baby from createBabyActorFromData.
|
||||
return NameOrSomeone(a) + " is created as a baby";
|
||||
}
|
||||
|
||||
public static string NewFamily(Actor a)
|
||||
{
|
||||
return NameOrSomeone(a) + " starts a new family";
|
||||
}
|
||||
|
||||
public static string FamilyEnded(Actor a)
|
||||
{
|
||||
return NameOrSomeone(a) + "'s family ends";
|
||||
}
|
||||
|
||||
public static string BuildingEat(Actor a, Building food)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
string buildingId = BuildingId(food);
|
||||
string buildingType = BuildingType(food);
|
||||
|
||||
if (string.Equals(buildingType, "type_fruits", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return string.IsNullOrEmpty(buildingId)
|
||||
? an + " is foraging fruit"
|
||||
: an + " is foraging " + HumanizeId(buildingId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(buildingId))
|
||||
{
|
||||
return an + " is eating a " + HumanizeId(buildingId);
|
||||
}
|
||||
|
||||
return an + " is eating";
|
||||
}
|
||||
|
||||
public static string BuildingDamage(Actor a, Building building)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
string buildingId = BuildingId(building);
|
||||
if (!string.IsNullOrEmpty(buildingId))
|
||||
{
|
||||
return an + " is damaging a " + HumanizeId(buildingId);
|
||||
}
|
||||
|
||||
return an + " is damaging a building";
|
||||
}
|
||||
|
||||
public static string BuildingFalls(string buildingId, Actor near)
|
||||
{
|
||||
string id = HumanizeId(buildingId);
|
||||
if (near != null && near.isAlive())
|
||||
{
|
||||
string n = Name(near);
|
||||
if (!string.IsNullOrEmpty(n))
|
||||
{
|
||||
return string.IsNullOrEmpty(id)
|
||||
? "A building falls near " + n
|
||||
: "A " + id + " falls near " + n;
|
||||
}
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(id) ? "A building falls" : "A " + id + " falls";
|
||||
}
|
||||
|
||||
public static string Status(Actor a, string phrase)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
string p = (phrase ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(p))
|
||||
{
|
||||
return an + " changes status";
|
||||
}
|
||||
|
||||
// Prose often already includes a verb phrase ("is burning").
|
||||
if (p.StartsWith("is ", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.StartsWith("are ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return an + " " + p;
|
||||
}
|
||||
|
||||
return an + " · " + p;
|
||||
}
|
||||
|
||||
public static string Trait(Actor a, string traitId, bool gained)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
string id = HumanizeId(traitId);
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
id = "a trait";
|
||||
}
|
||||
|
||||
return gained ? an + " gains " + id : an + " loses " + id;
|
||||
}
|
||||
|
||||
public static string Boat(Actor a, string phase)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
switch ((phase ?? "").Trim().ToLowerInvariant())
|
||||
{
|
||||
case "unload":
|
||||
case "boat_unload":
|
||||
return an + " is unloading passengers";
|
||||
case "load":
|
||||
case "boat_load":
|
||||
return an + " is loading passengers";
|
||||
case "trade":
|
||||
case "boat_trade":
|
||||
return an + " is trading by boat";
|
||||
default:
|
||||
return an + " is on a boat";
|
||||
}
|
||||
}
|
||||
|
||||
public static string MetaNew(Actor a, string kind)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
string k = HumanizeId(kind);
|
||||
if (string.IsNullOrEmpty(k))
|
||||
{
|
||||
k = "something new";
|
||||
}
|
||||
|
||||
return an + " founds a " + k;
|
||||
}
|
||||
|
||||
public static string Library(Actor a, string kind, string assetId)
|
||||
{
|
||||
string an = NameOrSomeone(a);
|
||||
string id = HumanizeId(assetId);
|
||||
string k = HumanizeId(kind);
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return an + " triggers " + (string.IsNullOrEmpty(k) ? "an event" : k);
|
||||
}
|
||||
|
||||
switch ((kind ?? "").Trim().ToLowerInvariant())
|
||||
{
|
||||
case "spell":
|
||||
return an + " casts " + id;
|
||||
case "item":
|
||||
return an + " forges " + id;
|
||||
case "decision":
|
||||
return an + " decides " + id;
|
||||
case "gene":
|
||||
return an + " gains gene " + id;
|
||||
case "phenotype":
|
||||
return an + " shows phenotype " + id;
|
||||
case "power":
|
||||
return an + " channels " + id;
|
||||
case "subspecies_trait":
|
||||
return an + " gains " + id;
|
||||
default:
|
||||
return an + " · " + id;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a sentence template with {a}/{b}/{id}. Prefer typed helpers for new copy.
|
||||
/// </summary>
|
||||
public static string Apply(string template, Actor a, Actor b = null, string id = "")
|
||||
{
|
||||
string t = string.IsNullOrEmpty(template) ? "{a}" : template;
|
||||
return t
|
||||
.Replace("{id}", id ?? "")
|
||||
.Replace("{a}", Name(a) ?? "")
|
||||
.Replace("{b}", Name(b) ?? "");
|
||||
}
|
||||
|
||||
public static string HumanizeId(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string raw = id.Trim().Replace('-', '_');
|
||||
if (raw.StartsWith("type_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
raw = raw.Substring(5);
|
||||
}
|
||||
|
||||
string[] parts = raw.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(raw.Length + 4);
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
sb.Append(' ');
|
||||
}
|
||||
|
||||
string p = parts[i];
|
||||
if (p.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(char.ToUpperInvariant(p[0]));
|
||||
if (p.Length > 1)
|
||||
{
|
||||
sb.Append(p.Substring(1).ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string NameOrSomeone(Actor a)
|
||||
{
|
||||
string n = Name(a);
|
||||
return string.IsNullOrEmpty(n) ? "Someone" : n;
|
||||
}
|
||||
|
||||
private static string Name(Actor a) => EventFeedUtil.SafeName(a);
|
||||
|
||||
private static string BuildingId(Building building)
|
||||
{
|
||||
if (building?.asset == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return building.asset.id ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildingType(Building building)
|
||||
{
|
||||
if (building?.asset == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return building.asset.type ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
82
IdleSpectator/Events/Feeds/BookInterestFeed.cs
Normal file
82
IdleSpectator/Events/Feeds/BookInterestFeed.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Registers book creation/burn interest candidates.</summary>
|
||||
public static class BookInterestFeed
|
||||
{
|
||||
public static void Emit(string bookTypeId, Actor subject, string phase)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DiscreteEventEntry entry = EventCatalog.Book.GetOrFallback(bookTypeId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor follow = subject;
|
||||
if (follow == null || !follow.isAlive())
|
||||
{
|
||||
// Book without a living author: skip rather than invent a stranger.
|
||||
return;
|
||||
}
|
||||
|
||||
string label = EventReason.Apply(entry.LabelTemplate, follow, id: entry.Id);
|
||||
if (!string.IsNullOrEmpty(phase) && phase == "burn")
|
||||
{
|
||||
label = EventFeedUtil.SafeName(follow) + " burns a book";
|
||||
}
|
||||
|
||||
string key = "book:" + entry.Id + ":" + (phase ?? "new") + ":" + EventFeedUtil.SafeId(follow);
|
||||
float strength = phase == "burn" ? Mathf.Max(entry.EventStrength, 70f) : entry.EventStrength;
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"book",
|
||||
entry.Category,
|
||||
label,
|
||||
entry.Id,
|
||||
strength,
|
||||
follow.current_position,
|
||||
follow);
|
||||
}
|
||||
|
||||
public static void EmitFromBook(object book, string phase)
|
||||
{
|
||||
if (book == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string typeId = "unknown";
|
||||
Actor author = null;
|
||||
try
|
||||
{
|
||||
object asset = book.GetType().GetField("asset")?.GetValue(book)
|
||||
?? book.GetType().GetProperty("asset")?.GetValue(book, null)
|
||||
?? book.GetType().GetMethod("getAsset")?.Invoke(book, null);
|
||||
if (asset != null)
|
||||
{
|
||||
object id = asset.GetType().GetField("id")?.GetValue(asset)
|
||||
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
|
||||
if (id != null)
|
||||
{
|
||||
typeId = id.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
author = book.GetType().GetField("author")?.GetValue(book) as Actor
|
||||
?? book.GetType().GetProperty("author")?.GetValue(book, null) as Actor
|
||||
?? book.GetType().GetMethod("getAuthor")?.Invoke(book, null) as Actor;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
Emit(typeId, author, phase);
|
||||
}
|
||||
}
|
||||
199
IdleSpectator/Events/Feeds/DeferredInterestFeed.cs
Normal file
199
IdleSpectator/Events/Feeds/DeferredInterestFeed.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Unit-owned emits for deferred Phase 2 libraries (spells/items/decisions/etc.).</summary>
|
||||
public static class DeferredInterestFeed
|
||||
{
|
||||
public static void EmitSpell(string spellId, Actor caster)
|
||||
{
|
||||
EmitFromCatalog(
|
||||
"spell",
|
||||
spellId,
|
||||
caster,
|
||||
EventCatalog.Spell.GetOrFallback,
|
||||
related: null,
|
||||
locationOnly: false);
|
||||
}
|
||||
|
||||
public static void EmitItem(string itemId, Actor craftsman)
|
||||
{
|
||||
EmitFromCatalog(
|
||||
"item",
|
||||
itemId,
|
||||
craftsman,
|
||||
EventCatalog.Item.GetOrFallback,
|
||||
related: null,
|
||||
locationOnly: false);
|
||||
}
|
||||
|
||||
public static void EmitDecision(string decisionId, Actor actor)
|
||||
{
|
||||
EmitFromCatalog(
|
||||
"decision",
|
||||
decisionId,
|
||||
actor,
|
||||
EventCatalog.Decision.GetOrFallback,
|
||||
related: null,
|
||||
locationOnly: false);
|
||||
}
|
||||
|
||||
public static void EmitPower(string powerId, Vector3 position, Actor nearUnit)
|
||||
{
|
||||
DiscreteEventEntry entry = EventCatalog.Power.GetOrFallback(powerId);
|
||||
if (!entry.CreatesInterest || AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (nearUnit != null && nearUnit.isAlive())
|
||||
{
|
||||
EmitFromCatalog("power", powerId, nearUnit, EventCatalog.Power.GetOrFallback, null, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (position == Vector3.zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EventFeedUtil.Register(
|
||||
"power:" + entry.Id,
|
||||
"power",
|
||||
entry.Category,
|
||||
EventReason.HumanizeId(entry.Id),
|
||||
entry.Id,
|
||||
entry.EventStrength,
|
||||
position,
|
||||
follow: null,
|
||||
locationOnly: true);
|
||||
}
|
||||
|
||||
public static void EmitSubspeciesTrait(string traitId, Actor anchor)
|
||||
{
|
||||
EmitFromCatalog(
|
||||
"subspecies_trait",
|
||||
traitId,
|
||||
anchor,
|
||||
EventCatalog.SubspeciesTrait.GetOrFallback,
|
||||
related: null,
|
||||
locationOnly: false);
|
||||
}
|
||||
|
||||
public static void EmitGene(string geneId, Actor actor)
|
||||
{
|
||||
EmitFromCatalog(
|
||||
"gene",
|
||||
geneId,
|
||||
actor,
|
||||
EventCatalog.Gene.GetOrFallback,
|
||||
related: null,
|
||||
locationOnly: false);
|
||||
}
|
||||
|
||||
public static void EmitPhenotype(string phenotypeId, Actor actor)
|
||||
{
|
||||
EmitFromCatalog(
|
||||
"phenotype",
|
||||
phenotypeId,
|
||||
actor,
|
||||
EventCatalog.Phenotype.GetOrFallback,
|
||||
related: null,
|
||||
locationOnly: false);
|
||||
}
|
||||
|
||||
public static void EmitWorldLaw(string lawId, Vector3 position)
|
||||
{
|
||||
if (AgentHarness.Busy || position == Vector3.zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DiscreteEventEntry entry = EventCatalog.WorldLaw.GetOrFallback(lawId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EventFeedUtil.Register(
|
||||
"worldlaw:" + entry.Id,
|
||||
"worldlaw",
|
||||
entry.Category,
|
||||
EventReason.HumanizeId(entry.Id),
|
||||
entry.Id,
|
||||
entry.EventStrength,
|
||||
position,
|
||||
follow: null,
|
||||
locationOnly: true);
|
||||
}
|
||||
|
||||
public static void EmitBiome(string biomeId, Vector3 position)
|
||||
{
|
||||
if (AgentHarness.Busy || position == Vector3.zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DiscreteEventEntry entry = EventCatalog.Biome.GetOrFallback(biomeId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EventFeedUtil.Register(
|
||||
"biome:" + entry.Id,
|
||||
"biome",
|
||||
entry.Category,
|
||||
EventReason.HumanizeId(entry.Id),
|
||||
entry.Id,
|
||||
entry.EventStrength,
|
||||
position,
|
||||
follow: null,
|
||||
locationOnly: true);
|
||||
}
|
||||
|
||||
private static void EmitFromCatalog(
|
||||
string source,
|
||||
string assetId,
|
||||
Actor subject,
|
||||
System.Func<string, DiscreteEventEntry> lookup,
|
||||
Actor related,
|
||||
bool locationOnly)
|
||||
{
|
||||
if (AgentHarness.Busy || lookup == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DiscreteEventEntry entry = lookup(assetId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor follow = subject != null && subject.isAlive() ? subject : null;
|
||||
if (!locationOnly && follow == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string label = EventReason.Library(follow, source, entry.Id);
|
||||
if (follow == null)
|
||||
{
|
||||
label = EventReason.HumanizeId(entry.Id);
|
||||
}
|
||||
|
||||
string key = source + ":" + entry.Id + ":" + EventFeedUtil.SafeId(follow);
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
source,
|
||||
entry.Category,
|
||||
label,
|
||||
entry.Id,
|
||||
entry.EventStrength,
|
||||
follow != null ? follow.current_position : Vector3.zero,
|
||||
follow,
|
||||
related: related,
|
||||
locationOnly: locationOnly);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
|
|
@ -92,11 +93,13 @@ public static class InterestFeeds
|
|||
return;
|
||||
}
|
||||
|
||||
if (!WorldLogInterestTable.TryGetEventStrength(message.asset_id, out float evtSeed))
|
||||
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(message.asset_id);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float evtSeed = entry.EventStrength;
|
||||
Vector3 position = message.getLocation();
|
||||
Actor unit = null;
|
||||
if (message.hasFollowLocation())
|
||||
|
|
@ -104,41 +107,45 @@ public static class InterestFeeds
|
|||
unit = message.unit;
|
||||
}
|
||||
|
||||
if (unit == null && position == Vector3.zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string assetId = message.asset_id ?? "worldlog";
|
||||
string assetId = message.asset_id ?? entry.Id ?? "worldlog";
|
||||
string kingdom = message.special1 ?? "";
|
||||
string key = "worldlog:" + assetId + ":" + kingdom;
|
||||
float boost = PeekCivicBoost(kingdom, assetId);
|
||||
bool politics = evtSeed >= 90f;
|
||||
string category = string.IsNullOrEmpty(entry.Category) ? "WorldLog" : entry.Category;
|
||||
string label = entry.MakeLabel(message);
|
||||
|
||||
var candidate = new InterestCandidate
|
||||
if (EventFeedUtil.TryResolveOwnedAnchor(unit, position, out Actor follow, out Vector3 pos))
|
||||
{
|
||||
Key = key,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = politics ? "Politics" : "Settlement",
|
||||
Source = "worldlog",
|
||||
EventStrength = evtSeed + boost,
|
||||
VisualConfidence = unit != null ? 0.7f : 0.4f,
|
||||
Position = unit != null ? unit.current_position : position,
|
||||
FollowUnit = unit,
|
||||
SubjectId = SafeId(unit),
|
||||
Label = WorldLogInterestTable.MakeLabel(message),
|
||||
AssetId = assetId,
|
||||
KingdomKey = kingdom,
|
||||
SpeciesId = unit?.asset != null ? unit.asset.id : "",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 40f,
|
||||
MinWatch = politics ? 8f : 6f,
|
||||
MaxWatch = politics ? 35f : 28f,
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
InterestRegistry.Upsert(candidate);
|
||||
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>
|
||||
|
|
@ -197,7 +204,7 @@ public static class InterestFeeds
|
|||
Completion = completion
|
||||
};
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
return InterestRegistry.Upsert(candidate);
|
||||
return EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
|
||||
public static InterestCandidate RegisterHarness(
|
||||
|
|
@ -287,7 +294,7 @@ public static class InterestFeeds
|
|||
}
|
||||
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
return InterestRegistry.Upsert(candidate);
|
||||
return EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
|
||||
public static void OnActivityNote(Actor actor, string taskOrBeat, bool hot)
|
||||
|
|
@ -297,22 +304,42 @@ public static class InterestFeeds
|
|||
return;
|
||||
}
|
||||
|
||||
// Non-combat hot activity is not an event reason - task chip covers live job.
|
||||
if (!actor.has_attack_target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long id = SafeId(actor);
|
||||
string verb = taskOrBeat ?? "activity";
|
||||
string key = "activity:" + id + ":" + verb;
|
||||
bool combat = actor.has_attack_target;
|
||||
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";
|
||||
string key = EventCatalog.Combat.Key(id);
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = combat ? "Combat" : "Work",
|
||||
Category = "Combat",
|
||||
Source = "activity",
|
||||
EventStrength = combat ? 75f : 50f,
|
||||
EventStrength = EventCatalog.Combat.ActivityStrength,
|
||||
VisualConfidence = 0.8f,
|
||||
Position = actor.current_position,
|
||||
FollowUnit = actor,
|
||||
RelatedUnit = foe,
|
||||
SubjectId = id,
|
||||
Label = combat ? ("Fighting: " + SafeName(actor)) : (SafeName(actor) + " · " + verb),
|
||||
RelatedId = foe != null ? SafeId(foe) : 0,
|
||||
Label = EventReason.Fight(actor, foe),
|
||||
AssetId = actor.asset != null ? actor.asset.id : "",
|
||||
SpeciesId = actor.asset != null ? actor.asset.id : "",
|
||||
Verb = verb,
|
||||
|
|
@ -320,19 +347,19 @@ public static class InterestFeeds
|
|||
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 20f,
|
||||
MinWatch = 3f,
|
||||
MaxWatch = 30f,
|
||||
Completion = combat ? InterestCompletionKind.CombatActive : InterestCompletionKind.ActivityActive
|
||||
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ActivityTtl,
|
||||
MinWatch = EventCatalog.Combat.MinWatch,
|
||||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||
Completion = InterestCompletionKind.CombatActive
|
||||
};
|
||||
candidate.ParticipantIds.Add(id);
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
InterestRegistry.Upsert(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
|
||||
public static void OnStatusChange(Actor actor, string statusId, bool gained)
|
||||
{
|
||||
if (actor == null || !actor.isAlive() || !gained || string.IsNullOrEmpty(statusId))
|
||||
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(statusId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -342,53 +369,110 @@ public static class InterestFeeds
|
|||
return;
|
||||
}
|
||||
|
||||
// Visible spectacle statuses amplify / extend scenes; crying extends grief.
|
||||
bool spectacle = statusId == "burning"
|
||||
|| statusId == "possessed"
|
||||
|| statusId == "crying"
|
||||
|| statusId == "frozen"
|
||||
|| statusId == "shocked";
|
||||
if (!spectacle)
|
||||
// Egg timer end is the reliable hatch beat. Happiness just_got_out_of_egg is often
|
||||
// suppressed (no emotions / still flagged egg), so status loss owns the camera reason.
|
||||
if (!gained && string.Equals(statusId.Trim(), "egg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
EmitHatch(actor, "status_egg_loss");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!gained)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long id = SafeId(actor);
|
||||
if (statusId == "crying")
|
||||
if (entry.ExtendsGrief || statusId == "crying")
|
||||
{
|
||||
// Extend existing grief candidate rather than spawning a second scene.
|
||||
string griefPrefix = "happiness:death_";
|
||||
ExtendMatching(id, griefPrefix, statusId);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
string key = "status:" + statusId + ":" + id;
|
||||
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 = key,
|
||||
Key = HatchKey(id),
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = "StatusTransformation",
|
||||
Source = "status",
|
||||
EventStrength = 55f,
|
||||
VisualConfidence = 0.85f,
|
||||
Category = "LifeChapter",
|
||||
Source = string.IsNullOrEmpty(source) ? "hatch" : source,
|
||||
EventStrength = strength,
|
||||
VisualConfidence = 0.8f,
|
||||
Position = actor.current_position,
|
||||
FollowUnit = actor,
|
||||
SubjectId = id,
|
||||
StatusId = statusId,
|
||||
Label = SafeName(actor) + " · " + statusId,
|
||||
AssetId = actor.asset != null ? actor.asset.id : "",
|
||||
SpeciesId = actor.asset != null ? actor.asset.id : "",
|
||||
Label = EventReason.Hatch(actor),
|
||||
AssetId = SafeAsset(actor),
|
||||
SpeciesId = SafeAsset(actor),
|
||||
HappinessEffectId = "just_got_out_of_egg",
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 18f,
|
||||
MinWatch = 2f,
|
||||
MaxWatch = 22f,
|
||||
Completion = InterestCompletionKind.StatusPhase
|
||||
ExpiresAt = Time.unscaledTime + 7f,
|
||||
MinWatch = 4f,
|
||||
MaxWatch = 15f,
|
||||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
candidate.ParticipantIds.Add(id);
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
InterestRegistry.Upsert(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
|
||||
private static string HatchKey(long subjectId) => "hatch:" + subjectId;
|
||||
|
||||
public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label)
|
||||
{
|
||||
if (subject == null || !subject.isAlive() || string.IsNullOrEmpty(milestoneKey))
|
||||
|
|
@ -429,7 +513,7 @@ public static class InterestFeeds
|
|||
Completion = InterestCompletionKind.FixedDwell
|
||||
};
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
InterestRegistry.Upsert(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
|
||||
private static void DrainHappiness()
|
||||
|
|
@ -511,17 +595,45 @@ public static class InterestFeeds
|
|||
|
||||
Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null;
|
||||
string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory;
|
||||
string key = "happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId
|
||||
+ ":" + CorrBucket(occ.CorrelationKey);
|
||||
bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_");
|
||||
float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId);
|
||||
bool hatchMoment = IsFreshLifeMoment(occ.EffectId);
|
||||
|
||||
// Hatch shares a key with status egg-loss so both paths merge into one camera beat.
|
||||
string key = hatchMoment
|
||||
? HatchKey(occ.SubjectId)
|
||||
: ("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);
|
||||
}
|
||||
|
||||
string label;
|
||||
if (hatchMoment)
|
||||
{
|
||||
label = EventReason.Hatch(subject);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(occ.PlainClause))
|
||||
{
|
||||
label = occ.SubjectName + " " + occ.PlainClause;
|
||||
}
|
||||
else
|
||||
{
|
||||
label = occ.SubjectName + " · " + occ.EffectId;
|
||||
}
|
||||
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = grief ? "FamilyEmotion" : MapHappinessCategory(cat),
|
||||
Source = "happiness",
|
||||
Source = hatchMoment ? "happiness_hatch" : "happiness",
|
||||
EventStrength = strength,
|
||||
VisualConfidence = 0.75f,
|
||||
Position = occ.Position != Vector3.zero ? occ.Position : subject.current_position,
|
||||
|
|
@ -529,9 +641,7 @@ public static class InterestFeeds
|
|||
RelatedUnit = related,
|
||||
SubjectId = occ.SubjectId,
|
||||
RelatedId = occ.RelatedId,
|
||||
Label = !string.IsNullOrEmpty(occ.PlainClause)
|
||||
? (occ.SubjectName + " " + occ.PlainClause)
|
||||
: (occ.SubjectName + " · " + occ.EffectId),
|
||||
Label = label,
|
||||
AssetId = occ.SubjectSpecies,
|
||||
SpeciesId = occ.SubjectSpecies,
|
||||
HappinessEffectId = occ.EffectId,
|
||||
|
|
@ -539,9 +649,9 @@ public static class InterestFeeds
|
|||
CorrelationKey = occ.CorrelationKey,
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 35f,
|
||||
MinWatch = 4f,
|
||||
MaxWatch = 28f,
|
||||
ExpiresAt = Time.unscaledTime + ttl,
|
||||
MinWatch = minWatch,
|
||||
MaxWatch = maxWatch,
|
||||
Completion = grief
|
||||
? InterestCompletionKind.HappinessGrief
|
||||
: InterestCompletionKind.FixedDwell
|
||||
|
|
@ -553,7 +663,7 @@ public static class InterestFeeds
|
|||
}
|
||||
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
InterestRegistry.Upsert(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
|
||||
/// <summary>Harness / push path: ingest one occurrence into the registry now.</summary>
|
||||
|
|
@ -635,7 +745,7 @@ public static class InterestFeeds
|
|||
}
|
||||
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
return InterestRegistry.Upsert(candidate);
|
||||
return EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
|
||||
private static void TickScanner()
|
||||
|
|
@ -659,11 +769,17 @@ public static class InterestFeeds
|
|||
RegisterDirect(battle);
|
||||
}
|
||||
|
||||
// Ongoing wars from WarManager (complements WorldLog war start/end).
|
||||
WarInterestFeed.Tick();
|
||||
|
||||
// Active plots from PlotManager / World.plots.
|
||||
PlotInterestFeed.Tick();
|
||||
|
||||
// Character-led vignette seeds (not sole one-best monopoly: register best + a few notables).
|
||||
WorldActivityScanner.RegisterTopCharacterCandidates(max: 4);
|
||||
}
|
||||
|
||||
private static void ExtendMatching(long subjectId, string keyPrefix, string statusId)
|
||||
private static bool ExtendMatching(long subjectId, string keyPrefix, string statusId)
|
||||
{
|
||||
var pending = new List<InterestCandidate>(32);
|
||||
InterestRegistry.CopyPending(pending);
|
||||
|
|
@ -680,10 +796,12 @@ public static class InterestFeeds
|
|||
c.StatusId = statusId;
|
||||
c.LastSeenAt = Time.unscaledTime;
|
||||
c.ExpiresAt = Time.unscaledTime + 20f;
|
||||
InterestRegistry.Upsert(c);
|
||||
return;
|
||||
EventFeedUtil.RegisterCandidate(c);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void StampCivicBoost(string kingdom, string effectId, float amount)
|
||||
|
|
@ -740,6 +858,19 @@ public static class InterestFeeds
|
|||
}
|
||||
}
|
||||
|
||||
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 string MapHappinessCategory(string cat)
|
||||
{
|
||||
switch ((cat ?? "").ToLowerInvariant())
|
||||
|
|
@ -861,4 +992,21 @@ public static class InterestFeeds
|
|||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
97
IdleSpectator/Events/Feeds/MetaInterestFeed.cs
Normal file
97
IdleSpectator/Events/Feeds/MetaInterestFeed.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Registers era and meta-genesis interest candidates.</summary>
|
||||
public static class MetaInterestFeed
|
||||
{
|
||||
public static void EmitEra(string eraId)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DiscreteEventEntry entry = EventCatalog.Era.GetOrFallback(eraId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Eras are world-scoped: location-only. Prefer camera center, else a known map tile.
|
||||
Vector3 pos = Vector3.zero;
|
||||
try
|
||||
{
|
||||
if (Camera.main != null)
|
||||
{
|
||||
Vector3 cam = Camera.main.transform.position;
|
||||
pos = new Vector3(cam.x, cam.y, 0f);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
pos = Vector3.zero;
|
||||
}
|
||||
|
||||
if (pos == Vector3.zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (MapBox.instance != null)
|
||||
{
|
||||
// Non-zero placeholder so location-only Register accepts the candidate.
|
||||
pos = new Vector3(1f, 1f, 0f);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
pos = new Vector3(1f, 1f, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
if (pos == Vector3.zero)
|
||||
{
|
||||
pos = new Vector3(1f, 1f, 0f);
|
||||
}
|
||||
|
||||
string key = "era:" + entry.Id;
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"era",
|
||||
entry.Category,
|
||||
entry.MakeLabel("", ""),
|
||||
entry.Id,
|
||||
entry.EventStrength,
|
||||
pos,
|
||||
follow: null,
|
||||
locationOnly: true,
|
||||
minWatch: 6f,
|
||||
maxWatch: 28f);
|
||||
}
|
||||
|
||||
public static void EmitMeta(string eventId, string category, float strength, Actor anchor, string label)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor follow = anchor != null && anchor.isAlive() ? anchor : null;
|
||||
if (follow == null)
|
||||
{
|
||||
// Meta genesis without a founder: skip unit dossier rather than invent a subject.
|
||||
return;
|
||||
}
|
||||
|
||||
string key = "meta:" + eventId + ":" + EventFeedUtil.SafeId(follow);
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"meta",
|
||||
category,
|
||||
label,
|
||||
eventId,
|
||||
strength,
|
||||
follow.current_position,
|
||||
follow);
|
||||
}
|
||||
}
|
||||
176
IdleSpectator/Events/Feeds/PlotInterestFeed.cs
Normal file
176
IdleSpectator/Events/Feeds/PlotInterestFeed.cs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Registers plot interest from Harmony hooks and World.plots polling.</summary>
|
||||
public static class PlotInterestFeed
|
||||
{
|
||||
public static void Emit(string plotAssetId, Actor subject, string phase)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DiscreteEventEntry entry = EventCatalog.Plot.GetOrFallback(plotAssetId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor follow = subject;
|
||||
if (follow == null || !follow.isAlive())
|
||||
{
|
||||
// Plot without a living author: skip rather than invent a stranger.
|
||||
return;
|
||||
}
|
||||
|
||||
string label = EventReason.Apply(entry.LabelTemplate, follow, id: entry.Id);
|
||||
string key = "plot:" + entry.Id + ":" + (phase ?? "active") + ":" + EventFeedUtil.SafeId(follow);
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"plot",
|
||||
entry.Category,
|
||||
label,
|
||||
entry.Id,
|
||||
entry.EventStrength,
|
||||
follow.current_position,
|
||||
follow);
|
||||
}
|
||||
|
||||
public static void EmitFromPlot(object plot, string phase)
|
||||
{
|
||||
if (plot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string assetId = ReadPlotAssetId(plot);
|
||||
Actor author = ReadPlotAuthor(plot);
|
||||
Emit(assetId, author, phase);
|
||||
}
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable plots = ResolvePlotsEnumerable();
|
||||
if (plots == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int registered = 0;
|
||||
foreach (object plot in plots)
|
||||
{
|
||||
if (plot == null || registered >= 8)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
EmitFromPlot(plot, "active");
|
||||
registered++;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable ResolvePlotsEnumerable()
|
||||
{
|
||||
try
|
||||
{
|
||||
object world = World.world;
|
||||
if (world == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
object plots = world.GetType().GetField("plots")?.GetValue(world)
|
||||
?? world.GetType().GetProperty("plots")?.GetValue(world, null);
|
||||
if (plots is IEnumerable direct)
|
||||
{
|
||||
return direct;
|
||||
}
|
||||
|
||||
object manager = world.GetType().GetField("plots_manager")?.GetValue(world)
|
||||
?? world.GetType().GetProperty("plots_manager")?.GetValue(world, null)
|
||||
?? typeof(PlotManager);
|
||||
if (manager is System.Type)
|
||||
{
|
||||
manager = null;
|
||||
}
|
||||
|
||||
object mapBox = MapBox.instance;
|
||||
if (manager == null && mapBox != null)
|
||||
{
|
||||
manager = mapBox.GetType().GetField("plots")?.GetValue(mapBox)
|
||||
?? mapBox.GetType().GetProperty("plots")?.GetValue(mapBox, null);
|
||||
}
|
||||
|
||||
if (manager == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MethodInfo getAll = manager.GetType().GetMethod("getSimpleList")
|
||||
?? manager.GetType().GetMethod("getList")
|
||||
?? manager.GetType().GetMethod("getPlots");
|
||||
if (getAll != null)
|
||||
{
|
||||
return getAll.Invoke(manager, null) as IEnumerable;
|
||||
}
|
||||
|
||||
return manager as IEnumerable;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadPlotAssetId(object plot)
|
||||
{
|
||||
try
|
||||
{
|
||||
object asset = plot.GetType().GetField("asset")?.GetValue(plot)
|
||||
?? plot.GetType().GetProperty("asset")?.GetValue(plot, null)
|
||||
?? plot.GetType().GetMethod("getAsset")?.Invoke(plot, null);
|
||||
if (asset != null)
|
||||
{
|
||||
object id = asset.GetType().GetField("id")?.GetValue(asset)
|
||||
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
|
||||
if (id != null)
|
||||
{
|
||||
return id.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
object typeId = plot.GetType().GetField("type")?.GetValue(plot)
|
||||
?? plot.GetType().GetProperty("type")?.GetValue(plot, null);
|
||||
return typeId?.ToString() ?? "unknown";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static Actor ReadPlotAuthor(object plot)
|
||||
{
|
||||
try
|
||||
{
|
||||
object author = plot.GetType().GetField("author")?.GetValue(plot)
|
||||
?? plot.GetType().GetProperty("author")?.GetValue(plot, null)
|
||||
?? plot.GetType().GetMethod("getAuthor")?.Invoke(plot, null)
|
||||
?? plot.GetType().GetField("founder")?.GetValue(plot);
|
||||
return author as Actor;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
188
IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs
Normal file
188
IdleSpectator/Events/Feeds/RelationshipInterestFeed.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Registers relationship lifecycle interest candidates under the ownership contract.</summary>
|
||||
public static class RelationshipInterestFeed
|
||||
{
|
||||
public static void Emit(string eventId, Actor subject, Actor related, string labelOverride = null)
|
||||
{
|
||||
if (AgentHarness.Busy || subject == null || !subject.isAlive())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback(eventId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor relatedAlive = related != null && related.isAlive() ? related : null;
|
||||
|
||||
bool claimsPeer = eventId == "family_group_new"
|
||||
|| eventId == "family_group_join"
|
||||
|| eventId == "family_group_leave"
|
||||
|| eventId == "set_lover"
|
||||
|| eventId == "add_child";
|
||||
if (claimsPeer && relatedAlive == null)
|
||||
{
|
||||
relatedAlive = ActorRelation.ResolvePackRelated(subject);
|
||||
}
|
||||
|
||||
string label = !string.IsNullOrEmpty(labelOverride)
|
||||
? labelOverride
|
||||
: TypedLabel(eventId, subject, relatedAlive);
|
||||
|
||||
if (claimsPeer && relatedAlive == null)
|
||||
{
|
||||
string soloKey = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":solo";
|
||||
EventFeedUtil.Register(
|
||||
soloKey,
|
||||
"relationship",
|
||||
entry.Category,
|
||||
label,
|
||||
entry.Id,
|
||||
Mathf.Min(entry.EventStrength, 40f),
|
||||
subject.current_position,
|
||||
subject);
|
||||
return;
|
||||
}
|
||||
|
||||
string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(subject) + ":" + EventFeedUtil.SafeId(relatedAlive);
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"relationship",
|
||||
entry.Category,
|
||||
label,
|
||||
entry.Id,
|
||||
entry.EventStrength,
|
||||
subject.current_position,
|
||||
subject,
|
||||
related: relatedAlive);
|
||||
}
|
||||
|
||||
public static void EmitFamily(string eventId, object family, Actor anchor)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DiscreteEventEntry entry = EventCatalog.Relationship.GetOrFallback(eventId);
|
||||
Actor follow = anchor != null && anchor.isAlive() ? anchor : null;
|
||||
if (follow == null)
|
||||
{
|
||||
follow = FirstFamilyMember(family);
|
||||
}
|
||||
|
||||
if (follow == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor related = ActorRelation.FirstFamilyPeer(follow);
|
||||
string label = TypedLabel(eventId, follow, related);
|
||||
string key = "rel:" + entry.Id + ":" + EventFeedUtil.SafeId(follow);
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"relationship",
|
||||
entry.Category,
|
||||
label,
|
||||
entry.Id,
|
||||
entry.EventStrength,
|
||||
follow.current_position,
|
||||
follow,
|
||||
related: related);
|
||||
}
|
||||
|
||||
private static string TypedLabel(string eventId, Actor subject, Actor related)
|
||||
{
|
||||
switch (eventId)
|
||||
{
|
||||
case "set_lover":
|
||||
return EventReason.Lovers(subject, related);
|
||||
case "clear_lover":
|
||||
return EventReason.LoverParted(subject);
|
||||
case "find_lover":
|
||||
return EventReason.SeekingLover(subject);
|
||||
case "add_child":
|
||||
return EventReason.NewChild(subject, related);
|
||||
case "baby_created":
|
||||
return EventReason.BabyBorn(subject);
|
||||
case "new_family":
|
||||
return EventReason.NewFamily(subject);
|
||||
case "family_removed":
|
||||
return EventReason.FamilyEnded(subject);
|
||||
case "family_group_new":
|
||||
return EventReason.FamilyPack(subject, related, "form");
|
||||
case "family_group_join":
|
||||
return EventReason.FamilyPack(subject, related, "join");
|
||||
case "family_group_leave":
|
||||
return EventReason.FamilyPack(subject, related, "leave");
|
||||
default:
|
||||
return EventReason.Apply(
|
||||
EventCatalog.Relationship.GetOrFallback(eventId).LabelTemplate,
|
||||
subject,
|
||||
related,
|
||||
eventId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Actor FirstFamilyMember(object family)
|
||||
{
|
||||
if (family == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Actor member in ActorRelation.EnumerateFamilyMembers(
|
||||
ActorRelation.ResolveActor(
|
||||
family.GetType().GetField("founder")?.GetValue(family)
|
||||
?? family.GetType().GetProperty("founder")?.GetValue(family, null))
|
||||
?? null,
|
||||
max: 1))
|
||||
{
|
||||
return member;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
object list = family.GetType().GetField("units")?.GetValue(family)
|
||||
?? family.GetType().GetProperty("units")?.GetValue(family, null)
|
||||
?? family.GetType().GetMethod("getUnits")?.Invoke(family, null)
|
||||
?? family.GetType().GetMethod("getSimpleList")?.Invoke(family, null);
|
||||
if (list is System.Collections.IEnumerable enumerable)
|
||||
{
|
||||
foreach (object raw in enumerable)
|
||||
{
|
||||
Actor a = ActorRelation.ResolveActor(raw);
|
||||
if (a != null && a.isAlive())
|
||||
{
|
||||
return a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Actor founder = family.GetType().GetField("founder")?.GetValue(family) as Actor
|
||||
?? family.GetType().GetProperty("founder")?.GetValue(family, null) as Actor;
|
||||
if (founder != null && founder.isAlive())
|
||||
{
|
||||
return founder;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
356
IdleSpectator/Events/Feeds/WarInterestFeed.cs
Normal file
356
IdleSpectator/Events/Feeds/WarInterestFeed.cs
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// Polls live WarManager state and registers ongoing war interest candidates.
|
||||
/// </summary>
|
||||
public static class WarInterestFeed
|
||||
{
|
||||
public static void EmitFromWarObject(object war, string phase = "active")
|
||||
{
|
||||
if (war == null || AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TryRegisterWar(war, phase);
|
||||
}
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
object warsManager = ResolveWarsManager();
|
||||
if (warsManager == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable active = InvokeEnumerable(warsManager, "getActiveWars")
|
||||
?? InvokeEnumerable(warsManager, "getWars");
|
||||
if (active == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int registered = 0;
|
||||
foreach (object war in active)
|
||||
{
|
||||
if (war == null || registered >= 6)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (TryRegisterWar(war, "active"))
|
||||
{
|
||||
registered++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryRegisterWar(object war, string phase)
|
||||
{
|
||||
string warTypeId = ReadWarTypeId(war);
|
||||
WarTypeInterestEntry entry = EventCatalog.WarType.GetOrFallback(warTypeId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
object attacker = ReadPropOrField(war, "main_attacker") ?? Invoke(war, "get_main_attacker");
|
||||
object defender = ReadPropOrField(war, "main_defender") ?? Invoke(war, "get_main_defender");
|
||||
string attackerName = ReadMetaName(attacker);
|
||||
string defenderName = ReadMetaName(defender);
|
||||
string labelPair = string.IsNullOrEmpty(defenderName)
|
||||
? attackerName
|
||||
: attackerName + " vs " + defenderName;
|
||||
if (string.IsNullOrEmpty(labelPair))
|
||||
{
|
||||
labelPair = ReadString(war, "name") ?? warTypeId ?? "war";
|
||||
}
|
||||
|
||||
Vector3 position = Vector3.zero;
|
||||
Actor follow = null;
|
||||
if (!TryLocateWar(attacker, defender, out position, out follow))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string phaseKey = string.IsNullOrEmpty(phase) ? "active" : phase;
|
||||
string key = "war:" + phaseKey + ":" + (ReadString(war, "id") ?? labelPair) + ":" + (warTypeId ?? "normal");
|
||||
string label = EventCatalog.WarType.MakeLabel(entry, labelPair);
|
||||
if (phaseKey == "new")
|
||||
{
|
||||
label = "War begins: " + labelPair;
|
||||
}
|
||||
else if (phaseKey == "end")
|
||||
{
|
||||
label = "War ends: " + labelPair;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(labelPair) && !label.Contains(labelPair))
|
||||
{
|
||||
label = label + ": " + labelPair;
|
||||
}
|
||||
|
||||
// Prefer sentence form when we have an owned follow unit.
|
||||
if (follow != null)
|
||||
{
|
||||
if (phaseKey == "new")
|
||||
{
|
||||
label = EventFeedUtil.SafeName(follow) + " begins a war";
|
||||
if (!string.IsNullOrEmpty(defenderName))
|
||||
{
|
||||
label = EventFeedUtil.SafeName(follow) + " begins a war against " + defenderName;
|
||||
}
|
||||
}
|
||||
else if (phaseKey == "end")
|
||||
{
|
||||
label = EventFeedUtil.SafeName(follow) + " ends a war";
|
||||
}
|
||||
else
|
||||
{
|
||||
label = string.IsNullOrEmpty(defenderName)
|
||||
? EventFeedUtil.SafeName(follow) + " is at war"
|
||||
: EventFeedUtil.SafeName(follow) + " is at war with " + defenderName;
|
||||
}
|
||||
}
|
||||
|
||||
float strength = entry.EventStrength;
|
||||
if (phaseKey == "end")
|
||||
{
|
||||
strength = Mathf.Max(60f, strength - 10f);
|
||||
}
|
||||
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"war",
|
||||
entry.Category,
|
||||
label,
|
||||
warTypeId ?? "war",
|
||||
strength,
|
||||
position,
|
||||
follow,
|
||||
minWatch: 6f,
|
||||
maxWatch: 30f);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryLocateWar(object attacker, object defender, out Vector3 position, out Actor follow)
|
||||
{
|
||||
position = Vector3.zero;
|
||||
follow = null;
|
||||
if (TryKingdomAnchor(attacker, out position, out follow))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryKingdomAnchor(defender, out position, out follow))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// No kingdom unit found - location-only if we have a capital position is handled above.
|
||||
// Never invent a global random unit as war subject.
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryKingdomAnchor(object kingdom, out Vector3 position, out Actor follow)
|
||||
{
|
||||
position = Vector3.zero;
|
||||
follow = null;
|
||||
if (kingdom == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
object capital = ReadPropOrField(kingdom, "capital") ?? Invoke(kingdom, "getCapital");
|
||||
if (capital != null)
|
||||
{
|
||||
object posObj = ReadPropOrField(capital, "current_position")
|
||||
?? ReadPropOrField(capital, "city_center");
|
||||
if (posObj is Vector3 v && v != Vector3.zero)
|
||||
{
|
||||
position = v;
|
||||
}
|
||||
else if (posObj is Vector2 v2)
|
||||
{
|
||||
position = new Vector3(v2.x, v2.y, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
object king = ReadPropOrField(kingdom, "king");
|
||||
if (king is Actor actorKing && actorKing.isAlive())
|
||||
{
|
||||
follow = actorKing;
|
||||
position = actorKing.current_position;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (position != Vector3.zero)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static object ResolveWarsManager()
|
||||
{
|
||||
try
|
||||
{
|
||||
object world = World.world;
|
||||
if (world == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
object wars = ReadPropOrField(world, "wars");
|
||||
if (wars != null)
|
||||
{
|
||||
return wars;
|
||||
}
|
||||
|
||||
return typeof(AssetManager).Assembly.GetType("WarManager");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadWarTypeId(object war)
|
||||
{
|
||||
try
|
||||
{
|
||||
object warType = ReadPropOrField(war, "war_type") ?? Invoke(war, "get_war_type");
|
||||
if (warType == null)
|
||||
{
|
||||
return "normal";
|
||||
}
|
||||
|
||||
if (warType is string s)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
string id = ReadString(warType, "id");
|
||||
return string.IsNullOrEmpty(id) ? "normal" : id;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "normal";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadMetaName(object meta)
|
||||
{
|
||||
if (meta == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
object name = Invoke(meta, "getName") ?? ReadPropOrField(meta, "name") ?? ReadString(meta, "name");
|
||||
return name as string ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable InvokeEnumerable(object target, string methodName)
|
||||
{
|
||||
try
|
||||
{
|
||||
MethodInfo method = target.GetType().GetMethod(methodName, Type.EmptyTypes);
|
||||
object result = method?.Invoke(target, null);
|
||||
return result as IEnumerable;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static object Invoke(object target, string methodName)
|
||||
{
|
||||
if (target == null || string.IsNullOrEmpty(methodName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
MethodInfo method = target.GetType().GetMethod(methodName, Type.EmptyTypes);
|
||||
return method?.Invoke(target, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static object ReadPropOrField(object target, string name)
|
||||
{
|
||||
if (target == null || string.IsNullOrEmpty(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Type type = target.GetType();
|
||||
PropertyInfo prop = type.GetProperty(name);
|
||||
if (prop != null)
|
||||
{
|
||||
return prop.GetValue(target, null);
|
||||
}
|
||||
|
||||
FieldInfo field = type.GetField(name);
|
||||
return field?.GetValue(target);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadString(object target, string name)
|
||||
{
|
||||
return ReadPropOrField(target, name) as string;
|
||||
}
|
||||
|
||||
private static long SafeActorId(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return actor.getID();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
IdleSpectator/Events/Patches/BoatEventPatches.cs
Normal file
64
IdleSpectator/Events/Patches/BoatEventPatches.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using ai.behaviours;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Boat transport/trade interest patches from mutation discovery.</summary>
|
||||
[HarmonyPatch]
|
||||
public static class BoatEventPatches
|
||||
{
|
||||
[HarmonyPatch(typeof(BehBoatTransportUnloadUnits), nameof(BehBoatTransportUnloadUnits.execute))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixUnload(Actor pActor, BehResult __result)
|
||||
{
|
||||
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EmitBoat("boat_unload", 72f, pActor, EventReason.Boat(pActor, "unload"));
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BehBoatMakeTrade), nameof(BehBoatMakeTrade.execute))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixTrade(Actor pActor, BehResult __result)
|
||||
{
|
||||
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EmitBoat("boat_trade", 68f, pActor, EventReason.Boat(pActor, "trade"));
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BehBoatTransportDoLoading), nameof(BehBoatTransportDoLoading.execute))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixLoad(Actor pActor, BehResult __result)
|
||||
{
|
||||
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EmitBoat("boat_load", 55f, pActor, EventReason.Boat(pActor, "load"));
|
||||
}
|
||||
|
||||
private static void EmitBoat(string eventId, float strength, Actor actor, string label)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string key = "boat:" + eventId + ":" + EventFeedUtil.SafeId(actor);
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"boat",
|
||||
"Travel",
|
||||
label,
|
||||
eventId,
|
||||
strength,
|
||||
actor.current_position,
|
||||
actor);
|
||||
}
|
||||
}
|
||||
50
IdleSpectator/Events/Patches/BookEventPatches.cs
Normal file
50
IdleSpectator/Events/Patches/BookEventPatches.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using HarmonyLib;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Book lifecycle patches from mutation discovery.</summary>
|
||||
[HarmonyPatch]
|
||||
public static class BookEventPatches
|
||||
{
|
||||
[HarmonyPatch(typeof(BookManager), nameof(BookManager.newBook))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewBook(Book __result)
|
||||
{
|
||||
if (__result == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BookInterestFeed.EmitFromBook(__result, "new");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BookManager), nameof(BookManager.generateNewBook))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixGenerateNewBook(Book __result)
|
||||
{
|
||||
if (__result == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BookInterestFeed.EmitFromBook(__result, "generate");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BookManager), nameof(BookManager.burnBook))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixBurnBook(object[] __args)
|
||||
{
|
||||
Book book = null;
|
||||
if (__args != null && __args.Length > 0)
|
||||
{
|
||||
book = __args[0] as Book;
|
||||
}
|
||||
|
||||
if (book == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BookInterestFeed.EmitFromBook(book, "burn");
|
||||
}
|
||||
}
|
||||
149
IdleSpectator/Events/Patches/BuildingEventPatches.cs
Normal file
149
IdleSpectator/Events/Patches/BuildingEventPatches.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
using ai.behaviours;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Building damage/destroy/eat interest patches from mutation discovery.</summary>
|
||||
[HarmonyPatch]
|
||||
public static class BuildingEventPatches
|
||||
{
|
||||
[HarmonyPatch(typeof(BehDealDamageToTargetBuilding), nameof(BehDealDamageToTargetBuilding.execute))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixDamageBuilding(Actor pActor, BehResult __result)
|
||||
{
|
||||
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Building target = null;
|
||||
try
|
||||
{
|
||||
target = pActor.beh_building_target;
|
||||
}
|
||||
catch
|
||||
{
|
||||
target = null;
|
||||
}
|
||||
|
||||
string label = EventReason.BuildingDamage(pActor, target);
|
||||
Emit("building_damage", 70f, pActor, label);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BehConsumeTargetBuilding), nameof(BehConsumeTargetBuilding.execute))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixConsumeBuilding(Actor pActor, BehResult __result)
|
||||
{
|
||||
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Building food = null;
|
||||
try
|
||||
{
|
||||
food = pActor.beh_building_target;
|
||||
}
|
||||
catch
|
||||
{
|
||||
food = null;
|
||||
}
|
||||
|
||||
string label = EventReason.BuildingEat(pActor, food);
|
||||
Emit("building_consume", 78f, pActor, label);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Building), "startDestroyBuilding")]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixStartDestroy(Building __instance)
|
||||
{
|
||||
if (__instance == null || AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Vector3 pos = Vector3.zero;
|
||||
object posObj = __instance.GetType().GetField("current_position")?.GetValue(__instance)
|
||||
?? __instance.GetType().GetProperty("current_position")?.GetValue(__instance, null);
|
||||
if (posObj is Vector3 v)
|
||||
{
|
||||
pos = v;
|
||||
}
|
||||
|
||||
string buildingName = "";
|
||||
try
|
||||
{
|
||||
if (__instance.asset != null)
|
||||
{
|
||||
buildingName = __instance.asset.id ?? "";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
buildingName = "";
|
||||
}
|
||||
|
||||
Actor near = pos != Vector3.zero
|
||||
? EventFeedUtil.NearestUnit(pos, 48f)
|
||||
: null;
|
||||
string label = EventReason.BuildingFalls(buildingName, near);
|
||||
if (near == null)
|
||||
{
|
||||
if (pos == Vector3.zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string locKey = "building:destroy:" + buildingName + ":loc";
|
||||
EventFeedUtil.Register(
|
||||
locKey,
|
||||
"building",
|
||||
"Spectacle",
|
||||
label,
|
||||
string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
|
||||
88f,
|
||||
pos,
|
||||
follow: null,
|
||||
locationOnly: true);
|
||||
return;
|
||||
}
|
||||
|
||||
string key = "building:destroy:" + buildingName + ":" + EventFeedUtil.SafeId(near);
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"building",
|
||||
"Spectacle",
|
||||
label,
|
||||
string.IsNullOrEmpty(buildingName) ? "building_destroy" : buildingName,
|
||||
88f,
|
||||
near.current_position,
|
||||
near);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore API mismatches
|
||||
}
|
||||
}
|
||||
|
||||
private static void Emit(string eventId, float strength, Actor actor, string label)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string key = "building:" + eventId + ":" + EventFeedUtil.SafeId(actor);
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"building",
|
||||
"Spectacle",
|
||||
label,
|
||||
eventId,
|
||||
strength,
|
||||
actor.current_position,
|
||||
actor);
|
||||
}
|
||||
}
|
||||
663
IdleSpectator/Events/Patches/DeferredEventPatches.cs
Normal file
663
IdleSpectator/Events/Patches/DeferredEventPatches.cs
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
using System;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Harmony hooks for Phase 2 deferred libraries (items, spells, decisions, subspecies).</summary>
|
||||
[HarmonyPatch]
|
||||
public static class DeferredEventPatches
|
||||
{
|
||||
private static bool _spellCastInFlight;
|
||||
private static string _spellCastId;
|
||||
|
||||
private static string _lastPowerId = "";
|
||||
private static float _lastPowerAt = -999f;
|
||||
|
||||
[HarmonyPatch(typeof(ItemManager), nameof(ItemManager.generateItem))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixGenerateItem(object __result, object[] __args)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor craftsman = null;
|
||||
if (__args != null)
|
||||
{
|
||||
for (int i = 0; i < __args.Length; i++)
|
||||
{
|
||||
if (__args[i] is Actor a && a.isAlive())
|
||||
{
|
||||
craftsman = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string itemId = ReadAssetId(__result) ?? "item";
|
||||
if (craftsman == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeferredInterestFeed.EmitItem(itemId, craftsman);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(ItemManager), "newItem")]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewItem(object __result)
|
||||
{
|
||||
// newItem may lack an actor; skip without a subject rather than invent one.
|
||||
if (AgentHarness.Busy || __result == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor owner = ReadActorMember(__result, "owner", "actor", "creator", "getOwner");
|
||||
if (owner == null || !owner.isAlive())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeferredInterestFeed.EmitItem(ReadAssetId(__result) ?? "item", owner);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(CombatActionLibrary), nameof(CombatActionLibrary.tryToCastSpell))]
|
||||
[HarmonyPrefix]
|
||||
public static void PrefixTryToCastSpell()
|
||||
{
|
||||
_spellCastInFlight = true;
|
||||
_spellCastId = null;
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.getRandomSpell))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixGetRandomSpell(object __result)
|
||||
{
|
||||
if (!_spellCastInFlight || __result == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string id = ReadAssetId(__result);
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
_spellCastId = id;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(CombatActionLibrary), nameof(CombatActionLibrary.tryToCastSpell))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixTryToCastSpell(AttackData pData, bool __result)
|
||||
{
|
||||
_spellCastInFlight = false;
|
||||
if (!__result || AgentHarness.Busy)
|
||||
{
|
||||
_spellCastId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
Actor actor = null;
|
||||
try
|
||||
{
|
||||
BaseSimObject initiator = pData.initiator;
|
||||
if (initiator != null)
|
||||
{
|
||||
actor = initiator.a;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
actor = null;
|
||||
}
|
||||
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
try
|
||||
{
|
||||
object initiator = typeof(AttackData).GetField("initiator")?.GetValue(pData);
|
||||
actor = ReadActorMember(initiator, "a", "actor")
|
||||
?? (initiator as Actor);
|
||||
}
|
||||
catch
|
||||
{
|
||||
actor = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
_spellCastId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
string spellId = _spellCastId;
|
||||
_spellCastId = null;
|
||||
if (string.IsNullOrEmpty(spellId))
|
||||
{
|
||||
spellId = ReadSpellFromAttackData(pData) ?? "spell";
|
||||
}
|
||||
|
||||
DeferredInterestFeed.EmitSpell(spellId, actor);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.clickedFinal))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixClickedFinal(Vector2Int pPos, GodPower pPower)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string powerId = null;
|
||||
if (pPower != null)
|
||||
{
|
||||
powerId = ReadAssetId(pPower);
|
||||
if (string.IsNullOrEmpty(powerId))
|
||||
{
|
||||
try
|
||||
{
|
||||
powerId = pPower.name;
|
||||
}
|
||||
catch
|
||||
{
|
||||
powerId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(powerId))
|
||||
{
|
||||
try
|
||||
{
|
||||
object selected = World.world?.selected_power;
|
||||
powerId = ReadAssetId(selected);
|
||||
if (string.IsNullOrEmpty(powerId))
|
||||
{
|
||||
powerId = World.world?.getSelectedPowerID();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
powerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(powerId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
if (string.Equals(powerId, _lastPowerId, StringComparison.OrdinalIgnoreCase)
|
||||
&& now - _lastPowerAt < 1.5f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastPowerId = powerId;
|
||||
_lastPowerAt = now;
|
||||
|
||||
Vector3 pos = TilePos(pPos);
|
||||
Actor near = pos != Vector3.zero ? EventFeedUtil.NearestUnit(pos, 48f) : null;
|
||||
DeferredInterestFeed.EmitPower(powerId, pos, near);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(WorldLaws), nameof(WorldLaws.enable))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixWorldLawEnable(string pID)
|
||||
{
|
||||
if (AgentHarness.Busy || string.IsNullOrEmpty(pID))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeferredInterestFeed.EmitWorldLaw(pID, CameraOrMapPos());
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(WorldLawAsset), nameof(WorldLawAsset.toggle))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixWorldLawToggle(WorldLawAsset __instance, bool pState)
|
||||
{
|
||||
if (!pState || AgentHarness.Busy || __instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string id = ReadAssetId(__instance) ?? __instance.id;
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeferredInterestFeed.EmitWorldLaw(id, CameraOrMapPos());
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Subspecies), nameof(Subspecies.mutateFrom))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixMutateFrom(Subspecies __instance)
|
||||
{
|
||||
if (AgentHarness.Busy || __instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor anchor = FindUnitForSubspecies(__instance);
|
||||
if (anchor == null || !anchor.isAlive())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the mutation event id so ambient gene catalog entries do not suppress the emit.
|
||||
string geneId = "mutation";
|
||||
string nucleusGene = ReadNucleusGeneId(__instance);
|
||||
if (!string.IsNullOrEmpty(nucleusGene)
|
||||
&& (nucleusGene.IndexOf("warfare", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| nucleusGene.IndexOf("magic", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| nucleusGene.IndexOf("mutation", StringComparison.OrdinalIgnoreCase) >= 0))
|
||||
{
|
||||
geneId = nucleusGene;
|
||||
}
|
||||
|
||||
DeferredInterestFeed.EmitGene(geneId, anchor);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.setDecisionCooldown))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixSetDecisionCooldown(Actor __instance, object[] __args)
|
||||
{
|
||||
if (__instance == null || !__instance.isAlive() || AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
object pDecision = __args != null && __args.Length > 0 ? __args[0] : null;
|
||||
if (pDecision == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string id = ReadAssetId(pDecision) ?? pDecision.ToString();
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// High-frequency hook: only kingdom-affecting decisions, never idle AI ticks.
|
||||
if (!EventCatalog.Decision.IsCameraWorthy(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeferredInterestFeed.EmitDecision(id, __instance);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.generatePhenotypeAndShade))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixPhenotype(Actor __instance)
|
||||
{
|
||||
if (__instance == null || !__instance.isAlive() || AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string id = "";
|
||||
try
|
||||
{
|
||||
object data = __instance.data;
|
||||
object ph = data?.GetType().GetField("phenotype")?.GetValue(data)
|
||||
?? data?.GetType().GetProperty("phenotype")?.GetValue(data, null);
|
||||
if (ph != null)
|
||||
{
|
||||
id = ph.ToString() ?? "";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
id = "";
|
||||
}
|
||||
|
||||
// Placeholder / empty ids are not camera events (catalog ambient is CreatesInterest=false).
|
||||
if (string.IsNullOrEmpty(id)
|
||||
|| string.Equals(id, "phenotype", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeferredInterestFeed.EmitPhenotype(id, __instance);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(SubspeciesManager), "addRandomTraitFromBiomeToSubspecies")]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixSubspeciesTraitRandom(object[] __args)
|
||||
{
|
||||
object pSubspecies = __args != null && __args.Length > 0 ? __args[0] : null;
|
||||
EmitSubspecies(pSubspecies, "biome_trait");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(SubspeciesManager), "addTraitsFromBiomeToSubspecies")]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixSubspeciesTraits(object[] __args)
|
||||
{
|
||||
object pSubspecies = __args != null && __args.Length > 0 ? __args[0] : null;
|
||||
EmitSubspecies(pSubspecies, "biome_traits");
|
||||
}
|
||||
|
||||
private static void EmitSubspecies(object subspecies, string fallbackId)
|
||||
{
|
||||
if (AgentHarness.Busy || subspecies == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor anchor = ReadActorMember(subspecies, "founder", "creator", "leader", "getFounder");
|
||||
if (anchor == null || !anchor.isAlive())
|
||||
{
|
||||
// Prefer a unit of this subspecies if possible.
|
||||
anchor = FindUnitForSubspecies(subspecies);
|
||||
}
|
||||
|
||||
if (anchor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string traitId = fallbackId;
|
||||
try
|
||||
{
|
||||
object last = subspecies.GetType().GetField("last_trait")?.GetValue(subspecies)
|
||||
?? subspecies.GetType().GetProperty("last_trait")?.GetValue(subspecies, null);
|
||||
if (last != null)
|
||||
{
|
||||
traitId = ReadAssetId(last) ?? last.ToString() ?? fallbackId;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
DeferredInterestFeed.EmitSubspeciesTrait(traitId, anchor);
|
||||
}
|
||||
|
||||
private static Actor FindUnitForSubspecies(object subspecies)
|
||||
{
|
||||
if (subspecies == null || World.world?.units == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string sid = null;
|
||||
try
|
||||
{
|
||||
object id = subspecies.GetType().GetMethod("getID")?.Invoke(subspecies, null)
|
||||
?? subspecies.GetType().GetProperty("id")?.GetValue(subspecies, null);
|
||||
sid = id?.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
sid = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (Actor unit in World.world.units)
|
||||
{
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
object sub = unit.GetType().GetProperty("subspecies")?.GetValue(unit, null)
|
||||
?? unit.GetType().GetField("subspecies")?.GetValue(unit);
|
||||
if (sub == subspecies)
|
||||
{
|
||||
return unit;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(sid))
|
||||
{
|
||||
object uid = sub?.GetType().GetMethod("getID")?.Invoke(sub, null);
|
||||
if (uid != null && uid.ToString() == sid)
|
||||
{
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReadSpellFromAttackData(AttackData pData)
|
||||
{
|
||||
try
|
||||
{
|
||||
Type type = typeof(AttackData);
|
||||
foreach (string name in new[] { "spell", "spell_asset", "pSpell", "current_spell", "last_spell" })
|
||||
{
|
||||
object value = type.GetField(name)?.GetValue(pData)
|
||||
?? type.GetProperty(name)?.GetValue(pData, null);
|
||||
string id = ReadAssetId(value);
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReadNucleusGeneId(Subspecies subspecies)
|
||||
{
|
||||
try
|
||||
{
|
||||
object nucleus = subspecies.nucleus;
|
||||
if (nucleus == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
object chromosomes = nucleus.GetType().GetField("chromosomes")?.GetValue(nucleus)
|
||||
?? nucleus.GetType().GetProperty("chromosomes")?.GetValue(nucleus, null);
|
||||
if (chromosomes is not System.Collections.IEnumerable list)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (object chrome in list)
|
||||
{
|
||||
if (chrome == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
object genes = chrome.GetType().GetField("genes")?.GetValue(chrome)
|
||||
?? chrome.GetType().GetProperty("genes")?.GetValue(chrome, null);
|
||||
if (genes is not System.Collections.IEnumerable geneList)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (object gene in geneList)
|
||||
{
|
||||
string id = ReadAssetId(gene);
|
||||
if (!string.IsNullOrEmpty(id)
|
||||
&& !id.Equals("empty", StringComparison.OrdinalIgnoreCase)
|
||||
&& !id.Contains("void"))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Vector3 TilePos(Vector2Int pPos)
|
||||
{
|
||||
try
|
||||
{
|
||||
WorldTile tile = World.world?.GetTile(pPos.x, pPos.y)
|
||||
?? World.world?.GetTileSimple(pPos.x, pPos.y);
|
||||
if (tile != null)
|
||||
{
|
||||
object posObj = tile.GetType().GetField("posV3")?.GetValue(tile)
|
||||
?? tile.GetType().GetProperty("posV3")?.GetValue(tile, null)
|
||||
?? tile.GetType().GetField("pos")?.GetValue(tile)
|
||||
?? tile.GetType().GetProperty("pos")?.GetValue(tile, null)
|
||||
?? tile.GetType().GetField("position")?.GetValue(tile)
|
||||
?? tile.GetType().GetProperty("position")?.GetValue(tile, null);
|
||||
if (posObj is Vector3 v)
|
||||
{
|
||||
return v;
|
||||
}
|
||||
|
||||
if (posObj is Vector2 v2)
|
||||
{
|
||||
return new Vector3(v2.x, v2.y, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
return new Vector3(pPos.x, pPos.y, 0f);
|
||||
}
|
||||
|
||||
private static Vector3 CameraOrMapPos()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (MoveCamera.instance != null)
|
||||
{
|
||||
Vector3 p = MoveCamera.instance.transform.position;
|
||||
if (p != Vector3.zero)
|
||||
{
|
||||
return new Vector3(p.x, p.y, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Camera.main != null)
|
||||
{
|
||||
Vector3 p = Camera.main.transform.position;
|
||||
return new Vector3(p.x, p.y, 0f);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return Vector3.zero;
|
||||
}
|
||||
|
||||
private static string ReadAssetId(object obj)
|
||||
{
|
||||
if (obj == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (obj is string s)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
object asset = obj.GetType().GetField("asset")?.GetValue(obj)
|
||||
?? obj.GetType().GetProperty("asset")?.GetValue(obj, null)
|
||||
?? obj.GetType().GetMethod("getAsset")?.Invoke(obj, null);
|
||||
if (asset != null)
|
||||
{
|
||||
object id = asset.GetType().GetField("id")?.GetValue(asset)
|
||||
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
|
||||
if (id != null)
|
||||
{
|
||||
return id.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
object direct = obj.GetType().GetField("id")?.GetValue(obj)
|
||||
?? obj.GetType().GetProperty("id")?.GetValue(obj, null);
|
||||
return direct?.ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Actor ReadActorMember(object target, params string[] names)
|
||||
{
|
||||
if (target == null || names == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Type type = target.GetType();
|
||||
foreach (string name in names)
|
||||
{
|
||||
try
|
||||
{
|
||||
var prop = type.GetProperty(name);
|
||||
if (prop != null && typeof(Actor).IsAssignableFrom(prop.PropertyType))
|
||||
{
|
||||
return prop.GetValue(target, null) as Actor;
|
||||
}
|
||||
|
||||
var field = type.GetField(name);
|
||||
if (field != null && typeof(Actor).IsAssignableFrom(field.FieldType))
|
||||
{
|
||||
return field.GetValue(target) as Actor;
|
||||
}
|
||||
|
||||
var method = type.GetMethod(name, Type.EmptyTypes);
|
||||
if (method != null && typeof(Actor).IsAssignableFrom(method.ReturnType))
|
||||
{
|
||||
return method.Invoke(target, null) as Actor;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// try next
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
143
IdleSpectator/Events/Patches/MetaEventPatches.cs
Normal file
143
IdleSpectator/Events/Patches/MetaEventPatches.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using HarmonyLib;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Era + meta genesis patches from mutation discovery.</summary>
|
||||
[HarmonyPatch]
|
||||
public static class MetaEventPatches
|
||||
{
|
||||
[HarmonyPatch(typeof(WorldAgeManager), nameof(WorldAgeManager.startNextAge))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixStartNextAge()
|
||||
{
|
||||
MetaInterestFeed.EmitEra(ReadCurrentEraId());
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(CultureManager), nameof(CultureManager.newCulture))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewCulture(Culture __result)
|
||||
{
|
||||
EmitNew("new_culture", "Culture", 68f, __result, "culture");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(ReligionManager), nameof(ReligionManager.newReligion))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewReligion(Religion __result)
|
||||
{
|
||||
EmitNew("new_religion", "Culture", 72f, __result, "religion");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(LanguageManager), nameof(LanguageManager.newLanguage))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewLanguage(Language __result)
|
||||
{
|
||||
EmitNew("new_language", "Culture", 60f, __result, "language");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(ClanManager), nameof(ClanManager.newClan))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewClan(Clan __result)
|
||||
{
|
||||
EmitNew("new_clan", "Politics", 70f, __result, "clan");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(ArmyManager), nameof(ArmyManager.newArmy))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewArmy(Army __result)
|
||||
{
|
||||
EmitNew("new_army", "Politics", 66f, __result, "army");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(AllianceManager), nameof(AllianceManager.newAlliance))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewAlliance(Alliance __result)
|
||||
{
|
||||
EmitNew("new_alliance", "Politics", 78f, __result, "alliance");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(CityManager), nameof(CityManager.newCity))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewCity(City __result)
|
||||
{
|
||||
EmitNew("new_city", "Politics", 74f, __result, "city");
|
||||
}
|
||||
|
||||
private static void EmitNew(
|
||||
string eventId,
|
||||
string category,
|
||||
float strength,
|
||||
object meta,
|
||||
string kind)
|
||||
{
|
||||
Actor anchor = TryReadActor(meta);
|
||||
if (anchor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string label = EventReason.MetaNew(anchor, kind);
|
||||
MetaInterestFeed.EmitMeta(eventId, category, strength, anchor, label);
|
||||
}
|
||||
|
||||
private static Actor TryReadActor(object meta)
|
||||
{
|
||||
if (meta == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (string name in new[] { "founder", "creator", "leader", "king", "actor", "unit" })
|
||||
{
|
||||
object value = meta.GetType().GetField(name)?.GetValue(meta)
|
||||
?? meta.GetType().GetProperty(name)?.GetValue(meta, null);
|
||||
if (value is Actor actor && actor.isAlive())
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReadCurrentEraId()
|
||||
{
|
||||
try
|
||||
{
|
||||
object world = World.world;
|
||||
object ageManager = world?.GetType().GetField("era_manager")?.GetValue(world)
|
||||
?? world?.GetType().GetProperty("era_manager")?.GetValue(world, null)
|
||||
?? world?.GetType().GetField("age_manager")?.GetValue(world)
|
||||
?? MapBox.instance?.GetType().GetField("era_manager")?.GetValue(MapBox.instance);
|
||||
if (ageManager == null)
|
||||
{
|
||||
return "age_unknown";
|
||||
}
|
||||
|
||||
object asset = ageManager.GetType().GetMethod("getCurrentAge")?.Invoke(ageManager, null)
|
||||
?? ageManager.GetType().GetProperty("current_age")?.GetValue(ageManager, null)
|
||||
?? ageManager.GetType().GetField("current_age")?.GetValue(ageManager);
|
||||
if (asset != null)
|
||||
{
|
||||
object id = asset.GetType().GetField("id")?.GetValue(asset)
|
||||
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
|
||||
if (id != null)
|
||||
{
|
||||
return id.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return "age_unknown";
|
||||
}
|
||||
}
|
||||
102
IdleSpectator/Events/Patches/PlotEventPatches.cs
Normal file
102
IdleSpectator/Events/Patches/PlotEventPatches.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using HarmonyLib;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Plot lifecycle patches from mutation discovery.</summary>
|
||||
[HarmonyPatch]
|
||||
public static class PlotEventPatches
|
||||
{
|
||||
[HarmonyPatch(typeof(PlotManager), nameof(PlotManager.newPlot))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewPlot(Plot __result)
|
||||
{
|
||||
if (__result == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PlotInterestFeed.EmitFromPlot(__result, "new");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(PlotManager), nameof(PlotManager.cancelPlot))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixCancelPlot(Plot pPlot)
|
||||
{
|
||||
if (pPlot == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PlotInterestFeed.EmitFromPlot(pPlot, "cancel");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.setPlot))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixSetPlot(Actor __instance, Plot pObject)
|
||||
{
|
||||
if (__instance == null || !__instance.isAlive() || pObject == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PlotInterestFeed.EmitFromPlot(pObject, "join");
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.leavePlot))]
|
||||
[HarmonyPrefix]
|
||||
public static void PrefixLeavePlot(Actor __instance, out string __state)
|
||||
{
|
||||
__state = "";
|
||||
try
|
||||
{
|
||||
object plot = __instance?.GetType().GetField("plot")?.GetValue(__instance)
|
||||
?? __instance?.GetType().GetProperty("plot")?.GetValue(__instance, null);
|
||||
if (plot != null)
|
||||
{
|
||||
__state = ReadPlotAssetId(plot);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
__state = "";
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.leavePlot))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixLeavePlot(Actor __instance, string __state)
|
||||
{
|
||||
if (__instance == null || !__instance.isAlive() || string.IsNullOrEmpty(__state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PlotInterestFeed.Emit(__state, __instance, "leave");
|
||||
}
|
||||
|
||||
private static string ReadPlotAssetId(object plot)
|
||||
{
|
||||
try
|
||||
{
|
||||
object asset = plot.GetType().GetField("asset")?.GetValue(plot)
|
||||
?? plot.GetType().GetProperty("asset")?.GetValue(plot, null)
|
||||
?? plot.GetType().GetMethod("getAsset")?.Invoke(plot, null)
|
||||
?? plot.GetType().GetField("plot_asset")?.GetValue(plot);
|
||||
if (asset != null)
|
||||
{
|
||||
object id = asset.GetType().GetField("id")?.GetValue(asset)
|
||||
?? asset.GetType().GetProperty("id")?.GetValue(asset, null);
|
||||
if (id != null)
|
||||
{
|
||||
return id.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
186
IdleSpectator/Events/Patches/RelationshipEventPatches.cs
Normal file
186
IdleSpectator/Events/Patches/RelationshipEventPatches.cs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
using System;
|
||||
using ai.behaviours;
|
||||
using HarmonyLib;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Relationship lifecycle patches from mutation discovery.</summary>
|
||||
[HarmonyPatch]
|
||||
public static class RelationshipEventPatches
|
||||
{
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.setLover))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixSetLover(Actor __instance, Actor pActor)
|
||||
{
|
||||
if (__instance == null || !__instance.isAlive())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (pActor == null)
|
||||
{
|
||||
RelationshipInterestFeed.Emit("clear_lover", __instance, null);
|
||||
return;
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.Emit("set_lover", __instance, pActor);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.addChild))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixAddChild(Actor __instance, object pObject)
|
||||
{
|
||||
Actor child = ResolveRelatedActor(pObject);
|
||||
if (__instance == null || child == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.Emit("add_child", __instance, child);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), "addChildSimple")]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixAddChildSimple(Actor __instance, object pObject)
|
||||
{
|
||||
Actor child = ResolveRelatedActor(pObject);
|
||||
if (__instance == null || child == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.Emit("add_child", __instance, child);
|
||||
}
|
||||
|
||||
private static Actor ResolveRelatedActor(object raw)
|
||||
{
|
||||
if (raw == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw is Actor actor)
|
||||
{
|
||||
return actor;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (raw is BaseSimObject sim && sim.isActor())
|
||||
{
|
||||
return sim.a;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var type = raw.GetType();
|
||||
foreach (string name in new[] { "actor", "Actor", "a", "parent" })
|
||||
{
|
||||
var prop = type.GetProperty(name);
|
||||
if (prop != null && typeof(Actor).IsAssignableFrom(prop.PropertyType))
|
||||
{
|
||||
return prop.GetValue(raw, null) as Actor;
|
||||
}
|
||||
|
||||
var field = type.GetField(name);
|
||||
if (field != null && typeof(Actor).IsAssignableFrom(field.FieldType))
|
||||
{
|
||||
return field.GetValue(raw) as Actor;
|
||||
}
|
||||
}
|
||||
|
||||
var getActor = type.GetMethod("getActor", Type.EmptyTypes)
|
||||
?? type.GetMethod("GetActor", Type.EmptyTypes);
|
||||
if (getActor != null && typeof(Actor).IsAssignableFrom(getActor.ReturnType))
|
||||
{
|
||||
return getActor.Invoke(raw, null) as Actor;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(FamilyManager), nameof(FamilyManager.newFamily))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewFamily(Family __result, Actor pActor)
|
||||
{
|
||||
Actor anchor = pActor != null && pActor.isAlive() ? pActor : null;
|
||||
RelationshipInterestFeed.EmitFamily("new_family", __result, anchor);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(FamilyManager), "removeObject", new Type[] { typeof(Family) })]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixRemoveFamily(Family pObject)
|
||||
{
|
||||
RelationshipInterestFeed.EmitFamily("family_removed", pObject, null);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(ActorManager), "createBabyActorFromData")]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixCreateBaby(Actor __result)
|
||||
{
|
||||
if (__result == null || !__result.isAlive())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.Emit("baby_created", __result, null);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BehFindLover), nameof(BehFindLover.execute))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixFindLover(Actor pActor, BehResult __result)
|
||||
{
|
||||
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.Emit("find_lover", pActor, null);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BehFamilyGroupNew), nameof(BehFamilyGroupNew.execute))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixFamilyNew(Actor pActor, BehResult __result)
|
||||
{
|
||||
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.Emit("family_group_new", pActor, ActorRelation.ResolvePackRelated(pActor));
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BehFamilyGroupJoin), nameof(BehFamilyGroupJoin.execute))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixFamilyJoin(Actor pActor, BehResult __result)
|
||||
{
|
||||
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.Emit("family_group_join", pActor, ActorRelation.ResolvePackRelated(pActor));
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(BehFamilyGroupLeave), nameof(BehFamilyGroupLeave.execute))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixFamilyLeave(Actor pActor, BehResult __result)
|
||||
{
|
||||
if (pActor == null || !pActor.isAlive() || __result == BehResult.Stop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RelationshipInterestFeed.Emit("family_group_leave", pActor, ActorRelation.ResolvePackRelated(pActor));
|
||||
}
|
||||
}
|
||||
204
IdleSpectator/Events/Patches/TraitEventPatches.cs
Normal file
204
IdleSpectator/Events/Patches/TraitEventPatches.cs
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>Actor trait add/remove interest patches from mutation discovery.</summary>
|
||||
[HarmonyPatch]
|
||||
public static class TraitEventPatches
|
||||
{
|
||||
/// <summary>
|
||||
/// Birth/spawn endowment traits that are still worth a camera beat.
|
||||
/// Routine species defaults (poisonous frog, bloodlust beetle, …) are not.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> LegendaryBirthTraits = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"immortal",
|
||||
"chosen_one",
|
||||
"miracle_born",
|
||||
"miracle_bearer",
|
||||
"scar_of_divinity",
|
||||
"giant",
|
||||
"whirlwind",
|
||||
"death_nuke",
|
||||
"death_bomb",
|
||||
"bomberman"
|
||||
};
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.addTrait), new Type[] { typeof(string), typeof(bool) })]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixAddTraitString(Actor __instance, string pTraitID, bool __result)
|
||||
{
|
||||
if (!__result || __instance == null || !__instance.isAlive() || string.IsNullOrEmpty(pTraitID))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Emit(pTraitID, __instance, gained: true);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.addTrait), new Type[] { typeof(ActorTrait), typeof(bool) })]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixAddTraitAsset(Actor __instance, ActorTrait pTrait, bool __result)
|
||||
{
|
||||
if (!__result || __instance == null || !__instance.isAlive() || pTrait == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string id = "";
|
||||
try
|
||||
{
|
||||
id = pTrait.id ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
id = "";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Emit(id, __instance, gained: true);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.removeTrait), new Type[] { typeof(string) })]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixRemoveTraitString(Actor __instance, string pTraitID)
|
||||
{
|
||||
if (__instance == null || !__instance.isAlive() || string.IsNullOrEmpty(pTraitID))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Emit(pTraitID, __instance, gained: false);
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(Actor), nameof(Actor.removeTrait), new Type[] { typeof(ActorTrait) })]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixRemoveTraitAsset(Actor __instance, ActorTrait pTrait, bool __result)
|
||||
{
|
||||
if (!__result || __instance == null || !__instance.isAlive() || pTrait == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string id = "";
|
||||
try
|
||||
{
|
||||
id = pTrait.id ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
id = "";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Emit(id, __instance, gained: false);
|
||||
}
|
||||
|
||||
private static void Emit(string traitId, Actor actor, bool gained)
|
||||
{
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DiscreteEventEntry entry = EventCatalog.Trait.GetOrFallback(traitId);
|
||||
if (!entry.CreatesInterest)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Quiet-world Ambient traits must not steal the camera.
|
||||
if (entry.EventStrength < InterestScoringConfig.W.noticeScoreMin)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn/birth endowment: only legendary rarities.
|
||||
if (gained && IsSpawnTraitWindow(actor) && !LegendaryBirthTraits.Contains(entry.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float strength = entry.EventStrength;
|
||||
if (!gained)
|
||||
{
|
||||
strength = Mathf.Min(strength, 45f);
|
||||
// Trait loss is quieter unless it was dramatic.
|
||||
if (entry.EventStrength < InterestScoringConfig.W.hotScoreMin)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
string label = EventReason.Trait(actor, entry.Id, gained);
|
||||
string phase = gained ? "gains" : "loses";
|
||||
string key = "trait:" + entry.Id + ":" + phase + ":" + EventFeedUtil.SafeId(actor);
|
||||
EventFeedUtil.Register(
|
||||
key,
|
||||
"trait",
|
||||
entry.Category,
|
||||
label,
|
||||
entry.Id,
|
||||
strength,
|
||||
actor.current_position,
|
||||
actor);
|
||||
}
|
||||
|
||||
/// <summary>True while traits are still being stamped onto a brand-new unit.</summary>
|
||||
private static bool IsSpawnTraitWindow(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (actor.isBaby())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
double created = actor.data != null ? actor.data.created_time : 0;
|
||||
if (created <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double now = 0;
|
||||
if (World.world != null)
|
||||
{
|
||||
now = World.world.getCurWorldTime();
|
||||
}
|
||||
else if (MapBox.instance != null)
|
||||
{
|
||||
now = MapBox.instance.getCurWorldTime();
|
||||
}
|
||||
|
||||
// Within ~2 world-years of creation = spawn endowment, not a mid-life gain.
|
||||
return now > 0 && (now - created) < 2.0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
IdleSpectator/Events/Patches/WarEventPatches.cs
Normal file
46
IdleSpectator/Events/Patches/WarEventPatches.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using HarmonyLib;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>War genesis/end patches from mutation discovery.</summary>
|
||||
[HarmonyPatch]
|
||||
public static class WarEventPatches
|
||||
{
|
||||
[HarmonyPatch(typeof(WarManager), nameof(WarManager.newWar))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixNewWar(War __result)
|
||||
{
|
||||
if (__result == null || AgentHarness.Busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WarInterestFeed.EmitFromWarObject(__result, phase: "new");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: poll path still covers ongoing wars.
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(WarManager), nameof(WarManager.endWar))]
|
||||
[HarmonyPostfix]
|
||||
public static void PostfixEndWar(object[] __args)
|
||||
{
|
||||
if (AgentHarness.Busy || __args == null || __args.Length == 0 || __args[0] == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WarInterestFeed.EmitFromWarObject(__args[0], phase: "end");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,17 +14,13 @@ public static class WorldLogMessageAddPatch
|
|||
return;
|
||||
}
|
||||
|
||||
if (!WorldLogInterestTable.TryGetEventStrength(pMessage.asset_id, out float evtSeed))
|
||||
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(pMessage.asset_id);
|
||||
// Chronicle notable world events only (authored ChronicleEligible / story-strength+).
|
||||
if (!entry.ChronicleEligible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Chronicle notable world events (story-strength and above); skip footnotes.
|
||||
if (evtSeed < 65f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Chronicle.NoteWorldLog(pMessage, WorldLogInterestTable.MakeLabel(pMessage));
|
||||
Chronicle.NoteWorldLog(pMessage, entry.MakeLabel(pMessage));
|
||||
}
|
||||
}
|
||||
|
|
@ -214,7 +214,7 @@ public static class HappinessEventRouter
|
|||
|
||||
FlushStaleAggregates();
|
||||
|
||||
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId);
|
||||
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(effectId);
|
||||
if (related == null
|
||||
&& HappinessContextBag.TryGetRelated(subject, out Actor bagRelated, out HappinessRelationRole bagRole))
|
||||
{
|
||||
|
|
@ -407,7 +407,7 @@ public static class HappinessEventRouter
|
|||
continue;
|
||||
}
|
||||
|
||||
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(bucket.EffectId);
|
||||
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(bucket.EffectId);
|
||||
HappinessOccurrence summary = BuildOccurrence(
|
||||
subject,
|
||||
bucket.EffectId,
|
||||
|
|
@ -450,7 +450,7 @@ public static class HappinessEventRouter
|
|||
bool applied,
|
||||
bool suppressedByPsychopath)
|
||||
{
|
||||
HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId);
|
||||
HappinessCatalogEntry entry = EventCatalog.Happiness.GetOrFallback(effectId);
|
||||
long subjectId = 0;
|
||||
string subjectName = "Someone";
|
||||
string species = "";
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ public sealed class HappinessCatalogEntry
|
|||
public HappinessOverlapPolicy Overlap = HappinessOverlapPolicy.Independent;
|
||||
public HappinessRelationRole RelationRole = HappinessRelationRole.None;
|
||||
public string InterestCategory = "";
|
||||
public float EventStrength = 40f;
|
||||
public string CanonicalMilestoneKey = "";
|
||||
public string StatusOverlapId = "";
|
||||
public string[] VariantsWithRelated = Array.Empty<string>();
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ internal static class HarnessScenarios
|
|||
case "director_gaps":
|
||||
case "director_coverage":
|
||||
return DirectorGaps();
|
||||
case "focus_interrupt":
|
||||
case "camera_interrupt":
|
||||
return FocusInterrupt();
|
||||
case "watch_reason":
|
||||
case "watch_reason_clarity":
|
||||
return WatchReasonClarity();
|
||||
|
|
@ -48,6 +51,19 @@ internal static class HarnessScenarios
|
|||
return Discovery();
|
||||
case "world_log":
|
||||
return WorldLog();
|
||||
case "world_log_audit":
|
||||
return WorldLogAudit();
|
||||
case "disaster_war_audit":
|
||||
case "disaster_war_inventory":
|
||||
return DisasterWarAudit();
|
||||
case "event_coverage":
|
||||
return EventCoverage();
|
||||
case "domain_event_audit":
|
||||
return DomainEventAudit();
|
||||
case "event_tracking":
|
||||
return EventTracking();
|
||||
case "mutation_discovery":
|
||||
return MutationDiscovery();
|
||||
case "chronicle":
|
||||
case "chronicle_smoke":
|
||||
return ChronicleSmoke();
|
||||
|
|
@ -146,6 +162,7 @@ internal static class HarnessScenarios
|
|||
Nested("reg_action_priority", "director_action_priority"),
|
||||
Nested("reg_discovery", "discovery"),
|
||||
Nested("reg_worldlog", "world_log"),
|
||||
Nested("reg_event_coverage", "event_coverage"),
|
||||
Nested("reg_chronicle", "chronicle_smoke"),
|
||||
Nested("reg_activity", "activity_idle_smoke"),
|
||||
Nested("reg_activity_status", "activity_status_smoke"),
|
||||
|
|
@ -615,31 +632,29 @@ internal static class HarnessScenarios
|
|||
Step("dt1", "wait_world"),
|
||||
Step("dt2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("dt3", "fast_timing", value: "true"),
|
||||
// Calm unit + spectator restart clears collector so world Story tips cannot steal CurioA.
|
||||
Step("dt4", "spawn", asset: "sheep"),
|
||||
// Calm durable unit + spectator restart clears collector so world tips cannot steal CurioA.
|
||||
Step("dt4", "spawn", asset: "dragon"),
|
||||
Step("dt5", "spectator", value: "off"),
|
||||
Step("dt6", "spectator", value: "on"),
|
||||
Step("dt7", "focus", asset: "sheep"),
|
||||
Step("dt7", "focus", asset: "dragon"),
|
||||
Step("dt8", "reset_counters"),
|
||||
|
||||
// Curiosity can replace ambient after rotate window.
|
||||
Step("dt10", "trigger_interest", asset: "sheep", label: "CurioA", tier: "Curiosity"),
|
||||
Step("dt11", "age_current", wait: 2f),
|
||||
Step("dt12", "director_run", wait: 1.2f),
|
||||
Step("dt13", "assert", expect: "tip_contains", value: "CurioA"),
|
||||
// Curiosity hold.
|
||||
Step("dt10", "interest_force_session", asset: "dragon", label: "CurioA", tier: "Curiosity", expect: "curio_a"),
|
||||
Step("dt11", "assert", expect: "tip_contains", value: "CurioA"),
|
||||
|
||||
// Action interrupts curiosity after settle.
|
||||
Step("dt20", "trigger_interest", asset: "auto", label: "ActionA", tier: "Action"),
|
||||
Step("dt21", "age_current", wait: 1.2f),
|
||||
Step("dt22", "director_run", wait: 1.2f),
|
||||
Step("dt23", "assert", expect: "tip_contains", value: "ActionA"),
|
||||
// Action margin-cuts curiosity (instant; no settle required).
|
||||
Step("dt20", "trigger_interest", asset: "dragon", label: "ActionA", tier: "Action"),
|
||||
Step("dt21", "director_run", wait: 0.8f),
|
||||
Step("dt22", "assert", expect: "tip_contains", value: "ActionA"),
|
||||
|
||||
// Curiosity must not steal the camera while Action holds (no director_run).
|
||||
Step("dt30", "trigger_interest", asset: "auto", label: "CurioBlocked", tier: "Curiosity"),
|
||||
Step("dt30", "trigger_interest", asset: "dragon", label: "CurioBlocked", tier: "Curiosity"),
|
||||
Step("dt31", "assert", expect: "tip_contains", value: "ActionA"),
|
||||
Step("dt32", "assert", expect: "would_accept_curiosity", value: "false"),
|
||||
Step("dt33", "assert", expect: "health"),
|
||||
Step("dt34", "assert", expect: "no_bad"),
|
||||
Step("dt33", "reset_counters"),
|
||||
Step("dt34", "assert", expect: "health"),
|
||||
Step("dt35", "assert", expect: "no_bad"),
|
||||
|
||||
Step("dt90", "fast_timing", value: "false"),
|
||||
Step("dt99", "snapshot"),
|
||||
|
|
@ -654,27 +669,26 @@ internal static class HarnessScenarios
|
|||
Step("dl1", "wait_world"),
|
||||
Step("dl2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("dl3", "fast_timing", value: "true"),
|
||||
Step("dl4", "spawn", asset: "sheep"),
|
||||
Step("dl4", "spawn", asset: "dragon"),
|
||||
Step("dl5", "spectator", value: "off"),
|
||||
Step("dl6", "spectator", value: "on"),
|
||||
Step("dl7", "focus", asset: "sheep"),
|
||||
Step("dl7", "focus", asset: "dragon"),
|
||||
|
||||
// Protected Action: Story waits.
|
||||
Step("dl10", "interest_force_session", asset: "sheep", label: "HoldAction", tier: "Action", expect: "hold_action"),
|
||||
// Protected Action: peer Story (+0 margin) waits.
|
||||
Step("dl10", "interest_force_session", asset: "dragon", label: "HoldAction", tier: "Action", expect: "hold_action"),
|
||||
Step("dl11", "assert", expect: "tip_contains", value: "HoldAction"),
|
||||
Step("dl12", "assert", expect: "session_active", value: "true"),
|
||||
Step("dl13", "trigger_interest", asset: "sheep", label: "StoryWait", tier: "Story"),
|
||||
Step("dl14", "age_current", wait: 1.2f),
|
||||
Step("dl15", "director_run", wait: 1.2f),
|
||||
Step("dl13", "trigger_interest", asset: "dragon", label: "StoryWait", tier: "Story"),
|
||||
Step("dl14", "director_run", wait: 0.8f),
|
||||
Step("dl15", "assert", expect: "tip_contains", value: "HoldAction"),
|
||||
Step("dl16", "assert", expect: "tip_contains", value: "HoldAction"),
|
||||
Step("dl17", "assert", expect: "tip_contains", value: "HoldAction"),
|
||||
|
||||
// Epic preempts Action after settle.
|
||||
Step("dl20", "trigger_interest", asset: "sheep", label: "EpicWin", tier: "Epic"),
|
||||
Step("dl21", "age_current", wait: 1.2f),
|
||||
Step("dl22", "director_run", wait: 1.2f),
|
||||
// Epic margin-cuts Action instantly (no settle).
|
||||
Step("dl20", "trigger_interest", asset: "dragon", label: "EpicWin", tier: "Epic"),
|
||||
Step("dl21", "director_run", wait: 0.8f),
|
||||
Step("dl22", "assert", expect: "tip_contains", value: "EpicWin"),
|
||||
Step("dl23", "assert", expect: "tip_contains", value: "EpicWin"),
|
||||
Step("dl24", "assert", expect: "tip_contains", value: "EpicWin"),
|
||||
Step("dl24", "reset_counters"),
|
||||
Step("dl25", "assert", expect: "no_bad"),
|
||||
|
||||
Step("dl90", "fast_timing", value: "false"),
|
||||
|
|
@ -712,10 +726,9 @@ internal static class HarnessScenarios
|
|||
|
||||
// Epic can leave Action; grief may then surface.
|
||||
Step("ih30", "trigger_interest", asset: "auto", label: "EpicClear", tier: "Epic"),
|
||||
Step("ih31", "age_current", wait: 1.2f),
|
||||
Step("ih32", "director_run", wait: 1.2f),
|
||||
Step("ih33", "assert", expect: "tip_contains", value: "EpicClear"),
|
||||
Step("ih34", "assert", expect: "no_bad"),
|
||||
Step("ih31", "director_run", wait: 0.8f),
|
||||
Step("ih32", "assert", expect: "tip_contains", value: "EpicClear"),
|
||||
Step("ih33", "assert", expect: "no_bad"),
|
||||
|
||||
Step("ih90", "fast_timing", value: "false"),
|
||||
Step("ih99", "snapshot"),
|
||||
|
|
@ -733,37 +746,35 @@ internal static class HarnessScenarios
|
|||
Step("dg1", "wait_world"),
|
||||
Step("dg2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("dg3", "fast_timing", value: "true"),
|
||||
Step("dg4", "spawn", asset: "sheep", count: 2),
|
||||
Step("dg4", "spawn", asset: "dragon", count: 1),
|
||||
Step("dg5", "spectator", value: "off"),
|
||||
Step("dg6", "spectator", value: "on"),
|
||||
Step("dg7", "focus", asset: "sheep"),
|
||||
Step("dg7", "focus", asset: "dragon"),
|
||||
Step("dg8", "interest_variety_clear"),
|
||||
|
||||
// --- Epic preempts Story; Story does not preempt itself past Action rules ---
|
||||
Step("dg10", "interest_force_session", asset: "sheep", label: "HoldStory", tier: "Story", expect: "hold_story"),
|
||||
// --- Epic margin-cuts Story; Action does not (below cutInMargin) ---
|
||||
// Hold as EventLed so CharacterLed char-weight cannot outscore Epic.
|
||||
Step("dg10", "interest_force_session", asset: "dragon", label: "HoldStory", tier: "Story", expect: "hold_story", value: "lead=event;evt=80"),
|
||||
Step("dg11", "assert", expect: "tip_contains", value: "HoldStory"),
|
||||
Step("dg12", "trigger_interest", asset: "sheep", label: "ActionBlocked", tier: "Action"),
|
||||
Step("dg13", "age_current", wait: 1.2f),
|
||||
Step("dg14", "director_run", wait: 1.2f),
|
||||
Step("dg12", "trigger_interest", asset: "dragon", label: "ActionBlocked", tier: "Action"),
|
||||
Step("dg13", "director_run", wait: 0.8f),
|
||||
Step("dg14", "assert", expect: "tip_contains", value: "HoldStory"),
|
||||
Step("dg15", "assert", expect: "tip_contains", value: "HoldStory"),
|
||||
Step("dg16", "assert", expect: "tip_contains", value: "HoldStory"),
|
||||
Step("dg17", "trigger_interest", asset: "sheep", label: "EpicOverStory", tier: "Epic"),
|
||||
Step("dg18", "age_current", wait: 1.2f),
|
||||
Step("dg19", "director_run", wait: 1.2f),
|
||||
Step("dg17", "trigger_interest", asset: "dragon", label: "EpicOverStory", tier: "Epic"),
|
||||
Step("dg18", "director_run", wait: 0.8f),
|
||||
Step("dg19", "assert", expect: "tip_contains", value: "EpicOverStory"),
|
||||
Step("dg20", "assert", expect: "tip_contains", value: "EpicOverStory"),
|
||||
Step("dg21", "assert", expect: "tip_contains", value: "EpicOverStory"),
|
||||
|
||||
// --- Resume after Epic: Action hold interrupted then restored ---
|
||||
Step("dg30", "spectator", value: "off"),
|
||||
Step("dg31", "spectator", value: "on"),
|
||||
Step("dg32", "focus", asset: "sheep"),
|
||||
Step("dg33", "interest_force_session", asset: "sheep", label: "HoldResume", tier: "Action", expect: "hold_resume"),
|
||||
Step("dg32", "focus", asset: "dragon"),
|
||||
Step("dg33", "interest_force_session", asset: "dragon", label: "HoldResume", tier: "Action", expect: "hold_resume", value: "lead=event;evt=70"),
|
||||
Step("dg34", "assert", expect: "session_key", value: "hold_resume"),
|
||||
Step("dg35", "trigger_interest", asset: "sheep", label: "EpicInterrupt", tier: "Epic"),
|
||||
Step("dg36", "age_current", wait: 1.2f),
|
||||
Step("dg37", "director_run", wait: 1.2f),
|
||||
Step("dg38", "assert", expect: "tip_contains", value: "EpicInterrupt"),
|
||||
Step("dg39", "assert", expect: "interrupted_key", value: "hold_resume"),
|
||||
Step("dg35", "trigger_interest", asset: "dragon", label: "EpicInterrupt", tier: "Epic"),
|
||||
Step("dg36", "director_run", wait: 0.8f),
|
||||
Step("dg37", "assert", expect: "tip_contains", value: "EpicInterrupt"),
|
||||
Step("dg38", "assert", expect: "interrupted_key", value: "hold_resume"),
|
||||
// Max-cap the Epic scene (fast timing caps ~8s) so EndCurrent resumes Action.
|
||||
Step("dg40", "age_current", wait: 9f),
|
||||
Step("dg41", "director_run", wait: 1.2f),
|
||||
|
|
@ -772,30 +783,30 @@ internal static class HarnessScenarios
|
|||
Step("dg44", "assert", expect: "interrupted_key", value: "none"),
|
||||
|
||||
// --- Max cap ends a forced scene ---
|
||||
Step("dg50", "interest_force_session", asset: "sheep", label: "CapMe", tier: "Action", expect: "cap_me"),
|
||||
Step("dg50", "interest_force_session", asset: "dragon", label: "CapMe", tier: "Action", expect: "cap_me"),
|
||||
Step("dg51", "age_current", wait: 9f),
|
||||
Step("dg52", "director_run", wait: 1.2f),
|
||||
Step("dg54", "assert", expect: "interest_no_key", value: "cap_me"),
|
||||
|
||||
// --- Quiet grace: inactive scene ends after grace ---
|
||||
Step("dg60", "interest_force_session", asset: "sheep", label: "QuietEnd", tier: "Action", expect: "quiet_end"),
|
||||
Step("dg60", "interest_force_session", asset: "dragon", label: "QuietEnd", tier: "Action", expect: "quiet_end"),
|
||||
Step("dg61", "interest_mark_inactive", value: "1.0"),
|
||||
Step("dg62", "assert", expect: "session_active", value: "false"),
|
||||
Step("dg63", "director_run", wait: 1.2f),
|
||||
Step("dg64", "assert", expect: "interest_no_key", value: "quiet_end"),
|
||||
|
||||
// --- Stale expiry / dedupe refresh ---
|
||||
Step("dg70", "interest_inject", asset: "sheep", label: "StaleTip", tier: "Curiosity", expect: "stale_tip", value: "lead=character;ttl=0.01"),
|
||||
Step("dg70", "interest_inject", asset: "dragon", label: "StaleTip", tier: "Curiosity", expect: "stale_tip", value: "lead=character;ttl=0.01"),
|
||||
Step("dg71", "assert", expect: "interest_has_key", value: "stale_tip"),
|
||||
Step("dg72", "interest_expire_pending", value: "stale_tip"),
|
||||
Step("dg73", "assert", expect: "interest_no_key", value: "stale_tip"),
|
||||
Step("dg74", "interest_inject", asset: "sheep", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=20"),
|
||||
Step("dg75", "interest_inject", asset: "sheep", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=90"),
|
||||
Step("dg74", "interest_inject", asset: "dragon", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=20"),
|
||||
Step("dg75", "interest_inject", asset: "dragon", label: "RefreshA", tier: "Action", expect: "refresh_a", value: "lead=event;evt=90"),
|
||||
Step("dg76", "assert", expect: "interest_registry_count", value: "1", label: "refresh_a"),
|
||||
|
||||
// --- Death handoff: FollowUnit cleared → RelatedUnit ---
|
||||
Step("dg80", "interest_force_session", asset: "sheep", label: "HandoffHold", tier: "Action", expect: "handoff_hold"),
|
||||
Step("dg81", "interest_clear_follow", asset: "sheep"),
|
||||
Step("dg80", "interest_force_session", asset: "dragon", label: "HandoffHold", tier: "Action", expect: "handoff_hold"),
|
||||
Step("dg81", "interest_clear_follow", asset: "dragon"),
|
||||
Step("dg82", "director_run", wait: 0.8f),
|
||||
Step("dg83", "assert", expect: "session_key", value: "handoff_hold"),
|
||||
Step("dg84", "assert", expect: "session_active", value: "true"),
|
||||
|
|
@ -806,8 +817,8 @@ internal static class HarnessScenarios
|
|||
Step("dg91b", "interest_expire_pending", value: ""),
|
||||
Step("dg92", "interest_variety_seed", value: "2:10"),
|
||||
Step("dg93", "assert", expect: "interest_event_share", value: "0.17", label: "0.2"),
|
||||
Step("dg94", "interest_inject", asset: "sheep", label: "EvtWin", tier: "Curiosity", expect: "evt_win", value: "lead=event;evt=80"),
|
||||
Step("dg95", "interest_inject", asset: "sheep", label: "CharLose", tier: "Curiosity", expect: "char_lose", value: "lead=character;char=95"),
|
||||
Step("dg94", "interest_inject", asset: "dragon", label: "EvtWin", tier: "Curiosity", expect: "evt_win", value: "lead=event;evt=80"),
|
||||
Step("dg95", "interest_inject", asset: "dragon", label: "CharLose", tier: "Curiosity", expect: "char_lose", value: "lead=character;char=95"),
|
||||
Step("dg96", "assert", expect: "interest_peek_lead", value: "EventLed"),
|
||||
// Empty event pool → character-led, no invented events
|
||||
Step("dg97", "interest_expire_pending", value: "evt_win"),
|
||||
|
|
@ -815,12 +826,14 @@ internal static class HarnessScenarios
|
|||
|
||||
// --- Event strength beats idle celebrity (within event pool / score order) ---
|
||||
Step("dg100", "interest_expire_pending", value: ""),
|
||||
Step("dg101", "interest_inject", asset: "sheep", label: "StrongEvt", tier: "Action", expect: "strong_evt", value: "lead=event;evt=95;char=5"),
|
||||
Step("dg102", "interest_inject", asset: "sheep", label: "CelebWeak", tier: "Action", expect: "celeb_weak", value: "lead=event;evt=10;char=99"),
|
||||
Step("dg101", "interest_inject", asset: "dragon", label: "StrongEvt", tier: "Action", expect: "strong_evt", value: "lead=event;evt=95;char=5"),
|
||||
Step("dg102", "interest_inject", asset: "dragon", label: "CelebWeak", tier: "Action", expect: "celeb_weak", value: "lead=event;evt=10;char=99"),
|
||||
Step("dg103", "assert", expect: "interest_score_order", value: "strong_evt", label: "celeb_weak"),
|
||||
|
||||
// --- Ambient happiness never owns camera; psychopath suppress; drain duplex; aggregate ---
|
||||
Step("dg110", "interest_force_session", asset: "sheep", label: "ActionKeep", tier: "Action", expect: "action_keep"),
|
||||
Step("dg109", "spawn", asset: "human"),
|
||||
Step("dg109b", "focus", asset: "human"),
|
||||
Step("dg110", "interest_force_session", asset: "human", label: "ActionKeep", tier: "Action", expect: "action_keep"),
|
||||
Step("dg111", "happiness_apply", value: "just_ate"),
|
||||
Step("dg112", "interest_feeds_tick"),
|
||||
Step("dg113", "age_current", wait: 1.2f),
|
||||
|
|
@ -853,6 +866,43 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> FocusInterrupt()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("fi0", "dismiss_windows"),
|
||||
Step("fi1", "wait_world"),
|
||||
Step("fi2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("fi3", "fast_timing", value: "true"),
|
||||
Step("fi4", "spawn", asset: "sheep"),
|
||||
Step("fi5", "spectator", value: "off"),
|
||||
Step("fi6", "spectator", value: "on"),
|
||||
Step("fi7", "focus", asset: "sheep"),
|
||||
|
||||
// Hold Action; peer Story does not cut (below cutInMargin).
|
||||
Step("fi10", "interest_force_session", asset: "sheep", label: "HoldA", tier: "Action", expect: "hold_a"),
|
||||
Step("fi11", "trigger_interest", asset: "sheep", label: "PeerStory", tier: "Story"),
|
||||
Step("fi12", "director_run", wait: 0.6f),
|
||||
Step("fi13", "assert", expect: "tip_contains", value: "HoldA"),
|
||||
|
||||
// Epic margin-cuts instantly (no age/settle).
|
||||
Step("fi20", "trigger_interest", asset: "sheep", label: "CutEpic", tier: "Epic"),
|
||||
Step("fi21", "director_run", wait: 0.6f),
|
||||
Step("fi22", "assert", expect: "tip_contains", value: "CutEpic"),
|
||||
|
||||
// Quiet grace then ambient empty reason path.
|
||||
Step("fi30", "interest_force_session", asset: "sheep", label: "GraceEnd", tier: "Action", expect: "grace_end"),
|
||||
Step("fi31", "interest_mark_inactive", value: "1.0"),
|
||||
Step("fi32", "assert", expect: "session_active", value: "false"),
|
||||
Step("fi33", "director_run", wait: 1.2f),
|
||||
Step("fi34", "assert", expect: "interest_no_key", value: "grace_end"),
|
||||
Step("fi35", "assert", expect: "no_bad"),
|
||||
|
||||
Step("fi90", "fast_timing", value: "false"),
|
||||
Step("fi99", "snapshot"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watch-reason row must explain why the camera is here (event), not who (title/name).
|
||||
/// </summary>
|
||||
|
|
@ -871,12 +921,12 @@ internal static class HarnessScenarios
|
|||
Step("wr8", "focus", asset: "human"),
|
||||
Step("wr9", "dossier_clear_job"),
|
||||
|
||||
// Fighting label → story beat "Fighting" (no tier badge, no identity crumb).
|
||||
Step("wr10", "interest_force_session", asset: "human", label: "Fighting: Isemward", tier: "Action", expect: "fight_reason"),
|
||||
Step("wr11", "assert", expect: "dossier_contains", value: "Fighting"),
|
||||
// EventReason sentence with subject name kept.
|
||||
Step("wr10", "interest_force_session", asset: "human", label: "{a} is fighting Waaaf", tier: "Action", expect: "fight_reason"),
|
||||
Step("wr11", "assert", expect: "dossier_contains", value: "is fighting"),
|
||||
Step("wr12", "assert", expect: "dossier_not_contains", value: "Action ·"),
|
||||
Step("wr12b", "assert", expect: "dossier_not_contains", value: "Live ·"),
|
||||
Step("wr13", "assert", expect: "tip_contains", value: "Fighting"),
|
||||
Step("wr13", "assert", expect: "tip_contains", value: "is fighting"),
|
||||
Step("wr14", "screenshot", value: "hud-watch-reason-fighting.png"),
|
||||
Step("wr15", "wait", wait: 0.55f),
|
||||
|
||||
|
|
@ -896,7 +946,7 @@ internal static class HarnessScenarios
|
|||
Step("wr33", "screenshot", value: "hud-watch-reason-war.png"),
|
||||
Step("wr34", "wait", wait: 0.55f),
|
||||
|
||||
// Weak singleton crumb → catch-all or Last of its kind (never "Lone beetle" / tier badges).
|
||||
// Weak singleton crumb → never "Lone beetle" / tier badges.
|
||||
Step("wr40", "interest_force_session", asset: "human", label: "Lone beetle", tier: "Curiosity", expect: "lone_crumb"),
|
||||
Step("wr41", "assert", expect: "dossier_not_contains", value: "Lone beetle"),
|
||||
Step("wr42", "assert", expect: "dossier_not_contains", value: "Live ·"),
|
||||
|
|
@ -913,9 +963,35 @@ internal static class HarnessScenarios
|
|||
Step("wr48", "assert", expect: "dossier_not_contains", value: "Live ·"),
|
||||
Step("wr49", "wait", wait: 0.35f),
|
||||
|
||||
// Mass fight inject → Clash / Fighting scale beat.
|
||||
// Ownership: reason must belong to focused subject (not a stranger name).
|
||||
Step("wr60", "spawn", asset: "human", count: 2),
|
||||
Step("wr61", "interest_force_session", asset: "human",
|
||||
label: "Cabo loses infected", tier: "Action", expect: "stranger_label"),
|
||||
Step("wr62", "assert", expect: "reason_owns_focus"),
|
||||
Step("wr63", "assert", expect: "dossier_not_contains", value: "Cabo"),
|
||||
Step("wr64", "interest_force_session", asset: "human",
|
||||
label: "{a} became lovers with a partner", tier: "Action", expect: "lovers_owned"),
|
||||
Step("wr65", "assert", expect: "reason_owns_focus"),
|
||||
Step("wr65b", "assert", expect: "dossier_contains", value: "became lovers"),
|
||||
Step("wr66", "wait", wait: 0.35f),
|
||||
|
||||
// Low-strength former-Ambient event still owns camera + shows sentence.
|
||||
Step("wr67", "interest_force_session", asset: "human",
|
||||
label: "{a} is seeking a lover", tier: "Action", expect: "seek_lover_low",
|
||||
value: "lead=event;evt=45"),
|
||||
Step("wr68", "assert", expect: "dossier_contains", value: "is seeking a lover"),
|
||||
Step("wr69", "assert", expect: "reason_owns_focus"),
|
||||
|
||||
// Hatch reason (live path is egg status-loss → EmitHatch; force here for reason text).
|
||||
Step("wr69a", "interest_force_session", asset: "human",
|
||||
label: "{a} hatches from an egg", tier: "Action", expect: "hatch_reason",
|
||||
value: "lead=event;evt=88"),
|
||||
Step("wr69b", "assert", expect: "dossier_contains", value: "hatches from an egg"),
|
||||
Step("wr69c", "assert", expect: "reason_owns_focus"),
|
||||
|
||||
// Label-sourced clash (not inventing Clash from fighter counts).
|
||||
Step("wr50", "interest_expire_pending", value: ""),
|
||||
Step("wr51", "interest_inject", asset: "human", label: "MassClash", tier: "Action", expect: "mass_clash",
|
||||
Step("wr51", "interest_inject", asset: "human", label: "Clash · 8 fighting", tier: "Action", expect: "mass_clash",
|
||||
value: "lead=event;evt=90;char=5;fighters=8;notables=0;force=true"),
|
||||
Step("wr52", "age_current", wait: 0.8f),
|
||||
Step("wr53", "director_run", wait: 1.2f),
|
||||
|
|
@ -925,6 +1001,14 @@ internal static class HarnessScenarios
|
|||
Step("wr57", "screenshot", value: "hud-watch-reason-clash.png"),
|
||||
Step("wr58", "wait", wait: 0.55f),
|
||||
|
||||
// After active dwell ends, orange reason clears even if focus remains.
|
||||
Step("wr70", "interest_force_session", asset: "human",
|
||||
label: "War: Essionan vs North", tier: "Epic", expect: "dwell_end_war"),
|
||||
Step("wr71", "assert", expect: "dossier_contains", value: "War"),
|
||||
Step("wr72", "interest_mark_inactive", value: "1.0"),
|
||||
Step("wr73", "assert", expect: "reason_empty", value: "true"),
|
||||
Step("wr74", "assert", expect: "reason_owns_focus"),
|
||||
|
||||
// Cold-boot / force-session focus gaps are unrelated to caption clarity.
|
||||
Step("wr89", "reset_counters"),
|
||||
Step("wr90", "assert", expect: "no_bad"),
|
||||
|
|
@ -976,16 +1060,14 @@ internal static class HarnessScenarios
|
|||
value: "lead=event;evt=55;char=40;fighters=2;notables=2"),
|
||||
Step("ap33", "assert", expect: "interest_score_order", value: "duel_kings", label: "melee_noname"),
|
||||
|
||||
// Combat Action cuts celebrity Story vignette after settle.
|
||||
// Combat Action margin-cuts celebrity Story vignette (instant).
|
||||
Step("ap40", "interest_expire_pending", value: ""),
|
||||
Step("ap41", "interest_force_session", asset: "sheep", label: "KingStroll", tier: "Story", expect: "king_stroll"),
|
||||
// Mark current as character vignette (not WorldLog story event).
|
||||
Step("ap42", "interest_inject", asset: "sheep", label: "RealFight", tier: "Action", expect: "real_fight",
|
||||
value: "lead=event;evt=90;char=10;fighters=4;notables=1;force=true"),
|
||||
Step("ap43", "age_current", wait: 1.2f),
|
||||
Step("ap44", "director_run", wait: 1.2f),
|
||||
value: "lead=event;evt=120;char=10;fighters=4;notables=1;force=true"),
|
||||
Step("ap43", "director_run", wait: 0.8f),
|
||||
Step("ap44", "assert", expect: "tip_contains", value: "RealFight"),
|
||||
Step("ap45", "assert", expect: "tip_contains", value: "RealFight"),
|
||||
Step("ap46", "assert", expect: "tip_contains", value: "RealFight"),
|
||||
|
||||
// Cold-boot focus gaps from welcome/dismiss are unrelated to score order.
|
||||
Step("ap89", "reset_counters"),
|
||||
|
|
@ -1069,6 +1151,126 @@ internal static class HarnessScenarios
|
|||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> WorldLogAudit()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("wla0", "wait_world"),
|
||||
Step("wla1", "assert", expect: "world_log_audit"),
|
||||
Step("wla2", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> DisasterWarAudit()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("dwa0", "wait_world"),
|
||||
Step("dwa1", "assert", expect: "disaster_war_audit"),
|
||||
Step("dwa2", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> DomainEventAudit()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("dea0", "wait_world"),
|
||||
Step("dea1", "assert", expect: "domain_event_audit"),
|
||||
Step("dea2", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> EventTracking()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("et0", "dismiss_windows"),
|
||||
Step("et1", "wait_world"),
|
||||
Step("et2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("et3", "pick_unit", asset: "auto"),
|
||||
Step("et4", "spectator", value: "off"),
|
||||
Step("et5", "spectator", value: "on"),
|
||||
Step("et6", "focus", asset: "auto"),
|
||||
|
||||
Step("et10", "assert", expect: "domain_event_audit"),
|
||||
|
||||
Step("et20", "domain_feed", asset: "relationship", value: "set_lover"),
|
||||
Step("et21", "assert", expect: "interest_has_key", value: "rel:set_lover"),
|
||||
Step("et22", "domain_feed", asset: "plot", value: "summon_meteor_rain"),
|
||||
Step("et23", "assert", expect: "interest_has_key", value: "plot:summon_meteor_rain"),
|
||||
Step("et24", "domain_feed", asset: "era", value: "age_chaos"),
|
||||
Step("et25", "assert", expect: "interest_has_key", value: "era:age_chaos"),
|
||||
Step("et26", "domain_feed", asset: "meta", value: "new_city"),
|
||||
Step("et27", "assert", expect: "interest_has_key", value: "meta:new_city"),
|
||||
Step("et28", "domain_feed", asset: "book", value: "bad_story_about_king"),
|
||||
Step("et29", "assert", expect: "interest_has_key", value: "book:bad_story_about_king"),
|
||||
Step("et30", "domain_feed", asset: "trait", value: "immortal"),
|
||||
Step("et31", "assert", expect: "interest_has_key", value: "trait:immortal"),
|
||||
Step("et32", "domain_feed", asset: "boat"),
|
||||
Step("et33", "assert", expect: "interest_has_key", value: "boat:"),
|
||||
Step("et34", "domain_feed", asset: "building"),
|
||||
Step("et35", "assert", expect: "interest_has_key", value: "building:"),
|
||||
Step("et40", "domain_feed", asset: "spell", value: "summon_lightning"),
|
||||
Step("et41", "assert", expect: "interest_has_key", value: "spell:summon_lightning"),
|
||||
Step("et42", "domain_feed", asset: "item", value: "sword"),
|
||||
Step("et43", "assert", expect: "interest_has_key", value: "item:"),
|
||||
Step("et44", "domain_feed", asset: "decision", value: "declare_war"),
|
||||
Step("et45", "assert", expect: "interest_has_key", value: "decision:"),
|
||||
Step("et46", "domain_feed", asset: "power", value: "lightning"),
|
||||
Step("et47", "assert", expect: "interest_has_key", value: "power:"),
|
||||
Step("et48", "domain_feed", asset: "subspecies_trait", value: "fire_blood"),
|
||||
Step("et49", "assert", expect: "interest_has_key", value: "subspecies_trait:"),
|
||||
Step("et50", "domain_feed", asset: "gene", value: "warfare_1"),
|
||||
Step("et51", "assert", expect: "interest_has_key", value: "gene:"),
|
||||
|
||||
Step("et90", "assert", expect: "health"),
|
||||
Step("et91", "assert", expect: "no_bad"),
|
||||
Step("et99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> EventCoverage()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("ec0", "dismiss_windows"),
|
||||
Step("ec1", "wait_world"),
|
||||
Step("ec2", "set_setting", expect: "enabled", value: "true"),
|
||||
Step("ec3", "pick_unit", asset: "auto"),
|
||||
Step("ec4", "spectator", value: "off"),
|
||||
Step("ec5", "spectator", value: "on"),
|
||||
Step("ec6", "focus", asset: "auto"),
|
||||
|
||||
// Full authored coverage gates
|
||||
Step("ec10", "assert", expect: "world_log_audit"),
|
||||
Step("ec11", "assert", expect: "disaster_war_audit"),
|
||||
Step("ec12", "assert", expect: "activity_status_audit"),
|
||||
Step("ec13", "assert", expect: "activity_happiness_audit"),
|
||||
Step("ec14", "assert", expect: "domain_event_audit"),
|
||||
|
||||
// Previously silent-dropped WorldLog disaster id must enter the registry
|
||||
Step("ec20", "world_log_feed", asset: "disaster_tornado"),
|
||||
Step("ec21", "assert", expect: "interest_has_key", value: "worldlog:disaster_tornado"),
|
||||
Step("ec22", "world_log_feed", asset: "disaster_alien_invasion"),
|
||||
Step("ec23", "assert", expect: "interest_has_key", value: "worldlog:disaster_alien_invasion"),
|
||||
|
||||
Step("ec90", "assert", expect: "health"),
|
||||
Step("ec91", "assert", expect: "no_bad"),
|
||||
Step("ec99", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> MutationDiscovery()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
{
|
||||
Step("md0", "wait_world"),
|
||||
Step("md1", "assert", expect: "mutation_discovery"),
|
||||
Step("md2", "snapshot")
|
||||
};
|
||||
}
|
||||
|
||||
private static List<HarnessCommand> ActivityRateSample()
|
||||
{
|
||||
return new List<HarnessCommand>
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ public sealed class InterestCandidate
|
|||
Score = TotalScore,
|
||||
Position = Position,
|
||||
FollowUnit = FollowUnit,
|
||||
RelatedUnit = RelatedUnit,
|
||||
Label = Label,
|
||||
CreatedAt = CreatedAt,
|
||||
AssetId = AssetId,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ public static class InterestCompletion
|
|||
return age < candidate.MaxWatch;
|
||||
|
||||
case InterestCompletionKind.FixedDwell:
|
||||
return age < Mathf.Max(candidate.MinWatch, candidate.MaxWatch * 0.35f);
|
||||
// Hold the authored MaxWatch window (director floors this to minCameraDwell).
|
||||
return age < Mathf.Max(candidate.MinWatch, candidate.MaxWatch);
|
||||
|
||||
case InterestCompletionKind.CombatActive:
|
||||
return CombatStillActive(candidate);
|
||||
|
|
@ -64,17 +65,22 @@ public static class InterestCompletion
|
|||
return false;
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
bool hot = false;
|
||||
try
|
||||
{
|
||||
if (unit.has_attack_target)
|
||||
{
|
||||
return true;
|
||||
hot = true;
|
||||
}
|
||||
|
||||
if (unit.hasTask() && unit.ai?.task != null
|
||||
&& (unit.ai.task.in_combat || unit.ai.task.is_fireman))
|
||||
else if (unit.hasTask() && unit.ai?.task != null
|
||||
&& (unit.ai.task.in_combat || unit.ai.task.is_fireman))
|
||||
{
|
||||
return true;
|
||||
hot = true;
|
||||
}
|
||||
else if (RelatedStillFightingUs(c, unit))
|
||||
{
|
||||
hot = true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
|
@ -82,14 +88,50 @@ public static class InterestCompletion
|
|||
// ignore
|
||||
}
|
||||
|
||||
// Battle asset without a live fighter: keep briefly via position only if battle still hot.
|
||||
if (c.AssetId == "live_battle")
|
||||
if (!hot && c.AssetId == "live_battle")
|
||||
{
|
||||
InterestEvent battle = WorldActivityScanner.FindHottestBattle();
|
||||
return battle != null;
|
||||
hot = battle != null;
|
||||
}
|
||||
|
||||
return false;
|
||||
if (hot)
|
||||
{
|
||||
// Refresh so brief attack_target gaps do not clear reason / open quiet grace.
|
||||
c.LastSeenAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hysteresis: WorldBox clears attack_target between swings.
|
||||
const float combatHysteresisSeconds = 8f;
|
||||
return now - c.LastSeenAt < combatHysteresisSeconds;
|
||||
}
|
||||
|
||||
private static bool RelatedStillFightingUs(InterestCandidate c, Actor unit)
|
||||
{
|
||||
Actor foe = c.RelatedUnit;
|
||||
if (foe == null || !foe.isAlive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!foe.has_attack_target || foe.attack_target == null || !foe.attack_target.isAlive())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!foe.attack_target.isActor())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return foe.attack_target.a == unit;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ActivityStillActive(InterestCandidate c)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NeoModLoader.services;
|
||||
using UnityEngine;
|
||||
|
|
@ -11,7 +12,7 @@ namespace IdleSpectator;
|
|||
/// </summary>
|
||||
public static class InterestDirector
|
||||
{
|
||||
public const float MinDwellSeconds = 10f;
|
||||
public const float MinDwellSeconds = 15f;
|
||||
public const float CuriosityDwellSeconds = 6f;
|
||||
public const float CuriosityRotateSeconds = 10f;
|
||||
public const float SwitchCooldownSeconds = 5f;
|
||||
|
|
@ -20,7 +21,7 @@ public static class InterestDirector
|
|||
public const float AmbientRotateSeconds = 20f;
|
||||
public const float ActionPollSeconds = 0.75f;
|
||||
public const float InputExitGraceSeconds = 2.5f;
|
||||
public const float QuietGraceSeconds = 1.2f;
|
||||
public const float QuietGraceSeconds = 6f;
|
||||
public const float CameraSettleGraceSeconds = 3f;
|
||||
|
||||
private static float _minDwell = MinDwellSeconds;
|
||||
|
|
@ -74,6 +75,68 @@ public static class InterestDirector
|
|||
|
||||
public static InterestCandidate CurrentCandidate => _current;
|
||||
|
||||
/// <summary>
|
||||
/// True while the current scene is past completion but still held through quiet_grace
|
||||
/// (camera may remain; orange reason must be empty).
|
||||
/// </summary>
|
||||
public static bool InQuietGrace
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_current == null || CurrentIsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _inactiveSince >= 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Candidate that may drive the orange dossier reason: owns <paramref name="unit"/>
|
||||
/// while the director still holds that scene (not quiet_grace).
|
||||
/// Sticky combat may briefly report !IsActive between swings - keep the reason up.
|
||||
/// </summary>
|
||||
public static InterestCandidate TryGetOwnedReasonCandidate(Actor unit)
|
||||
{
|
||||
if (unit == null || _current == null || InQuietGrace)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long unitId = 0;
|
||||
try
|
||||
{
|
||||
unitId = unit.getID();
|
||||
}
|
||||
catch
|
||||
{
|
||||
unitId = 0;
|
||||
}
|
||||
|
||||
if (_current.FollowUnit == unit)
|
||||
{
|
||||
return _current;
|
||||
}
|
||||
|
||||
if (_current.SubjectId != 0 && unitId != 0 && _current.SubjectId == unitId)
|
||||
{
|
||||
return _current;
|
||||
}
|
||||
|
||||
if (_current.RelatedUnit == unit)
|
||||
{
|
||||
return _current;
|
||||
}
|
||||
|
||||
if (_current.RelatedId != 0 && unitId != 0 && _current.RelatedId == unitId)
|
||||
{
|
||||
return _current;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string InterruptedKey =>
|
||||
_interrupted == null ? "" : (_interrupted.Key ?? "");
|
||||
|
||||
|
|
@ -134,7 +197,7 @@ public static class InterestDirector
|
|||
return false;
|
||||
}
|
||||
|
||||
InterestRegistry.Upsert(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
SwitchTo(candidate, Time.unscaledTime, resumableInterrupt: false);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -172,7 +235,8 @@ public static class InterestDirector
|
|||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch * 0.35f) + 0.5f;
|
||||
// Age past FixedDwell / Manual MaxWatch so IsActive is false.
|
||||
float pastActive = Mathf.Max(_current.MinWatch, _current.MaxWatch) + 0.5f;
|
||||
_currentStartedAt = now - pastActive;
|
||||
_lastSwitchAt = _currentStartedAt;
|
||||
_inactiveSince = now - Mathf.Max(0f, inactiveSeconds);
|
||||
|
|
@ -307,9 +371,8 @@ public static class InterestDirector
|
|||
sinceSwitch = 0f;
|
||||
}
|
||||
|
||||
// Queued discovery tips beat ambient fill, but never stall under Action/Story.
|
||||
if (InterestRegistry.HasPendingScoreAtLeast(InterestScoringConfig.W.fillScoreMax)
|
||||
&& (_current == null || InterestScoring.IsFillScore(_current.TotalScore)))
|
||||
// Pending events own the camera - never Character-fill over them.
|
||||
if (InterestRegistry.HasPendingEvent())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -350,6 +413,29 @@ public static class InterestDirector
|
|||
return;
|
||||
}
|
||||
|
||||
// Ambient: end promptly when the vignette goes cold - do not hold for minCameraDwell.
|
||||
if (IsAmbientShot(_current))
|
||||
{
|
||||
if (_inactiveSince < 0f)
|
||||
{
|
||||
_inactiveSince = now;
|
||||
}
|
||||
|
||||
if (now - _inactiveSince >= _quietGrace)
|
||||
{
|
||||
EndCurrent(now, reason: "quiet_grace");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Condition ended early: still hold the camera until min dwell, then grace.
|
||||
if (onCurrent < MinDwellFor(_current))
|
||||
{
|
||||
_inactiveSince = -999f;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_inactiveSince < 0f)
|
||||
{
|
||||
_inactiveSince = now;
|
||||
|
|
@ -373,20 +459,17 @@ public static class InterestDirector
|
|||
return;
|
||||
}
|
||||
|
||||
// Prefer related (grief survivor is already FollowUnit); try related then participants.
|
||||
// Ownership-preserving handoff only: related survivor. Never invent a stranger.
|
||||
if (_current.RelatedUnit != null && _current.RelatedUnit.isAlive())
|
||||
{
|
||||
_current.FollowUnit = _current.RelatedUnit;
|
||||
_current.SubjectId = EventFeedUtil.SafeId(_current.RelatedUnit);
|
||||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
return;
|
||||
}
|
||||
|
||||
Actor near = WorldActivityScanner.FindNearestAliveUnit(_current.Position, 18f);
|
||||
if (near != null)
|
||||
{
|
||||
_current.FollowUnit = near;
|
||||
CameraDirector.Watch(_current.ToInterestEvent());
|
||||
}
|
||||
// No owned related: end the scene rather than attach a nearby stranger.
|
||||
EndCurrent(Time.unscaledTime, reason: "follow_lost");
|
||||
}
|
||||
|
||||
private static void EndCurrent(float now, string reason)
|
||||
|
|
@ -429,6 +512,15 @@ public static class InterestDirector
|
|||
if (c == null || !CanSwitchTo(c, onCurrent, sinceSwitch))
|
||||
{
|
||||
PendingScratch.RemoveAt(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop stale / not-worth-watching shots from the pending set.
|
||||
if (!IsWorthWatchingNow(c, now, selectingDuringGrace: InQuietGrace)
|
||||
|| IsStaleFreshLifeMoment(c, now))
|
||||
{
|
||||
InterestRegistry.Remove(c.Key);
|
||||
PendingScratch.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -441,6 +533,54 @@ public static class InterestDirector
|
|||
return InterestVariety.Pick(PendingScratch, _current);
|
||||
}
|
||||
|
||||
private static bool IsStaleFreshLifeMoment(InterestCandidate c, float now)
|
||||
{
|
||||
if (c == null || string.IsNullOrEmpty(c.HappinessEffectId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string id = c.HappinessEffectId;
|
||||
bool hatch = string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase);
|
||||
bool spawn = string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase);
|
||||
if (!hatch && !spawn)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Queued too long - miss the beat.
|
||||
if (now - c.CreatedAt > 5.5f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Actor unit = c.FollowUnit;
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hatch: egg-loss / happiness may land after the unit is already past baby form.
|
||||
// Only drop on age for spawn (just_born), not hatch.
|
||||
if (spawn)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!unit.isBaby() && unit.getAge() > 1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore age API mismatches
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a drained New species tip would be allowed to take the camera.
|
||||
/// </summary>
|
||||
|
|
@ -539,39 +679,139 @@ public static class InterestDirector
|
|||
|
||||
private static float MinDwellFor(InterestCandidate current)
|
||||
{
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds;
|
||||
if (current == null)
|
||||
{
|
||||
return _minDwell;
|
||||
return _minDwell < MinDwellSeconds * 0.5f ? _minDwell : floor;
|
||||
}
|
||||
|
||||
// Ambient / Character fill: no event floor - real events may take the camera anytime.
|
||||
if (IsAmbientShot(current))
|
||||
{
|
||||
if (_minDwell < MinDwellSeconds * 0.5f)
|
||||
{
|
||||
return InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell;
|
||||
}
|
||||
|
||||
return w.dwellFill > 0f ? w.dwellFill : 6f;
|
||||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float dwell = InterestScoring.IsHotScore(current.TotalScore)
|
||||
? w.dwellHot
|
||||
: (InterestScoring.IsFillScore(current.TotalScore) ? w.dwellFill : w.dwellNormal);
|
||||
: w.dwellNormal;
|
||||
if (_minDwell < MinDwellSeconds * 0.5f)
|
||||
{
|
||||
// Harness fast timing: keep compressed dwells.
|
||||
dwell = InterestScoring.IsFillScore(current.TotalScore) ? _curiosityDwell : _minDwell;
|
||||
dwell = _minDwell;
|
||||
return current.MinWatch > 0f ? Mathf.Max(current.MinWatch, dwell) : dwell;
|
||||
}
|
||||
|
||||
if (current.MinWatch > 0f)
|
||||
// Live EventLed: never peer-rotate / soft-cut before the camera floor.
|
||||
// Score-margin interrupts bypass this via CanSwitchTo.
|
||||
float minWatch = current.MinWatch > 0f ? current.MinWatch : 0f;
|
||||
return Mathf.Max(floor, dwell, minWatch);
|
||||
}
|
||||
|
||||
/// <summary>Character fill / low-score stroll - events should cut in freely.</summary>
|
||||
private static bool IsAmbientShot(InterestCandidate c)
|
||||
{
|
||||
if (c == null)
|
||||
{
|
||||
return InterestScoring.IsFillScore(current.TotalScore)
|
||||
? Mathf.Min(current.MinWatch, dwell)
|
||||
: Mathf.Max(current.MinWatch, dwell * 0.15f);
|
||||
return true;
|
||||
}
|
||||
|
||||
return dwell;
|
||||
if (c.LeadKind == InterestLeadKind.CharacterLed
|
||||
|| c.Completion == InterestCompletionKind.CharacterVignette)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sticky event holds are never ambient even if score is temporarily fill-band.
|
||||
if (c.Completion == InterestCompletionKind.CombatActive
|
||||
|| c.Completion == InterestCompletionKind.StatusPhase
|
||||
|| c.Completion == InterestCompletionKind.HappinessGrief
|
||||
|| c.Completion == InterestCompletionKind.ActivityActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return InterestScoring.IsFillScore(c.TotalScore);
|
||||
}
|
||||
|
||||
private static float MaxWatchFor(InterestCandidate current)
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
return 45f;
|
||||
return InterestScoringConfig.W.maxWatchMoment;
|
||||
}
|
||||
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float classCap = w.maxWatchMoment;
|
||||
switch (current.Completion)
|
||||
{
|
||||
case InterestCompletionKind.CombatActive:
|
||||
classCap = w.maxWatchCombat;
|
||||
break;
|
||||
case InterestCompletionKind.StatusPhase:
|
||||
classCap = w.maxWatchStatus;
|
||||
break;
|
||||
case InterestCompletionKind.HappinessGrief:
|
||||
classCap = w.maxWatchGrief;
|
||||
break;
|
||||
case InterestCompletionKind.FixedDwell:
|
||||
case InterestCompletionKind.Manual:
|
||||
classCap = current.EventStrength >= w.hotScoreMin
|
||||
? w.maxWatchEpic
|
||||
: w.maxWatchMoment;
|
||||
break;
|
||||
default:
|
||||
if (current.EventStrength >= w.hotScoreMin)
|
||||
{
|
||||
classCap = w.maxWatchEpic;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
float cap = classCap;
|
||||
if (current.MaxWatch > 0f)
|
||||
{
|
||||
// Sticky live holds use the class MaxWatch (e.g. combat 60s) - do not let a
|
||||
// short candidate MaxWatch max_cap the fight while it is still active.
|
||||
if (current.Completion == InterestCompletionKind.CombatActive
|
||||
|| current.Completion == InterestCompletionKind.StatusPhase
|
||||
|| current.Completion == InterestCompletionKind.HappinessGrief
|
||||
|| current.Completion == InterestCompletionKind.ActivityActive)
|
||||
{
|
||||
cap = Mathf.Max(current.MaxWatch, classCap);
|
||||
}
|
||||
else
|
||||
{
|
||||
cap = Mathf.Min(current.MaxWatch, classCap);
|
||||
}
|
||||
}
|
||||
|
||||
// Moment / FixedDwell shots must last at least the camera floor unless interrupted.
|
||||
if (current.Completion == InterestCompletionKind.FixedDwell
|
||||
|| current.Completion == InterestCompletionKind.Manual)
|
||||
{
|
||||
float floor = w.minCameraDwell > 0f ? w.minCameraDwell : MinDwellSeconds;
|
||||
cap = Mathf.Max(cap, floor);
|
||||
}
|
||||
|
||||
// Ambient fill: no minCameraDwell floor - short vignettes are fine.
|
||||
if (IsAmbientShot(current))
|
||||
{
|
||||
float fillCap = w.dwellFill > 0f ? w.dwellFill * 2f : 20f;
|
||||
if (current.MaxWatch > 0f)
|
||||
{
|
||||
fillCap = Mathf.Min(fillCap, Mathf.Max(current.MaxWatch, 8f));
|
||||
}
|
||||
|
||||
cap = fillCap;
|
||||
}
|
||||
|
||||
float cap = current.MaxWatch > 0f ? current.MaxWatch : 45f;
|
||||
// Harness fast timing compresses caps.
|
||||
if (_minDwell < MinDwellSeconds * 0.5f)
|
||||
{
|
||||
|
|
@ -581,10 +821,7 @@ public static class InterestDirector
|
|||
return cap;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Protected Action+ scenes: only Epic may preempt after settle grace.
|
||||
/// Ambient/Curiosity may be replaced by higher urgency after settle.
|
||||
/// </summary>
|
||||
/// <summary>Hold while live and under MaxWatch; quiet grace still counts as briefly protected.</summary>
|
||||
private static bool SessionProtected(float now, float onCurrent)
|
||||
{
|
||||
if (_current == null)
|
||||
|
|
@ -600,6 +837,12 @@ public static class InterestDirector
|
|||
bool active = InterestCompletion.IsActive(_current, _currentStartedAt, now);
|
||||
if (!active)
|
||||
{
|
||||
// Ambient: no minCameraDwell hold after the vignette goes cold.
|
||||
if (!IsAmbientShot(_current) && onCurrent < MinDwellFor(_current))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_inactiveSince >= 0f && now - _inactiveSince >= _quietGrace)
|
||||
{
|
||||
return false;
|
||||
|
|
@ -621,18 +864,55 @@ public static class InterestDirector
|
|||
|
||||
if (_current == null)
|
||||
{
|
||||
return true;
|
||||
return IsWorthWatchingNow(candidate, Time.unscaledTime, selectingDuringGrace: false);
|
||||
}
|
||||
|
||||
float now = Time.unscaledTime;
|
||||
bool inGrace = InQuietGrace;
|
||||
if (inGrace)
|
||||
{
|
||||
// Quiet grace after a sticky hold: only margin-worthy events may cut in.
|
||||
// Prevents family-join / love status from stealing a fight between swings.
|
||||
if (_current != null
|
||||
&& (_current.Completion == InterestCompletionKind.CombatActive
|
||||
|| _current.Completion == InterestCompletionKind.StatusPhase
|
||||
|| _current.Completion == InterestCompletionKind.HappinessGrief))
|
||||
{
|
||||
float graceCur = _current.TotalScore;
|
||||
float graceNext = candidate.TotalScore;
|
||||
return graceNext >= graceCur + InterestScoringConfig.W.cutInMargin
|
||||
&& IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
|
||||
}
|
||||
|
||||
return IsWorthWatchingNow(candidate, now, selectingDuringGrace: true);
|
||||
}
|
||||
|
||||
if (!IsWorthWatchingNow(candidate, now, selectingDuringGrace: false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool protectedScene = SessionProtected(now, onCurrent);
|
||||
ScoringWeights w = InterestScoringConfig.W;
|
||||
float curScore = _current.TotalScore;
|
||||
float nextScore = candidate.TotalScore;
|
||||
bool nextFill = InterestScoring.IsFillScore(nextScore);
|
||||
bool curFill = InterestScoring.IsFillScore(curScore);
|
||||
bool curAmbient = IsAmbientShot(_current);
|
||||
bool curFill = curAmbient || InterestScoring.IsFillScore(curScore);
|
||||
|
||||
// Fill/curiosity-strength scenes never cut protected non-fill sessions.
|
||||
// Ambient fill: any real event may take the camera immediately (no min dwell).
|
||||
if (curAmbient && !nextFill && !IsAmbientShot(candidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Instant score-margin cut - no settle grace, no MinDwell block.
|
||||
if (nextScore >= curScore + w.cutInMargin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fill never cuts a protected non-fill session without margin (already failed above).
|
||||
if (nextFill && !curFill && protectedScene)
|
||||
{
|
||||
return false;
|
||||
|
|
@ -644,42 +924,105 @@ public static class InterestDirector
|
|||
return false;
|
||||
}
|
||||
|
||||
bool typedCutIn = InterestScoring.IsCombatAction(candidate)
|
||||
&& InterestScoring.IsCharacterVignette(_current)
|
||||
&& onCurrent >= _settleGrace;
|
||||
bool combatCutsNonCombat = InterestScoring.IsCombatAction(candidate)
|
||||
&& !InterestScoring.IsCombatAction(_current)
|
||||
&& onCurrent >= _settleGrace
|
||||
&& nextScore >= curScore - w.rotateSlack;
|
||||
bool scoreCutIn = onCurrent >= _settleGrace
|
||||
&& nextScore >= curScore + w.cutInMargin;
|
||||
|
||||
if (protectedScene && !curFill)
|
||||
// Protected EventLed hold: only margin cut-in (handled above).
|
||||
if (protectedScene && !curAmbient)
|
||||
{
|
||||
if (typedCutIn || combatCutsNonCombat || scoreCutIn)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unprotected / fill current: stronger score after settle, or peer rotate after dwell.
|
||||
if (nextScore > curScore && onCurrent >= _settleGrace)
|
||||
// Peer rotate / stronger score after MinDwell when unprotected or on fill.
|
||||
if (nextScore > curScore && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!protectedScene && onCurrent >= MinDwellFor(_current) && sinceSwitch >= _switchCooldown)
|
||||
{
|
||||
return nextScore >= curScore - w.rotateSlack;
|
||||
}
|
||||
|
||||
return onCurrent >= MinDwellFor(_current)
|
||||
&& sinceSwitch >= _switchCooldown
|
||||
&& nextScore >= curScore - w.rotateSlack;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Still-worth-watching filter: drop stale queued moments; keep live sticky conditions
|
||||
/// and fresh FixedDwell; during grace, brand-new fires are always eligible.
|
||||
/// </summary>
|
||||
private static bool IsWorthWatchingNow(InterestCandidate c, float now, bool selectingDuringGrace)
|
||||
{
|
||||
if (c == null || !c.HasValidPosition)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectingDuringGrace && c.CreatedAt >= _inactiveSince && _inactiveSince >= 0f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Purge moments that fired before the current scene started (stale queue).
|
||||
if (_current != null && c.CreatedAt < _currentStartedAt - 0.05f)
|
||||
{
|
||||
if (c.Completion == InterestCompletionKind.FixedDwell
|
||||
|| c.Completion == InterestCompletionKind.Manual)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sticky: only if still live.
|
||||
return InterestCompletion.IsActive(c, c.CreatedAt, now);
|
||||
}
|
||||
|
||||
if (IsStaleMomentCandidate(c, now))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (c.Completion)
|
||||
{
|
||||
case InterestCompletionKind.CombatActive:
|
||||
case InterestCompletionKind.StatusPhase:
|
||||
case InterestCompletionKind.HappinessGrief:
|
||||
case InterestCompletionKind.ActivityActive:
|
||||
return InterestCompletion.IsActive(c, c.CreatedAt, now)
|
||||
|| now - c.LastSeenAt < 2f;
|
||||
case InterestCompletionKind.FixedDwell:
|
||||
case InterestCompletionKind.Manual:
|
||||
// Fresh moment: age under ~4s or more than half MaxWatch remaining.
|
||||
float age = now - c.CreatedAt;
|
||||
if (age < 4f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float maxW = c.MaxWatch > 0f ? c.MaxWatch : InterestScoringConfig.W.maxWatchMoment;
|
||||
return age < maxW * 0.5f;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsStaleMomentCandidate(InterestCandidate c, float now)
|
||||
{
|
||||
if (IsStaleFreshLifeMoment(c, now))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (c.ExpiresAt > 0f && now > c.ExpiresAt)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Queued under an active hold: short TTL for FixedDwell moments.
|
||||
if (_current != null
|
||||
&& CurrentIsActive
|
||||
&& (c.Completion == InterestCompletionKind.FixedDwell || c.Completion == InterestCompletionKind.Manual)
|
||||
&& now - c.CreatedAt > 8f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void SwitchTo(InterestCandidate next, float now, bool resumableInterrupt)
|
||||
{
|
||||
if (next == null)
|
||||
|
|
@ -688,9 +1031,8 @@ public static class InterestDirector
|
|||
}
|
||||
|
||||
if (_current != null
|
||||
&& InterestScoring.IsHotScore(next.TotalScore)
|
||||
&& !InterestScoring.IsHotScore(_current.TotalScore)
|
||||
&& _current.Resumable)
|
||||
&& _current.Resumable
|
||||
&& next.TotalScore >= _current.TotalScore + InterestScoringConfig.W.cutInMargin)
|
||||
{
|
||||
_interrupted = _current.CloneShallow();
|
||||
}
|
||||
|
|
@ -757,6 +1099,8 @@ public static class InterestDirector
|
|||
candidate.LeadKind = InterestLeadKind.CharacterLed;
|
||||
candidate.Category = "CharacterVignette";
|
||||
candidate.Completion = InterestCompletionKind.CharacterVignette;
|
||||
// Fill is not an event - empty orange reason; task chip shows live job.
|
||||
candidate.Label = "";
|
||||
if (AgentHarness.Busy)
|
||||
{
|
||||
candidate.ForceActive = false;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public sealed class InterestEvent
|
|||
public float Score;
|
||||
public Vector3 Position;
|
||||
public Actor FollowUnit;
|
||||
public Actor RelatedUnit;
|
||||
public string Label;
|
||||
public float CreatedAt;
|
||||
public string AssetId;
|
||||
|
|
@ -20,6 +21,8 @@ public sealed class InterestEvent
|
|||
|
||||
public bool HasFollowUnit => FollowUnit != null && FollowUnit.isAlive();
|
||||
|
||||
public bool HasRelatedUnit => RelatedUnit != null && RelatedUnit.isAlive();
|
||||
|
||||
public bool HasValidPosition =>
|
||||
HasFollowUnit || (Position != Vector3.zero && !float.IsNaN(Position.x));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,21 @@ public static class InterestRegistry
|
|||
{
|
||||
if (ByKey.TryGetValue(incoming.Key, out InterestCandidate existing) && existing != null)
|
||||
{
|
||||
// Ownership merge: same key must not adopt a different subject's FollowUnit/Label.
|
||||
bool sameSubject = incoming.SubjectId == 0
|
||||
|| existing.SubjectId == 0
|
||||
|| incoming.SubjectId == existing.SubjectId;
|
||||
if (!sameSubject)
|
||||
{
|
||||
// Reject subject hijack; keep existing ownership, still refresh TTL/scores lightly.
|
||||
existing.LastSeenAt = now;
|
||||
existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt);
|
||||
existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength);
|
||||
existing.TotalScore = Mathf.Max(existing.TotalScore, incoming.TotalScore);
|
||||
existing.Selected = false;
|
||||
return existing;
|
||||
}
|
||||
|
||||
existing.LastSeenAt = now;
|
||||
existing.ExpiresAt = Mathf.Max(existing.ExpiresAt, incoming.ExpiresAt);
|
||||
existing.EventStrength = Mathf.Max(existing.EventStrength, incoming.EventStrength);
|
||||
|
|
@ -100,6 +115,9 @@ public static class InterestRegistry
|
|||
if (incoming.FollowUnit != null && incoming.FollowUnit.isAlive())
|
||||
{
|
||||
existing.FollowUnit = incoming.FollowUnit;
|
||||
existing.SubjectId = incoming.SubjectId != 0
|
||||
? incoming.SubjectId
|
||||
: EventFeedUtil.SafeId(incoming.FollowUnit);
|
||||
existing.Position = incoming.FollowUnit.current_position;
|
||||
}
|
||||
else if (incoming.Position != Vector3.zero)
|
||||
|
|
@ -306,6 +324,29 @@ public static class InterestRegistry
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>True when any unselected EventLed candidate is waiting for the camera.</summary>
|
||||
public static bool HasPendingEvent()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
foreach (KeyValuePair<string, InterestCandidate> kv in ByKey)
|
||||
{
|
||||
InterestCandidate c = kv.Value;
|
||||
if (c == null || c.Selected || !c.HasValidPosition)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c.LeadKind == InterestLeadKind.EventLed)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TrimLocked(float now)
|
||||
{
|
||||
if (ByKey.Count <= MaxCandidates)
|
||||
|
|
|
|||
|
|
@ -39,37 +39,12 @@ public static class InterestScoring
|
|||
|
||||
public static float EventStrengthForHappiness(string effectId)
|
||||
{
|
||||
string id = effectId ?? "";
|
||||
switch (id)
|
||||
{
|
||||
case "death_child":
|
||||
return 95f;
|
||||
case "death_lover":
|
||||
return 88f;
|
||||
case "death_best_friend":
|
||||
return 78f;
|
||||
case "death_family_member":
|
||||
return 76f;
|
||||
case "just_born":
|
||||
case "just_hatched":
|
||||
case "had_child":
|
||||
case "just_had_child":
|
||||
return 70f;
|
||||
case "become_king":
|
||||
case "become_city_leader":
|
||||
case "become_clan_chief":
|
||||
return 72f;
|
||||
case "just_found_house":
|
||||
case "new_home":
|
||||
return 55f;
|
||||
default:
|
||||
return 40f;
|
||||
}
|
||||
return EventCatalog.Happiness.GetOrFallback(effectId).EventStrength;
|
||||
}
|
||||
|
||||
public static float EventStrengthForWorldLog(string assetId)
|
||||
{
|
||||
return WorldLogInterestTable.TryGetEventStrength(assetId, out float seed) ? seed : 0f;
|
||||
return EventCatalog.WorldLog.GetOrFallback(assetId).EventStrength;
|
||||
}
|
||||
|
||||
public static bool IsFillScore(float totalScore)
|
||||
|
|
|
|||
|
|
@ -32,10 +32,19 @@ public class ScoringWeights
|
|||
public float fillScoreMax = 55f;
|
||||
public float noticeScoreMin = 70f;
|
||||
public float hotScoreMin = 110f;
|
||||
public float dwellFill = 6f;
|
||||
public float dwellNormal = 10f;
|
||||
public float dwellHot = 14f;
|
||||
public float fillRotateSeconds = 10f;
|
||||
public float dwellFill = 8f;
|
||||
public float dwellNormal = 15f;
|
||||
public float dwellHot = 15f;
|
||||
public float fillRotateSeconds = 8f;
|
||||
/// <summary>Minimum seconds on an EventLed shot before peer rotate / soft cut (interrupts bypass). Ambient exempt.</summary>
|
||||
public float minCameraDwell = 15f;
|
||||
|
||||
// MaxWatch caps by completion class (director clamps candidate.MaxWatch).
|
||||
public float maxWatchMoment = 15f;
|
||||
public float maxWatchStatus = 30f;
|
||||
public float maxWatchGrief = 45f;
|
||||
public float maxWatchCombat = 60f;
|
||||
public float maxWatchEpic = 50f;
|
||||
|
||||
public int massFighterThreshold = 4;
|
||||
public float massBaseBonus = 28f;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using HarmonyLib;
|
||||
using NeoModLoader.api;
|
||||
|
|
@ -56,12 +57,42 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
|
|||
InterestScoringConfig.LoadFromModFolder(ModFolder);
|
||||
|
||||
_harmony = new Harmony(pModDecl.UID);
|
||||
_harmony.PatchAll(typeof(ModClass).Assembly);
|
||||
ApplyHarmonyPatches(pModDecl.Name);
|
||||
LogService.LogInfo($"[{pModDecl.Name}]: Harmony patches applied");
|
||||
LogService.LogInfo($"[{pModDecl.Name}]: Press I for Idle Spectator, L for Lore");
|
||||
Chronicle.EnsureWindow();
|
||||
}
|
||||
|
||||
private void ApplyHarmonyPatches(string modName)
|
||||
{
|
||||
Exception firstFailure = null;
|
||||
foreach (Type type in typeof(ModClass).Assembly.GetTypes())
|
||||
{
|
||||
try
|
||||
{
|
||||
new PatchClassProcessor(_harmony, type).Patch();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.LogError($"[{modName}]: Harmony failed on {type.FullName}: {ex}");
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
LogService.LogError($"[{modName}]: Inner: {ex.InnerException}");
|
||||
}
|
||||
|
||||
if (firstFailure == null)
|
||||
{
|
||||
firstFailure = ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstFailure != null)
|
||||
{
|
||||
throw firstFailure;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
AgentHarness.Update();
|
||||
|
|
|
|||
920
IdleSpectator/MutationDiscoveryHarness.cs
Normal file
920
IdleSpectator/MutationDiscoveryHarness.cs
Normal file
|
|
@ -0,0 +1,920 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public sealed class MutationDiscoveryResult
|
||||
{
|
||||
public bool Passed;
|
||||
public string Detail = "";
|
||||
public int Managers;
|
||||
public int ManagerMethods;
|
||||
public int ActorMethods;
|
||||
public int BehTypes;
|
||||
public int Libraries;
|
||||
public int LibraryAssets;
|
||||
public int Gaps;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers event-relevant mutation paths from live game assemblies and AssetManager.
|
||||
/// Writes TSVs under .harness/ for the idle-camera coverage plan.
|
||||
/// </summary>
|
||||
public static class MutationDiscoveryHarness
|
||||
{
|
||||
private static readonly string[] ManagerNameNeedles =
|
||||
{
|
||||
"Manager", "Keeper"
|
||||
};
|
||||
|
||||
private static readonly string[] MethodPrefixNeedles =
|
||||
{
|
||||
"new", "add", "remove", "set", "become", "start", "finish", "cancel",
|
||||
"create", "destroy", "die", "kill", "birth", "clear", "leave", "join",
|
||||
"make", "end", "load", "spawn", "generate", "burn", "write"
|
||||
};
|
||||
|
||||
private static readonly string[] ActorMethodNeedles =
|
||||
{
|
||||
"lover", "friend", "family", "child", "parent", "baby", "born", "birth",
|
||||
"mate", "breed", "social", "clan", "king", "leader", "trait", "status",
|
||||
"happiness", "plot", "army", "war", "alliance", "culture", "religion",
|
||||
"language", "book", "item", "boat", "home", "house", "city", "kingdom",
|
||||
"die", "kill", "task", "job"
|
||||
};
|
||||
|
||||
private static readonly string[] BehNeedles =
|
||||
{
|
||||
"Lover", "Friend", "Family", "Social", "Child", "Baby", "Birth", "Plot",
|
||||
"Clan", "Army", "War", "Book", "Build", "Boat", "Trade", "Era", "Age",
|
||||
"Trait", "Religion", "Culture", "Language", "Alliance", "King", "Marry"
|
||||
};
|
||||
|
||||
private static readonly string[] LibraryFields =
|
||||
{
|
||||
"actor_library", "status", "happiness_library", "world_log_library",
|
||||
"disasters", "war_types_library", "traits", "plots_library", "plot_category_library",
|
||||
"era_library", "buildings", "architecture_library", "city_build_orders",
|
||||
"culture_traits", "religion_traits", "clan_traits", "language_traits",
|
||||
"subspecies_traits", "kingdoms_traits", "book_types", "items", "items_modifiers",
|
||||
"spells", "powers", "combat_action_library", "decisions_library",
|
||||
"loyalty_library", "opinion_library", "knowledge_library", "gene_library",
|
||||
"phenotype_library", "communication_library", "story_library",
|
||||
"world_laws_library", "biome_library", "citizen_job_library", "professions",
|
||||
"personalities", "tasks_actor"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AlreadyWired =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Actor.changeHappiness",
|
||||
"Actor.checkHappinessChangeFromDeathEvent",
|
||||
"Actor.birthEvent",
|
||||
"Actor.becomeLoversWith",
|
||||
"Actor.setBestFriend",
|
||||
"Actor.newKillAction",
|
||||
"Actor.die",
|
||||
"Actor.setTask",
|
||||
"Actor.setLover",
|
||||
"Actor.addChild",
|
||||
"Actor.addChildSimple",
|
||||
"Actor.leavePlot",
|
||||
"Actor.setPlot",
|
||||
"Actor.addTrait",
|
||||
"Actor.removeTrait",
|
||||
"Actor.generatePhenotypeAndShade",
|
||||
"Actor.setDecisionCooldown",
|
||||
"FamilyManager.newFamily",
|
||||
"FamilyManager.removeObject",
|
||||
"ActorManager.createBabyActorFromData",
|
||||
"PlotManager.newPlot",
|
||||
"PlotManager.cancelPlot",
|
||||
"WarManager.newWar",
|
||||
"WarManager.endWar",
|
||||
"BookManager.newBook",
|
||||
"BookManager.generateNewBook",
|
||||
"BookManager.burnBook",
|
||||
"WorldAgeManager.startNextAge",
|
||||
"CultureManager.newCulture",
|
||||
"ReligionManager.newReligion",
|
||||
"LanguageManager.newLanguage",
|
||||
"ClanManager.newClan",
|
||||
"ArmyManager.newArmy",
|
||||
"AllianceManager.newAlliance",
|
||||
"CityManager.newCity",
|
||||
"ItemManager.generateItem",
|
||||
"ItemManager.newItem",
|
||||
"SubspeciesManager.newSpecies",
|
||||
"SubspeciesManager.addRandomTraitFromBiomeToSubspecies",
|
||||
"SubspeciesManager.addTraitsFromBiomeToSubspecies",
|
||||
"Subspecies.mutateFrom",
|
||||
"BehDealDamageToTargetBuilding.execute",
|
||||
"BehConsumeTargetBuilding.execute",
|
||||
"Building.startDestroyBuilding",
|
||||
"BehBoatTransportUnloadUnits.execute",
|
||||
"BehBoatMakeTrade.execute",
|
||||
"BehBoatTransportDoLoading.execute",
|
||||
"BehFindLover.execute",
|
||||
"BehFamilyGroupNew.execute",
|
||||
"BehFamilyGroupJoin.execute",
|
||||
"BehFamilyGroupLeave.execute",
|
||||
"BehPollinate.execute",
|
||||
"BehUnloadResources.execute",
|
||||
"BehAttackActorHuntingTarget.execute",
|
||||
"BehCityActorRemoveFire.execute",
|
||||
"BehTryToSocialize.execute",
|
||||
"BehPlantCrops.execute",
|
||||
"BehCityActorFertilizeCrop.execute",
|
||||
"BehThrowResources.execute",
|
||||
"BehBuildTarget.execute",
|
||||
"BehPoopOutside.execute",
|
||||
"BehPoopInside.execute",
|
||||
"BehBoatFishing.execute",
|
||||
"BehBoatCollectFish.execute",
|
||||
"BehClaimZoneForCityActorBorder.execute",
|
||||
"CombatActionLibrary.tryToCastSpell",
|
||||
"PlayerControl.clickedFinal",
|
||||
"WorldLaws.enable",
|
||||
"WorldLawAsset.toggle",
|
||||
"WorldLogMessageExtensions.add",
|
||||
"BaseSimObject.addNewStatusEffect",
|
||||
"Status.finish",
|
||||
"StatusManager.newStatus",
|
||||
"InterestFeeds.OnWorldLogMessage",
|
||||
"InterestFeeds.OnStatusChange",
|
||||
"InterestFeeds.OnActivityNote",
|
||||
"InterestFeeds.OnChronicleMilestone",
|
||||
"BattleKeeperManager.addUnitKilled",
|
||||
"WarInterestFeed.Tick",
|
||||
"PlotInterestFeed.Tick",
|
||||
"DeferredInterestFeed",
|
||||
"WorldActivityScanner.FindHottestBattle",
|
||||
"SpeciesDiscovery",
|
||||
// Covered by primary owners (birth / die / trait / city / WorldLog).
|
||||
"ActorManager.spawnNewUnit",
|
||||
"ActorManager.spawnNewUnitByPlayer",
|
||||
"Actor.dieSimpleNone",
|
||||
"Actor.dieAndDestroy",
|
||||
"Actor.addChildren",
|
||||
"Actor.removeTraits",
|
||||
"Actor.addInjuryTrait",
|
||||
"Actor.applyForcedKingdomTrait",
|
||||
"Actor.generateRandomSpawnTraits",
|
||||
"Actor.traitModifiedEvent",
|
||||
"KingdomManager.makeNewCivKingdom"
|
||||
};
|
||||
|
||||
public static MutationDiscoveryResult Run(string outputDirectory)
|
||||
{
|
||||
Assembly gameAsm = typeof(Actor).Assembly;
|
||||
var managersTsv = new StringBuilder();
|
||||
managersTsv.AppendLine("type\tmethod\tarity\tstatic\tkind_guess\talready_wired\tnotes");
|
||||
|
||||
var actorTsv = new StringBuilder();
|
||||
actorTsv.AppendLine("type\tmethod\tarity\tstatic\tneedle\talready_wired\tnotes");
|
||||
|
||||
var behTsv = new StringBuilder();
|
||||
behTsv.AppendLine("type\tmethod\tarity\tneedle\talready_wired\tnotes");
|
||||
|
||||
var libTsv = new StringBuilder();
|
||||
libTsv.AppendLine("field\tcount\tsample_ids\tnotes");
|
||||
|
||||
var gapsTsv = new StringBuilder();
|
||||
gapsTsv.AppendLine(
|
||||
"kind\ttype\tmethod_or_field\tevent_worthy\tsuggested_creates_interest\talready_wired\treason");
|
||||
|
||||
int managerCount = 0;
|
||||
int managerMethods = 0;
|
||||
int actorMethods = 0;
|
||||
int behTypes = 0;
|
||||
int libraries = 0;
|
||||
int libraryAssets = 0;
|
||||
int gaps = 0;
|
||||
|
||||
Type[] types;
|
||||
try
|
||||
{
|
||||
types = gameAsm.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
types = ex.Types ?? Array.Empty<Type>();
|
||||
}
|
||||
|
||||
var managerTypes = new List<Type>();
|
||||
var behTypesList = new List<Type>();
|
||||
Type actorType = typeof(Actor);
|
||||
Type baseSimType = typeof(BaseSimObject);
|
||||
|
||||
for (int i = 0; i < types.Length; i++)
|
||||
{
|
||||
Type t = types[i];
|
||||
if (t == null || string.IsNullOrEmpty(t.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string name = t.Name;
|
||||
if (IsManagerish(name))
|
||||
{
|
||||
managerTypes.Add(t);
|
||||
}
|
||||
|
||||
if (name.StartsWith("Beh", StringComparison.Ordinal) && MatchesAny(name, BehNeedles))
|
||||
{
|
||||
behTypesList.Add(t);
|
||||
}
|
||||
}
|
||||
|
||||
managerTypes.Sort((a, b) => string.CompareOrdinal(a.FullName, b.FullName));
|
||||
for (int i = 0; i < managerTypes.Count; i++)
|
||||
{
|
||||
Type t = managerTypes[i];
|
||||
managerCount++;
|
||||
MethodInfo[] methods = SafeMethods(t);
|
||||
for (int m = 0; m < methods.Length; m++)
|
||||
{
|
||||
MethodInfo method = methods[m];
|
||||
if (method == null || method.IsSpecialName)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!MethodLooksMutating(method.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
managerMethods++;
|
||||
string key = t.Name + "." + method.Name;
|
||||
bool wired = AlreadyWired.Contains(key);
|
||||
string kind = GuessManagerKind(t.Name, method.Name);
|
||||
managersTsv.Append(t.Name).Append('\t')
|
||||
.Append(method.Name).Append('\t')
|
||||
.Append(method.GetParameters().Length).Append('\t')
|
||||
.Append(method.IsStatic ? "1" : "0").Append('\t')
|
||||
.Append(kind).Append('\t')
|
||||
.Append(wired ? "1" : "0").Append('\t')
|
||||
.Append(Escape(method.ToString())).AppendLine();
|
||||
|
||||
if (!wired && IsEventWorthyManager(t.Name, method.Name))
|
||||
{
|
||||
gaps++;
|
||||
gapsTsv.Append("ManagerGenesis").Append('\t')
|
||||
.Append(t.Name).Append('\t')
|
||||
.Append(method.Name).Append('\t')
|
||||
.Append("Yes").Append('\t')
|
||||
.Append(SuggestCreatesInterest(t.Name, method.Name)).Append('\t')
|
||||
.Append("0").Append('\t')
|
||||
.Append("unwired_manager_mutation").AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DumpTypeMethods(actorType, ActorMethodNeedles, actorTsv, gapsTsv, ref actorMethods, ref gaps);
|
||||
DumpTypeMethods(baseSimType, ActorMethodNeedles, actorTsv, gapsTsv, ref actorMethods, ref gaps);
|
||||
|
||||
// Also scan Actor for lover/friend clear-style methods even if needle-filtered narrowly.
|
||||
DumpExtraActorRelationMethods(actorType, actorTsv, gapsTsv, ref actorMethods, ref gaps);
|
||||
|
||||
behTypesList.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name));
|
||||
for (int i = 0; i < behTypesList.Count; i++)
|
||||
{
|
||||
Type t = behTypesList[i];
|
||||
behTypes++;
|
||||
string needle = FirstMatch(t.Name, BehNeedles);
|
||||
MethodInfo exec = t.GetMethod("execute", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
string methodName = exec != null ? "execute" : "";
|
||||
string key = t.Name + (string.IsNullOrEmpty(methodName) ? "" : "." + methodName);
|
||||
bool wired = AlreadyWired.Contains(key);
|
||||
int arity = exec != null ? exec.GetParameters().Length : 0;
|
||||
behTsv.Append(t.Name).Append('\t')
|
||||
.Append(methodName).Append('\t')
|
||||
.Append(arity).Append('\t')
|
||||
.Append(needle).Append('\t')
|
||||
.Append(wired ? "1" : "0").Append('\t')
|
||||
.Append("BehPulse").AppendLine();
|
||||
|
||||
if (!wired && IsEventWorthyBeh(t.Name))
|
||||
{
|
||||
gaps++;
|
||||
gapsTsv.Append("BehPulse").Append('\t')
|
||||
.Append(t.Name).Append('\t')
|
||||
.Append(methodName).Append('\t')
|
||||
.Append(BehSoft(t.Name) ? "Soft" : "Yes").Append('\t')
|
||||
.Append(BehSoft(t.Name) ? "false" : "true").Append('\t')
|
||||
.Append("0").Append('\t')
|
||||
.Append("unwired_beh").AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < LibraryFields.Length; i++)
|
||||
{
|
||||
string field = LibraryFields[i];
|
||||
List<string> ids = EnumerateLibraryIds(field);
|
||||
libraries++;
|
||||
libraryAssets += ids.Count;
|
||||
string sample = "";
|
||||
int sampleN = Math.Min(5, ids.Count);
|
||||
for (int s = 0; s < sampleN; s++)
|
||||
{
|
||||
if (s > 0)
|
||||
{
|
||||
sample += ",";
|
||||
}
|
||||
|
||||
sample += ids[s];
|
||||
}
|
||||
|
||||
// Inventory is complete when a live catalog, activity chip, or WorldLog owns the domain.
|
||||
// CreatesInterest dials live in catalogs / dossier - not as open mutation gaps.
|
||||
libTsv.Append(field).Append('\t')
|
||||
.Append(ids.Count).Append('\t')
|
||||
.Append(Escape(sample)).Append('\t')
|
||||
.Append(LibraryCoverageNote(field)).AppendLine();
|
||||
|
||||
// Full id lists for priority event catalogs.
|
||||
if (!string.IsNullOrEmpty(outputDirectory)
|
||||
&& (field == "plots_library"
|
||||
|| field == "plot_category_library"
|
||||
|| field == "era_library"
|
||||
|| field == "book_types"
|
||||
|| field == "traits"
|
||||
|| field == "buildings"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = new StringBuilder();
|
||||
full.AppendLine("id");
|
||||
for (int s = 0; s < ids.Count; s++)
|
||||
{
|
||||
full.AppendLine(ids[s]);
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(outputDirectory, "library-" + field + ".tsv"),
|
||||
full.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// World / MapBox live manager fields
|
||||
AppendWorldFields(gapsTsv, managersTsv, ref gaps, ref managerMethods);
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
File.WriteAllText(Path.Combine(outputDirectory, "mutation_managers.tsv"), managersTsv.ToString());
|
||||
File.WriteAllText(Path.Combine(outputDirectory, "mutation_actor.tsv"), actorTsv.ToString());
|
||||
File.WriteAllText(Path.Combine(outputDirectory, "mutation_beh.tsv"), behTsv.ToString());
|
||||
File.WriteAllText(Path.Combine(outputDirectory, "mutation_libraries.tsv"), libTsv.ToString());
|
||||
File.WriteAllText(Path.Combine(outputDirectory, "mutation_gaps.tsv"), gapsTsv.ToString());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[IdleSpectator] mutation discovery write failed: " + ex.Message);
|
||||
}
|
||||
|
||||
return new MutationDiscoveryResult
|
||||
{
|
||||
Passed = managerCount > 0 && libraries > 0,
|
||||
Managers = managerCount,
|
||||
ManagerMethods = managerMethods,
|
||||
ActorMethods = actorMethods,
|
||||
BehTypes = behTypes,
|
||||
Libraries = libraries,
|
||||
LibraryAssets = libraryAssets,
|
||||
Gaps = gaps,
|
||||
Detail =
|
||||
$"managers={managerCount} managerMethods={managerMethods} actorMethods={actorMethods} "
|
||||
+ $"beh={behTypes} libraries={libraries} libraryAssets={libraryAssets} gaps={gaps}"
|
||||
};
|
||||
}
|
||||
|
||||
private static void AppendWorldFields(
|
||||
StringBuilder gapsTsv,
|
||||
StringBuilder managersTsv,
|
||||
ref int gaps,
|
||||
ref int managerMethods)
|
||||
{
|
||||
object world = null;
|
||||
try
|
||||
{
|
||||
world = World.world;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (world == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] fields =
|
||||
{
|
||||
"plots", "wars", "alliances", "clans", "cultures", "religions", "languages",
|
||||
"families", "armies", "books", "buildings", "items", "subspecies", "cities",
|
||||
"kingdoms", "diplomacy", "era_manager", "earthquake_manager", "units"
|
||||
};
|
||||
|
||||
Type worldType = world.GetType();
|
||||
for (int i = 0; i < fields.Length; i++)
|
||||
{
|
||||
string name = fields[i];
|
||||
object value = null;
|
||||
try
|
||||
{
|
||||
FieldInfo f = worldType.GetField(name);
|
||||
PropertyInfo p = worldType.GetProperty(name);
|
||||
value = f != null ? f.GetValue(world) : p?.GetValue(world, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
string typeName = value != null ? value.GetType().Name : "null";
|
||||
bool wired = name == "wars"
|
||||
|| name == "units"
|
||||
|| name == "subspecies"
|
||||
|| name == "plots"
|
||||
// Meta genesis Harmony covers new*; ongoing poll is optional fill.
|
||||
|| name == "alliances"
|
||||
|| name == "clans"
|
||||
|| name == "cultures"
|
||||
|| name == "religions"
|
||||
|| name == "languages"
|
||||
|| name == "families"
|
||||
|| name == "armies"
|
||||
|| name == "books"
|
||||
|| name == "cities"
|
||||
|| name == "kingdoms"
|
||||
|| name == "items"
|
||||
|| name == "buildings"
|
||||
|| name == "diplomacy";
|
||||
managersTsv.Append("World").Append('\t')
|
||||
.Append(name).Append('\t')
|
||||
.Append("0").Append('\t')
|
||||
.Append("0").Append('\t')
|
||||
.Append("PollState").Append('\t')
|
||||
.Append(wired ? "1" : "0").Append('\t')
|
||||
.Append(typeName).AppendLine();
|
||||
managerMethods++;
|
||||
|
||||
if (!wired && value != null)
|
||||
{
|
||||
gaps++;
|
||||
gapsTsv.Append("PollState").Append('\t')
|
||||
.Append("World").Append('\t')
|
||||
.Append(name).Append('\t')
|
||||
.Append("Yes").Append('\t')
|
||||
.Append("true").Append('\t')
|
||||
.Append("0").Append('\t')
|
||||
.Append("world_field=" + typeName).AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DumpTypeMethods(
|
||||
Type type,
|
||||
string[] needles,
|
||||
StringBuilder actorTsv,
|
||||
StringBuilder gapsTsv,
|
||||
ref int actorMethods,
|
||||
ref int gaps)
|
||||
{
|
||||
MethodInfo[] methods = SafeMethods(type);
|
||||
for (int i = 0; i < methods.Length; i++)
|
||||
{
|
||||
MethodInfo method = methods[i];
|
||||
if (method == null || method.IsSpecialName)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string needle = FirstMatch(method.Name, needles);
|
||||
if (string.IsNullOrEmpty(needle) && !MethodLooksMutating(method.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(needle))
|
||||
{
|
||||
needle = "mutate";
|
||||
}
|
||||
|
||||
actorMethods++;
|
||||
string key = type.Name + "." + method.Name;
|
||||
bool wired = AlreadyWired.Contains(key);
|
||||
actorTsv.Append(type.Name).Append('\t')
|
||||
.Append(method.Name).Append('\t')
|
||||
.Append(method.GetParameters().Length).Append('\t')
|
||||
.Append(method.IsStatic ? "1" : "0").Append('\t')
|
||||
.Append(needle).Append('\t')
|
||||
.Append(wired ? "1" : "0").Append('\t')
|
||||
.Append(Escape(method.ToString())).AppendLine();
|
||||
|
||||
if (!wired && IsEventWorthyActor(method.Name))
|
||||
{
|
||||
gaps++;
|
||||
gapsTsv.Append("ActorMutation").Append('\t')
|
||||
.Append(type.Name).Append('\t')
|
||||
.Append(method.Name).Append('\t')
|
||||
.Append("Yes").Append('\t')
|
||||
.Append(SuggestCreatesInterest("Actor", method.Name)).Append('\t')
|
||||
.Append("0").Append('\t')
|
||||
.Append("unwired_actor_mutation").AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DumpExtraActorRelationMethods(
|
||||
Type actorType,
|
||||
StringBuilder actorTsv,
|
||||
StringBuilder gapsTsv,
|
||||
ref int actorMethods,
|
||||
ref int gaps)
|
||||
{
|
||||
string[] extra =
|
||||
{
|
||||
"removeLover", "clearLover", "setLover", "getLover", "hasLover",
|
||||
"removeBestFriend", "clearBestFriend", "getBestFriend",
|
||||
"addChild", "removeChild", "makeBaby", "haveChild",
|
||||
"addTrait", "removeTrait", "setPlot", "leavePlot", "finishPlot"
|
||||
};
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
MethodInfo[] found = actorType.GetMethods(
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
for (int m = 0; m < found.Length; m++)
|
||||
{
|
||||
MethodInfo method = found[m];
|
||||
if (method == null || method.IsSpecialName)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool match = false;
|
||||
for (int i = 0; i < extra.Length; i++)
|
||||
{
|
||||
if (method.Name.Equals(extra[i], StringComparison.OrdinalIgnoreCase)
|
||||
|| method.Name.StartsWith(extra[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!match || !seen.Add(method.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
actorMethods++;
|
||||
string key = actorType.Name + "." + method.Name;
|
||||
bool wired = AlreadyWired.Contains(key);
|
||||
actorTsv.Append(actorType.Name).Append('\t')
|
||||
.Append(method.Name).Append('\t')
|
||||
.Append(method.GetParameters().Length).Append('\t')
|
||||
.Append(method.IsStatic ? "1" : "0").Append('\t')
|
||||
.Append("relation").Append('\t')
|
||||
.Append(wired ? "1" : "0").Append('\t')
|
||||
.Append("extra_relation_scan").AppendLine();
|
||||
|
||||
if (!wired && !method.Name.StartsWith("get", StringComparison.OrdinalIgnoreCase)
|
||||
&& !method.Name.StartsWith("has", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
gaps++;
|
||||
gapsTsv.Append("ActorMutation").Append('\t')
|
||||
.Append(actorType.Name).Append('\t')
|
||||
.Append(method.Name).Append('\t')
|
||||
.Append("Yes").Append('\t')
|
||||
.Append("true").Append('\t')
|
||||
.Append("0").Append('\t')
|
||||
.Append("relation_lifecycle").AppendLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> EnumerateLibraryIds(string fieldName)
|
||||
{
|
||||
var ids = new List<string>();
|
||||
try
|
||||
{
|
||||
object lib = typeof(AssetManager).GetField(fieldName)?.GetValue(null);
|
||||
if (lib == null)
|
||||
{
|
||||
return ids;
|
||||
}
|
||||
|
||||
object listObj = lib.GetType().GetField("list")?.GetValue(lib)
|
||||
?? lib.GetType().GetProperty("list")?.GetValue(lib, null);
|
||||
System.Collections.IList list = listObj as System.Collections.IList;
|
||||
if (list == null)
|
||||
{
|
||||
return ids;
|
||||
}
|
||||
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
object asset = list[i];
|
||||
object idObj = asset?.GetType().GetField("id")?.GetValue(asset)
|
||||
?? asset?.GetType().GetProperty("id")?.GetValue(asset, null);
|
||||
string id = idObj as string;
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Library missing in this build.
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static MethodInfo[] SafeMethods(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
return type.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<MethodInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsManagerish(string name)
|
||||
{
|
||||
for (int i = 0; i < ManagerNameNeedles.Length; i++)
|
||||
{
|
||||
if (name.IndexOf(ManagerNameNeedles[i], StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MethodLooksMutating(string name)
|
||||
{
|
||||
string n = name ?? "";
|
||||
for (int i = 0; i < MethodPrefixNeedles.Length; i++)
|
||||
{
|
||||
if (n.StartsWith(MethodPrefixNeedles[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesAny(string value, string[] needles)
|
||||
{
|
||||
return !string.IsNullOrEmpty(FirstMatch(value, needles));
|
||||
}
|
||||
|
||||
private static string FirstMatch(string value, string[] needles)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || needles == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
for (int i = 0; i < needles.Length; i++)
|
||||
{
|
||||
if (value.IndexOf(needles[i], StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return needles[i];
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string GuessManagerKind(string typeName, string methodName)
|
||||
{
|
||||
if (methodName.StartsWith("new", StringComparison.OrdinalIgnoreCase)
|
||||
|| methodName.StartsWith("create", StringComparison.OrdinalIgnoreCase)
|
||||
|| methodName.StartsWith("generate", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "ManagerGenesis";
|
||||
}
|
||||
|
||||
if (methodName.StartsWith("remove", StringComparison.OrdinalIgnoreCase)
|
||||
|| methodName.StartsWith("destroy", StringComparison.OrdinalIgnoreCase)
|
||||
|| methodName.StartsWith("finish", StringComparison.OrdinalIgnoreCase)
|
||||
|| methodName.StartsWith("cancel", StringComparison.OrdinalIgnoreCase)
|
||||
|| methodName.StartsWith("end", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "ManagerEnd";
|
||||
}
|
||||
|
||||
return "ManagerMutation";
|
||||
}
|
||||
|
||||
private static string LibraryCoverageNote(string field)
|
||||
{
|
||||
switch (field ?? "")
|
||||
{
|
||||
case "architecture_library":
|
||||
case "city_build_orders":
|
||||
return "covered_by_building_activity";
|
||||
case "kingdoms_traits":
|
||||
return "covered_by_trait_meta_feeds";
|
||||
case "items_modifiers":
|
||||
return "covered_by_item_feed";
|
||||
case "combat_action_library":
|
||||
return "covered_by_spell_combat_feeds";
|
||||
case "loyalty_library":
|
||||
case "opinion_library":
|
||||
case "knowledge_library":
|
||||
case "communication_library":
|
||||
case "story_library":
|
||||
return "covered_by_worldlog_happiness";
|
||||
case "citizen_job_library":
|
||||
case "professions":
|
||||
case "personalities":
|
||||
return "covered_by_activity_dossier";
|
||||
default:
|
||||
return "partially_or_fully_wired";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsEventWorthyManager(string typeName, string methodName)
|
||||
{
|
||||
string t = typeName ?? "";
|
||||
string m = methodName ?? "";
|
||||
|
||||
// Persistence / teardown / UI / FX / transport bookkeeping - not spectator moments.
|
||||
if (m.StartsWith("clear", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("load", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("setup", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("setDirty", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.IndexOf("startCollectHistoryData", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("setAges", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("setAgeTo", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("setCurrentSlot", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("setDefaultAges", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.Equals("setDirty", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("removeObject", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("createNewUnit", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("ClearAllDisposed", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("clearLastYearStats", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("clearAllCitiesLists", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("setAllLinksDirty", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("clearAll", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("clearAndClose", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.IndexOf("addRandomTraitFromBiome", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("generateModsFor", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("newDiplomacyTick", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("removeRelationsFor", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("removeDeadUnits", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.StartsWith("setLanguage", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.IndexOf("TextField", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("LocalizedText", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("Worldnet", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("createDB", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("loadDB", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("loadAutoTester", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("addAction", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (t.IndexOf("LocalizedText", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("UnitText", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("MapChunk", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("TileManager", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("DBManager", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("InApp", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("DelayedActions", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("TaxiManager", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Projectile", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("DropManager", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("ResourceThrow", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("EffectDrag", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("SignalManager", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("CorruptedTrees", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.StartsWith("SystemManager", StringComparison.OrdinalIgnoreCase)
|
||||
|| t.StartsWith("SimSystemManager", StringComparison.OrdinalIgnoreCase)
|
||||
|| t.StartsWith("CoreSystemManager", StringComparison.OrdinalIgnoreCase)
|
||||
|| t.StartsWith("MetaSystemManager", StringComparison.OrdinalIgnoreCase)
|
||||
|| t.StartsWith("BaseSystemManager", StringComparison.OrdinalIgnoreCase)
|
||||
|| t.IndexOf("Debug", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("UI", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Save", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Upload", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Tooltip", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Nameplate", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Locale", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| (t.IndexOf("Asset", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
&& t.IndexOf("Manager", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
&& t != "AssetManager"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsEventWorthyActor(string methodName)
|
||||
{
|
||||
string m = methodName ?? "";
|
||||
// Pure queries / counters / trait cache churn - not camera events.
|
||||
if (m.StartsWith("has", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("is", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("get", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("calc", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("check", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("count", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("sort", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("increase", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("decrease", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("u2_", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.StartsWith("inOwn", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.IndexOf("Nutrition", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.Equals("clearTraits", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("clearTraitCache", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("clearHomeBuilding", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("setHomeBuilding", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("setClan", StringComparison.OrdinalIgnoreCase)
|
||||
|| m.Equals("setArmy", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return m.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("friend", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("child", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("birth", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("baby", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("trait", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("plot", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("die", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| m.IndexOf("kill", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static bool IsEventWorthyBeh(string typeName)
|
||||
{
|
||||
// Beh pulses are covered by Actor.setTask → OnActivityNote / warm activity scanner.
|
||||
// Only leave a gap when a Beh is a distinct narrative feed not on the activity path.
|
||||
_ = typeName;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool BehSoft(string typeName)
|
||||
{
|
||||
string t = typeName ?? "";
|
||||
return t.IndexOf("Social", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Talk", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Check", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf("Follow", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static string SuggestCreatesInterest(string typeName, string methodName)
|
||||
{
|
||||
string blob = (typeName ?? "") + "." + (methodName ?? "");
|
||||
if (blob.IndexOf("War", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| blob.IndexOf("Plot", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| blob.IndexOf("Age", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| blob.IndexOf("die", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| blob.IndexOf("kill", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| blob.IndexOf("lover", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| blob.IndexOf("destroy", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| blob.IndexOf("new", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return "true";
|
||||
}
|
||||
|
||||
return "false";
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.Replace('\t', ' ').Replace('\n', ' ').Replace('\r', ' ');
|
||||
}
|
||||
}
|
||||
|
|
@ -212,11 +212,8 @@ public sealed class UnitDossier
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Story beat for the dossier reason row: what is happening in this shot.
|
||||
/// No tier badges, no job append, no nametag repeats.
|
||||
/// Fill / ambient scenes with no sharper beat leave the reason empty (row hidden).
|
||||
/// Follow-up (not blocking): expand event/character tags and optional beatPhrases
|
||||
/// in scoring-model.json once playtests show thin beats.
|
||||
/// Orange dossier reason: tracked event that earned this shot.
|
||||
/// Empty when no owned active candidate (never invent from live fighting/job).
|
||||
/// </summary>
|
||||
private static string BuildStoryBeat(UnitDossier d, string watchLabel, Actor actor)
|
||||
{
|
||||
|
|
@ -225,169 +222,50 @@ public sealed class UnitDossier
|
|||
return HarnessReasonOverride.Trim();
|
||||
}
|
||||
|
||||
InterestCandidate scene = MatchingScene(actor, d);
|
||||
return OwnedEventReason(actor, d);
|
||||
}
|
||||
|
||||
string combat = CombatBeat(d, scene);
|
||||
if (!string.IsNullOrEmpty(combat))
|
||||
/// <summary>
|
||||
/// Public so caption can refresh reason when the director leaves active dwell
|
||||
/// (quiet_grace) without rebuilding the whole dossier.
|
||||
/// </summary>
|
||||
public static string OwnedEventReason(Actor actor, UnitDossier dossier = null)
|
||||
{
|
||||
if (actor == null || !actor.isAlive())
|
||||
{
|
||||
return combat;
|
||||
return "";
|
||||
}
|
||||
|
||||
string griefOrLife = HappinessBeat(scene, d);
|
||||
if (!string.IsNullOrEmpty(griefOrLife))
|
||||
InterestCandidate scene = InterestDirector.TryGetOwnedReasonCandidate(actor);
|
||||
if (scene == null)
|
||||
{
|
||||
return griefOrLife;
|
||||
return "";
|
||||
}
|
||||
|
||||
// Ambient filler: no story beat - hide the reason row.
|
||||
if (scene != null && InterestScoring.IsFillScore(scene.TotalScore)
|
||||
// Character-fill vignettes: hide reason (task chip shows live activity).
|
||||
if (scene.LeadKind == InterestLeadKind.CharacterLed
|
||||
&& !InterestScoring.IsCombatAction(scene))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string fromScene = SceneLabelBeat(scene, d);
|
||||
if (!string.IsNullOrEmpty(fromScene))
|
||||
UnitDossier d = dossier;
|
||||
if (d == null)
|
||||
{
|
||||
return fromScene;
|
||||
}
|
||||
|
||||
string fromWatch = EventLabelBeat(watchLabel, d);
|
||||
if (!string.IsNullOrEmpty(fromWatch))
|
||||
{
|
||||
return fromWatch;
|
||||
}
|
||||
|
||||
if (d.IsSpectacle)
|
||||
{
|
||||
return "Spectacle";
|
||||
}
|
||||
|
||||
string activity = ActivityBeat(actor);
|
||||
if (!string.IsNullOrEmpty(activity))
|
||||
{
|
||||
return activity;
|
||||
}
|
||||
|
||||
if (d.IsLoneSpecies)
|
||||
{
|
||||
return "Last of its kind";
|
||||
}
|
||||
|
||||
// Discovery / new species from watch label leftovers.
|
||||
string discovery = CleanBeat(ShortenWatchLabel(watchLabel, d));
|
||||
if (!string.IsNullOrEmpty(discovery)
|
||||
&& discovery.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return "New species";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static InterestCandidate MatchingScene(Actor actor, UnitDossier d)
|
||||
{
|
||||
InterestCandidate scene = InterestDirector.CurrentCandidate;
|
||||
if (scene == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scene.FollowUnit == actor)
|
||||
{
|
||||
return scene;
|
||||
}
|
||||
|
||||
if (actor != null && scene.SubjectId == d.UnitId)
|
||||
{
|
||||
return scene;
|
||||
}
|
||||
|
||||
if (scene.RelatedUnit == actor)
|
||||
{
|
||||
return scene;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string CombatBeat(UnitDossier d, InterestCandidate scene)
|
||||
{
|
||||
bool fighting = d.IsFighting
|
||||
|| (scene != null && InterestScoring.IsCombatAction(scene));
|
||||
if (!fighting)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
int fighters = scene != null ? scene.ParticipantCount : 0;
|
||||
int notables = scene != null ? scene.NotableParticipantCount : 0;
|
||||
if (fighters <= 0 && d.IsFighting)
|
||||
{
|
||||
fighters = 1;
|
||||
notables = (d.IsKing || d.IsFavorite || d.IsCityLeader) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (fighters <= 2 && notables >= 2)
|
||||
{
|
||||
return "King duel";
|
||||
}
|
||||
|
||||
if (fighters >= 4)
|
||||
{
|
||||
return "Clash · " + fighters + " fighting";
|
||||
}
|
||||
|
||||
if (fighters >= 2)
|
||||
{
|
||||
return "Fighting · " + fighters;
|
||||
}
|
||||
|
||||
return "Fighting";
|
||||
}
|
||||
|
||||
private static string HappinessBeat(InterestCandidate scene, UnitDossier d)
|
||||
{
|
||||
if (scene == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string label = scene.Label ?? "";
|
||||
if (label.StartsWith("Slain here", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CleanBeat(label);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(scene.HappinessEffectId))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
string id = scene.HappinessEffectId;
|
||||
if (id.StartsWith("death_", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string grief = EventLabelBeat(label, d);
|
||||
if (!string.IsNullOrEmpty(grief))
|
||||
d = new UnitDossier();
|
||||
try
|
||||
{
|
||||
return grief;
|
||||
d.UnitId = actor.getID();
|
||||
d.Name = SafeName(actor);
|
||||
d.SpeciesId = actor.asset != null ? actor.asset.id : "";
|
||||
}
|
||||
|
||||
if (id.IndexOf("child", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
catch
|
||||
{
|
||||
return "Mourning a child";
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (id.IndexOf("lover", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return "Mourning a lover";
|
||||
}
|
||||
|
||||
return "Grief";
|
||||
}
|
||||
|
||||
string happy = EventLabelBeat(label, d);
|
||||
return happy ?? "";
|
||||
return SceneLabelBeat(scene, d) ?? "";
|
||||
}
|
||||
|
||||
private static string SceneLabelBeat(InterestCandidate scene, UnitDossier d)
|
||||
|
|
@ -397,7 +275,206 @@ public sealed class UnitDossier
|
|||
return "";
|
||||
}
|
||||
|
||||
return EventLabelBeat(scene.Label, d);
|
||||
string beat = EventLabelBeat(scene.Label, d);
|
||||
if (string.IsNullOrEmpty(beat))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
// Ownership: never let the reason name a living stranger (not subject/related).
|
||||
if (ReasonNamesStranger(beat, scene))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
// Ownership: reject beats that lead with someone else's proper name.
|
||||
if (LeadsWithForeignName(beat, scene))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return beat;
|
||||
}
|
||||
|
||||
private static bool LeadsWithForeignName(string reason, InterestCandidate scene)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason) || scene == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string first = FirstToken(reason);
|
||||
if (string.IsNullOrEmpty(first) || IsOwnedBeatKeyword(first))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
|
||||
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
|
||||
if (!string.IsNullOrEmpty(subjectName)
|
||||
&& first.Equals(FirstToken(subjectName), System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(relatedName)
|
||||
&& first.Equals(FirstToken(relatedName), System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Leading token looks like a proper name that is not the owned subject/related.
|
||||
return first.Length >= 3 && char.IsLetter(first[0]);
|
||||
}
|
||||
|
||||
private static string FirstToken(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
while (i < text.Length && (char.IsWhiteSpace(text[i]) || text[i] == ':' || text[i] == '·'))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
int start = i;
|
||||
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_' || text[i] == '-'))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
return start < i ? text.Substring(start, i - start) : "";
|
||||
}
|
||||
|
||||
private static bool IsOwnedBeatKeyword(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (token.ToLowerInvariant())
|
||||
{
|
||||
case "fighting":
|
||||
case "war":
|
||||
case "battle":
|
||||
case "clash":
|
||||
case "grief":
|
||||
case "mourning":
|
||||
case "mourn":
|
||||
case "slain":
|
||||
case "lovers":
|
||||
case "lover":
|
||||
case "new":
|
||||
case "baby":
|
||||
case "book":
|
||||
case "building":
|
||||
case "boat":
|
||||
case "meta":
|
||||
case "seeking":
|
||||
case "joins":
|
||||
case "leaves":
|
||||
case "family":
|
||||
case "starts":
|
||||
case "gathers":
|
||||
case "damages":
|
||||
case "consumes":
|
||||
case "spectacle":
|
||||
case "in":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ReasonNamesStranger(string reason, InterestCandidate scene)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason) || scene == null || World.world?.units == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string subjectName = EventFeedUtil.SafeName(scene.FollowUnit);
|
||||
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
|
||||
try
|
||||
{
|
||||
foreach (Actor unit in World.world.units)
|
||||
{
|
||||
if (unit == null || !unit.isAlive())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (unit == scene.FollowUnit || unit == scene.RelatedUnit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string name = EventFeedUtil.SafeName(unit);
|
||||
// Length 3 catches English crumbs in reasons ("egg", "war", "duel").
|
||||
if (string.IsNullOrEmpty(name) || name.Length < 4)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(subjectName)
|
||||
&& name.Equals(subjectName, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(relatedName)
|
||||
&& name.Equals(relatedName, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ContainsWholeName(reason, name))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="name"/> appears as a whole token in <paramref name="reason"/>.</summary>
|
||||
private static bool ContainsWholeName(string reason, string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(reason) || string.IsNullOrEmpty(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int start = 0;
|
||||
while (start <= reason.Length - name.Length)
|
||||
{
|
||||
int idx = reason.IndexOf(name, start, System.StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool leftOk = idx == 0 || !char.IsLetterOrDigit(reason[idx - 1]);
|
||||
int end = idx + name.Length;
|
||||
bool rightOk = end >= reason.Length || !char.IsLetterOrDigit(reason[end]);
|
||||
if (leftOk && rightOk)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
start = idx + 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string EventLabelBeat(string label, UnitDossier d)
|
||||
|
|
@ -417,61 +494,13 @@ public sealed class UnitDossier
|
|||
}
|
||||
}
|
||||
|
||||
// Keep war / fight story text with context (do not strip after first colon).
|
||||
if (s.StartsWith("War", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("Fighting", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("Clash", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("Grief", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("Mourn", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| s.StartsWith("Slain", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (IsIdentityWhy(s, d))
|
||||
{
|
||||
// "Fighting: Name" → Fighting
|
||||
int colon = s.IndexOf(':');
|
||||
if (colon > 0)
|
||||
{
|
||||
string head = s.Substring(0, colon).Trim();
|
||||
return string.IsNullOrEmpty(head) ? "Fighting" : head;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return CleanBeat(s);
|
||||
}
|
||||
|
||||
string shortened = ShortenWatchLabel(s, d);
|
||||
if (string.IsNullOrEmpty(shortened) || IsIdentityWhy(shortened, d))
|
||||
// EventReason sentences are dossier-ready - keep names; only drop identity crumbs.
|
||||
if (IsIdentityWhy(s, d) || IsWeakFlavorBeat(s, d))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
// Reject weak singleton/species crumbs ("Lone beetle").
|
||||
if (IsWeakFlavorBeat(shortened, d))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return shortened;
|
||||
}
|
||||
|
||||
private static string ActivityBeat(Actor actor)
|
||||
{
|
||||
if (actor == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
// Task/job already live on the nametag; only surface hot action here.
|
||||
ActivityBand band = ActivityInterestTable.Classify(actor);
|
||||
if (band == ActivityBand.Hot)
|
||||
{
|
||||
return "In action";
|
||||
}
|
||||
|
||||
return "";
|
||||
return CleanBeat(s);
|
||||
}
|
||||
|
||||
private static bool IsWeakFlavorBeat(string text, UnitDossier d)
|
||||
|
|
@ -539,6 +568,12 @@ public sealed class UnitDossier
|
|||
&& t.IndexOf("mourn", System.StringComparison.OrdinalIgnoreCase) < 0
|
||||
&& t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
// EventReason sentences lead with the subject name - keep them.
|
||||
if (LooksLikeEventSentence(t) || StartsWithSubjectVerbPhrase(t, d.Name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// "Isemward (human, 12 kills)" style still useful via kills - keep if kills mentioned.
|
||||
if (t.IndexOf("kill", System.StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
|
|
@ -551,6 +586,76 @@ public sealed class UnitDossier
|
|||
return IsRedundantWithHeadline(t, d);
|
||||
}
|
||||
|
||||
private static bool LooksLikeEventSentence(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string t = text;
|
||||
return t.IndexOf(" is ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" became ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" joins ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" leaves ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" forms ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" gains ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" loses ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" writes ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" founds ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" summons ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" begins ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" ends ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" burns ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" welcomes ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" parted ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" decides ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" casts ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" forges ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" eating ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" foraging ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" damaging ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" plotting ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" lovers ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" hatches ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" mates ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" appears ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" finishes ", System.StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| t.IndexOf(" bloodlust ", System.StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Name verb …" EventReason form (covers hatches/mates/appears without an "is" helper).
|
||||
/// Rejects identity crumbs like "Name (species, …)".
|
||||
/// </summary>
|
||||
private static bool StartsWithSubjectVerbPhrase(string text, string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!text.StartsWith(name, System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int i = name.Length;
|
||||
if (i >= text.Length || text[i] != ' ')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int verbStart = i + 1;
|
||||
if (verbStart >= text.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
char c = text[verbStart];
|
||||
return char.IsLetter(c) && c != '(';
|
||||
}
|
||||
|
||||
private static bool IsShownTraitName(UnitDossier d, string text)
|
||||
{
|
||||
if (d == null || string.IsNullOrEmpty(text))
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ public static class WatchCaption
|
|||
{
|
||||
if (interest != null)
|
||||
{
|
||||
// Location-only / no unit: tip text for logs; dossier has no owned subject reason.
|
||||
LastHeadline = CameraDirector.FormatWatchTip(interest);
|
||||
LastDetail = "";
|
||||
LastCaptionText = LastHeadline;
|
||||
|
|
@ -434,7 +435,9 @@ public static class WatchCaption
|
|||
return;
|
||||
}
|
||||
|
||||
SetFromActor(unit, interest?.Label);
|
||||
// Reason ownership comes from InterestDirector.TryGetOwnedReasonCandidate
|
||||
// (active dwell + owned unit), not from a sticky InterestEvent.Label.
|
||||
SetFromActor(unit, label: null);
|
||||
}
|
||||
|
||||
public static void SetFromActor(Actor actor, string label = null)
|
||||
|
|
@ -508,6 +511,7 @@ public static class WatchCaption
|
|||
|
||||
RefreshLivePortrait();
|
||||
RefreshLiveTask();
|
||||
RefreshOwnedReason();
|
||||
RefreshHistoryIfChanged();
|
||||
return;
|
||||
}
|
||||
|
|
@ -558,6 +562,7 @@ public static class WatchCaption
|
|||
|
||||
RefreshLivePortrait();
|
||||
RefreshLiveTask();
|
||||
RefreshOwnedReason();
|
||||
RefreshHistoryIfChanged();
|
||||
}
|
||||
|
||||
|
|
@ -626,6 +631,77 @@ public static class WatchCaption
|
|||
BringHeaderFront();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep orange reason in sync with director active dwell (clears in quiet_grace).
|
||||
/// </summary>
|
||||
private static void RefreshOwnedReason()
|
||||
{
|
||||
if (!_visible || _current == null || HasStatusBanner())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Actor actor = ResolveBoundLiveActor();
|
||||
if (actor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string next = UnitDossier.OwnedEventReason(actor, _current);
|
||||
string prev = _current.ReasonLine ?? "";
|
||||
if (next == prev)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_current.ReasonLine = next;
|
||||
LastDetail = _current.DetailLine ?? "";
|
||||
LastCaptionText = JoinCaptionLines(_current.Headline, next, _current.DetailLine);
|
||||
|
||||
bool hasReason = !string.IsNullOrEmpty(next);
|
||||
if (_reasonText != null)
|
||||
{
|
||||
_reasonText.text = hasReason ? next : "";
|
||||
_reasonText.gameObject.SetActive(hasReason);
|
||||
}
|
||||
|
||||
bool hasTask = !string.IsNullOrEmpty(_current.TaskText);
|
||||
int traits = _current.TopTraits != null ? _current.TopTraits.Count : 0;
|
||||
int hist = CountActiveHistorySlots();
|
||||
Relayout(_current.UnitId != 0, traits, hasTask, hasReason, hist);
|
||||
}
|
||||
|
||||
private static string JoinCaptionLines(string headline, string reason, string detail)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (!string.IsNullOrEmpty(headline))
|
||||
{
|
||||
sb.Append(headline);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(reason))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append(reason);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(detail))
|
||||
{
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
sb.Append('\n');
|
||||
}
|
||||
|
||||
sb.Append(detail);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep the nametag task chip in sync with live AI (focus snapshot can miss brief no-task gaps).
|
||||
/// Never clears the chip on empty AI gaps - only replaces text when a new task is present.
|
||||
|
|
|
|||
|
|
@ -87,23 +87,11 @@ public static class WorldActivityScanner
|
|||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
Actor actor = alive[i];
|
||||
ActivityBand band = ActivityInterestTable.Classify(actor);
|
||||
InterestLeadKind lead = InterestLeadKind.CharacterLed;
|
||||
// Actions outrank characters: combat/hot work are events; kings walking stay vignettes.
|
||||
if (band == ActivityBand.Hot || actor.has_attack_target)
|
||||
if (actor == null || !actor.isAlive() || !actor.has_attack_target)
|
||||
{
|
||||
lead = InterestLeadKind.EventLed;
|
||||
}
|
||||
else if (band >= ActivityBand.Warm)
|
||||
{
|
||||
lead = InterestLeadKind.EventLed;
|
||||
}
|
||||
else if (IsSpectacle(actor) || IsSingletonSpecies(actor, speciesCounts))
|
||||
{
|
||||
lead = InterestLeadKind.CharacterLed;
|
||||
continue;
|
||||
}
|
||||
|
||||
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
|
||||
long id = 0;
|
||||
try
|
||||
{
|
||||
|
|
@ -114,66 +102,59 @@ public static class WorldActivityScanner
|
|||
continue;
|
||||
}
|
||||
|
||||
string key = lead == InterestLeadKind.CharacterLed
|
||||
? "vignette:" + id + ":" + (actor.asset != null ? actor.asset.id : "unit")
|
||||
: "scan:evt:" + id;
|
||||
|
||||
string label = FormatUnitLabel(actor, speciesCounts);
|
||||
if (actor.has_attack_target)
|
||||
Actor foe = null;
|
||||
try
|
||||
{
|
||||
label = "Fighting: " + SafeName(actor);
|
||||
if (actor.attack_target != null && actor.attack_target.isAlive() && actor.attack_target.isActor())
|
||||
{
|
||||
foe = actor.attack_target.a;
|
||||
if (foe != null && !foe.isAlive())
|
||||
{
|
||||
foe = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (band == ActivityBand.Hot)
|
||||
catch
|
||||
{
|
||||
label = "In action";
|
||||
foe = null;
|
||||
}
|
||||
|
||||
bool combat = actor.has_attack_target || band == ActivityBand.Hot;
|
||||
int fighters = 1;
|
||||
int notables = InterestScoring.IsNotable(actor) ? 1 : 0;
|
||||
if (combat)
|
||||
{
|
||||
CountFightCluster(actor.current_position, 10f, out fighters, out notables, out _);
|
||||
fighters = Mathf.Max(1, fighters);
|
||||
}
|
||||
SplitActorScore(actor, speciesCounts, out float actionScore, out float charScore);
|
||||
CountFightCluster(actor.current_position, 10f, out int fighters, out int notables, out _);
|
||||
fighters = Mathf.Max(1, fighters);
|
||||
|
||||
string label = EventReason.Fight(actor, foe);
|
||||
var candidate = new InterestCandidate
|
||||
{
|
||||
Key = key,
|
||||
LeadKind = lead,
|
||||
Category = combat
|
||||
? "Combat"
|
||||
: (lead == InterestLeadKind.CharacterLed ? "CharacterVignette" : "Work"),
|
||||
Key = EventCatalog.Combat.Key(id),
|
||||
LeadKind = InterestLeadKind.EventLed,
|
||||
Category = "Combat",
|
||||
Source = "scanner",
|
||||
EventStrength = lead == InterestLeadKind.EventLed
|
||||
? actionScore
|
||||
: actionScore * InterestScoringConfig.W.characterLedEventMul,
|
||||
EventStrength = Mathf.Max(actionScore, EventCatalog.Combat.ScannerStrengthFloor),
|
||||
CharacterSignificance = charScore,
|
||||
VisualConfidence = band >= ActivityBand.Warm ? 0.7f : 0.4f,
|
||||
VisualConfidence = 0.7f,
|
||||
Position = actor.current_position,
|
||||
FollowUnit = actor,
|
||||
RelatedUnit = foe,
|
||||
SubjectId = id,
|
||||
RelatedId = foe != null ? EventFeedUtil.SafeId(foe) : 0,
|
||||
Label = label,
|
||||
Verb = actor.has_attack_target ? "fighting" : (band == ActivityBand.Hot ? "busy" : ""),
|
||||
Verb = "fighting",
|
||||
AssetId = actor.asset != null ? actor.asset.id : "scored_unit",
|
||||
SpeciesId = actor.asset != null ? actor.asset.id : "",
|
||||
CityKey = actor.city != null ? (actor.city.name ?? "") : "",
|
||||
KingdomKey = actor.kingdom != null ? (actor.kingdom.name ?? "") : "",
|
||||
ParticipantCount = combat ? fighters : 0,
|
||||
NotableParticipantCount = combat ? notables : 0,
|
||||
ParticipantCount = fighters,
|
||||
NotableParticipantCount = notables,
|
||||
CreatedAt = Time.unscaledTime,
|
||||
LastSeenAt = Time.unscaledTime,
|
||||
ExpiresAt = Time.unscaledTime + 18f,
|
||||
MinWatch = 2f,
|
||||
MaxWatch = 22f,
|
||||
Completion = combat
|
||||
? InterestCompletionKind.CombatActive
|
||||
: (lead == InterestLeadKind.EventLed
|
||||
? InterestCompletionKind.ActivityActive
|
||||
: InterestCompletionKind.CharacterVignette)
|
||||
ExpiresAt = Time.unscaledTime + EventCatalog.Combat.ScannerTtl,
|
||||
MinWatch = EventCatalog.Combat.MinWatch,
|
||||
MaxWatch = EventCatalog.Combat.MaxWatch,
|
||||
Completion = InterestCompletionKind.CombatActive
|
||||
};
|
||||
InterestScoring.ScoreCheap(candidate);
|
||||
InterestRegistry.Upsert(candidate);
|
||||
EventFeedUtil.RegisterCandidate(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,11 +239,7 @@ public static class WorldActivityScanner
|
|||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
string label = fighters >= 5 || deaths >= 6
|
||||
? $"Battle ({fighters} fighting, {deaths} fallen)"
|
||||
: fighters <= 2
|
||||
? $"Duel ({deaths} fallen)"
|
||||
: $"Skirmish ({fighters} fighting)";
|
||||
string label = EventReason.Battle(follow ?? FindNearestAliveUnit(pos, 14f), fighters);
|
||||
best = new InterestEvent
|
||||
{
|
||||
Score = score,
|
||||
|
|
@ -322,12 +299,13 @@ public static class WorldActivityScanner
|
|||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
Actor focus = follow ?? actor;
|
||||
best = new InterestEvent
|
||||
{
|
||||
Score = score,
|
||||
Position = actor.current_position,
|
||||
FollowUnit = follow ?? actor,
|
||||
Label = fighters <= 2 ? "Duel" : $"Fight ({fighters})",
|
||||
FollowUnit = focus,
|
||||
Label = EventReason.Battle(focus, fighters),
|
||||
CreatedAt = Time.unscaledTime,
|
||||
AssetId = "live_battle",
|
||||
ParticipantCount = fighters,
|
||||
|
|
@ -531,12 +509,31 @@ public static class WorldActivityScanner
|
|||
finalScore = Mathf.Max(finalScore, actionScore + charScore * rankChar);
|
||||
}
|
||||
|
||||
string label = "";
|
||||
if (best.has_attack_target)
|
||||
{
|
||||
Actor foe = null;
|
||||
try
|
||||
{
|
||||
if (best.attack_target != null && best.attack_target.isAlive() && best.attack_target.isActor())
|
||||
{
|
||||
foe = best.attack_target.a;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
foe = null;
|
||||
}
|
||||
|
||||
label = EventReason.Fight(best, foe);
|
||||
}
|
||||
|
||||
return new InterestEvent
|
||||
{
|
||||
Score = finalScore,
|
||||
Position = best.current_position,
|
||||
FollowUnit = best,
|
||||
Label = FormatUnitLabel(best, speciesCounts),
|
||||
Label = label,
|
||||
CreatedAt = Time.unscaledTime,
|
||||
AssetId = best.asset != null ? best.asset.id : "scored_unit",
|
||||
ParticipantCount = fighters,
|
||||
|
|
|
|||
216
IdleSpectator/WorldLogEventHarness.cs
Normal file
216
IdleSpectator/WorldLogEventHarness.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
public sealed class WorldLogEventAuditResult
|
||||
{
|
||||
public bool Passed;
|
||||
public string Detail = "";
|
||||
public int Total;
|
||||
public int MissingAuthored;
|
||||
public int OrphanAuthored;
|
||||
public int EmptyLabel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exhaustive live world_log_library audit for authored WorldLog event catalog coverage.
|
||||
/// </summary>
|
||||
public static class WorldLogEventHarness
|
||||
{
|
||||
public static WorldLogEventAuditResult RunAudit(string outputDirectory)
|
||||
{
|
||||
List<string> liveIds = ActivityAssetCatalog.EnumerateLiveWorldLogIds();
|
||||
var authored = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string id in EventCatalog.WorldLog.AuthoredIds)
|
||||
{
|
||||
authored.Add(id);
|
||||
}
|
||||
|
||||
int missingAuthored = 0;
|
||||
int orphanAuthored = 0;
|
||||
int emptyLabel = 0;
|
||||
var tsv = new StringBuilder();
|
||||
tsv.AppendLine(
|
||||
"id\tgroup\tlocale\tpath_icon\tdisaster_link\tauthored\tevent_strength\tcategory\tcreates_interest\tlabel_sample\tnotes");
|
||||
|
||||
var liveSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < liveIds.Count; i++)
|
||||
{
|
||||
string id = liveIds[i];
|
||||
liveSet.Add(id);
|
||||
WorldLogAsset asset = ActivityAssetCatalog.TryGetWorldLogAsset(id);
|
||||
bool hasAuthored = EventCatalog.WorldLog.HasAuthored(id);
|
||||
WorldLogEventEntry entry = EventCatalog.WorldLog.GetOrFallback(id);
|
||||
if (!hasAuthored || entry.IsFallback)
|
||||
{
|
||||
missingAuthored++;
|
||||
}
|
||||
|
||||
string group = ReadStringField(asset, "group_id")
|
||||
?? ReadStringField(asset, "history_group")
|
||||
?? ReadStringField(asset, "group")
|
||||
?? "";
|
||||
string locale = "";
|
||||
try
|
||||
{
|
||||
locale = asset != null ? (asset.getLocaleID() ?? "") : "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
locale = ReadStringField(asset, "path_locale") ?? "";
|
||||
}
|
||||
|
||||
string pathIcon = ReadStringField(asset, "path_icon") ?? "";
|
||||
string disasterLink = ReadStringField(asset, "disaster_id")
|
||||
?? ReadStringField(asset, "linked_disaster")
|
||||
?? "";
|
||||
if (string.IsNullOrEmpty(disasterLink) && id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
disasterLink = "id_hint";
|
||||
}
|
||||
|
||||
string labelSample = entry.MakeLabel("Alpha", "Beta");
|
||||
if (string.IsNullOrEmpty(labelSample))
|
||||
{
|
||||
emptyLabel++;
|
||||
}
|
||||
|
||||
string notes = "ok";
|
||||
if (!hasAuthored || entry.IsFallback)
|
||||
{
|
||||
notes = "missing_authored";
|
||||
}
|
||||
else if (string.IsNullOrEmpty(labelSample))
|
||||
{
|
||||
notes = "empty_label";
|
||||
}
|
||||
|
||||
tsv.Append(id).Append('\t')
|
||||
.Append(Escape(group)).Append('\t')
|
||||
.Append(Escape(locale)).Append('\t')
|
||||
.Append(Escape(pathIcon)).Append('\t')
|
||||
.Append(Escape(disasterLink)).Append('\t')
|
||||
.Append(hasAuthored ? "1" : "0").Append('\t')
|
||||
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
|
||||
.Append(Escape(entry.Category)).Append('\t')
|
||||
.Append(entry.CreatesInterest ? "1" : "0").Append('\t')
|
||||
.Append(Escape(labelSample)).Append('\t')
|
||||
.Append(notes).AppendLine();
|
||||
}
|
||||
|
||||
foreach (string id in authored)
|
||||
{
|
||||
if (!liveSet.Contains(id))
|
||||
{
|
||||
orphanAuthored++;
|
||||
tsv.Append(id).Append("\t\t\t\t\t1\t0\t\t0\t\torphan_authored").AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
File.WriteAllText(
|
||||
Path.Combine(outputDirectory, "world-log-event-audit.tsv"),
|
||||
tsv.ToString());
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disk may be unavailable in some harness contexts.
|
||||
}
|
||||
|
||||
bool passed = missingAuthored == 0 && orphanAuthored == 0 && emptyLabel == 0;
|
||||
return new WorldLogEventAuditResult
|
||||
{
|
||||
Passed = passed,
|
||||
Total = liveIds.Count,
|
||||
MissingAuthored = missingAuthored,
|
||||
OrphanAuthored = orphanAuthored,
|
||||
EmptyLabel = emptyLabel,
|
||||
Detail =
|
||||
$"total={liveIds.Count} missingAuthored={missingAuthored} orphanAuthored={orphanAuthored} "
|
||||
+ $"emptyLabel={emptyLabel}"
|
||||
};
|
||||
}
|
||||
|
||||
public static string DumpDisasterWarInventory(string outputDirectory)
|
||||
{
|
||||
var tsv = new StringBuilder();
|
||||
tsv.AppendLine("kind\tid\tnotes");
|
||||
List<string> disasters = ActivityAssetCatalog.EnumerateLiveDisasterIds();
|
||||
for (int i = 0; i < disasters.Count; i++)
|
||||
{
|
||||
tsv.Append("disaster\t").Append(disasters[i]).Append("\tok").AppendLine();
|
||||
}
|
||||
|
||||
List<string> warTypes = ActivityAssetCatalog.EnumerateLiveWarTypeIds();
|
||||
for (int i = 0; i < warTypes.Count; i++)
|
||||
{
|
||||
tsv.Append("war_type\t").Append(warTypes[i]).Append("\tok").AppendLine();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
File.WriteAllText(
|
||||
Path.Combine(outputDirectory, "disaster-war-inventory.tsv"),
|
||||
tsv.ToString());
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return $"disasters={disasters.Count} war_types={warTypes.Count}";
|
||||
}
|
||||
|
||||
private static string ReadStringField(object asset, string fieldName)
|
||||
{
|
||||
if (asset == null || string.IsNullOrEmpty(fieldName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
FieldInfo field = asset.GetType().GetField(fieldName);
|
||||
if (field != null)
|
||||
{
|
||||
return field.GetValue(asset) as string;
|
||||
}
|
||||
|
||||
PropertyInfo prop = asset.GetType().GetProperty(fieldName);
|
||||
if (prop != null)
|
||||
{
|
||||
return prop.GetValue(asset, null) as string;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.Replace('\t', ' ').Replace('\n', ' ').Replace('\r', ' ');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +1,21 @@
|
|||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace IdleSpectator;
|
||||
|
||||
/// <summary>
|
||||
/// WorldLog asset ids → EventStrength seeds (score-native; no InterestTier).
|
||||
/// Compatibility facade over <see cref="EventCatalog.WorldLog"/>. Prefer the catalog directly.
|
||||
/// </summary>
|
||||
public static class WorldLogInterestTable
|
||||
{
|
||||
private static readonly Dictionary<string, float> EventSeeds = new Dictionary<string, float>
|
||||
{
|
||||
// World-shaping
|
||||
["diplomacy_war_started"] = 100f,
|
||||
["total_war_started"] = 100f,
|
||||
["kingdom_destroyed"] = 100f,
|
||||
["kingdom_shattered"] = 98f,
|
||||
["kingdom_fractured"] = 95f,
|
||||
["city_destroyed"] = 92f,
|
||||
["log_city_revolted"] = 90f,
|
||||
|
||||
// Leadership / politics / favorites / founding
|
||||
["kingdom_new"] = 75f,
|
||||
["city_new"] = 72f,
|
||||
["king_new"] = 78f,
|
||||
["king_dead"] = 80f,
|
||||
["king_killed"] = 85f,
|
||||
["king_fled_capital"] = 70f,
|
||||
["king_fled_city"] = 68f,
|
||||
["king_left"] = 65f,
|
||||
["alliance_new"] = 70f,
|
||||
["alliance_dissolved"] = 68f,
|
||||
["diplomacy_war_ended"] = 72f,
|
||||
["favorite_dead"] = 82f,
|
||||
["favorite_killed"] = 85f,
|
||||
["kingdom_royal_clan_new"] = 74f,
|
||||
["kingdom_royal_clan_changed"] = 72f,
|
||||
["kingdom_royal_clan_dead"] = 76f,
|
||||
|
||||
// Biosphere footnotes
|
||||
["race_dead"] = 40f
|
||||
};
|
||||
|
||||
public static bool TryGetEventStrength(string assetId, out float eventStrength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetId))
|
||||
{
|
||||
eventStrength = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (EventSeeds.TryGetValue(assetId, out eventStrength))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (assetId.Contains("disaster") || assetId.StartsWith("worldlog_disaster"))
|
||||
{
|
||||
eventStrength = 95f;
|
||||
return true;
|
||||
}
|
||||
|
||||
eventStrength = 0f;
|
||||
return false;
|
||||
return EventCatalog.WorldLog.TryGetEventStrength(assetId, out eventStrength);
|
||||
}
|
||||
|
||||
public static string MakeLabel(WorldLogMessage message)
|
||||
{
|
||||
string id = message.asset_id ?? "event";
|
||||
string a = message.special1;
|
||||
string b = message.special2;
|
||||
|
||||
switch (id)
|
||||
{
|
||||
case "diplomacy_war_started":
|
||||
return $"War: {a} vs {b}";
|
||||
case "total_war_started":
|
||||
return $"Total war: {a}";
|
||||
case "kingdom_destroyed":
|
||||
return $"Kingdom fell: {a}";
|
||||
case "kingdom_shattered":
|
||||
return $"Kingdom shattered: {a}";
|
||||
case "kingdom_fractured":
|
||||
return $"Kingdom fractured: {a}";
|
||||
case "city_destroyed":
|
||||
return $"City destroyed: {a}";
|
||||
case "log_city_revolted":
|
||||
return $"Revolt: {a}";
|
||||
case "kingdom_new":
|
||||
return $"New kingdom: {a}";
|
||||
case "city_new":
|
||||
return $"New city: {a}";
|
||||
case "king_new":
|
||||
return $"New king: {b} ({a})";
|
||||
case "king_dead":
|
||||
return $"King died: {b} ({a})";
|
||||
case "king_killed":
|
||||
return $"King killed: {b} ({a})";
|
||||
case "king_fled_capital":
|
||||
return $"King fled capital: {b}";
|
||||
case "king_fled_city":
|
||||
return $"King fled city: {b}";
|
||||
case "alliance_new":
|
||||
return $"Alliance: {a}";
|
||||
case "alliance_dissolved":
|
||||
return $"Alliance dissolved: {a}";
|
||||
case "diplomacy_war_ended":
|
||||
return $"War ended: {a}";
|
||||
case "kingdom_royal_clan_new":
|
||||
case "kingdom_royal_clan_changed":
|
||||
return $"Royal clan: {a}";
|
||||
case "kingdom_royal_clan_dead":
|
||||
return $"Royal clan ended: {a}";
|
||||
case "race_dead":
|
||||
return $"Species extinct: {a}";
|
||||
case "favorite_dead":
|
||||
case "favorite_killed":
|
||||
return $"Favorite fell: {a}";
|
||||
default:
|
||||
if (!string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b))
|
||||
{
|
||||
return $"{id}: {a} / {b}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(a))
|
||||
{
|
||||
return $"{id}: {a}";
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
return EventCatalog.WorldLog.MakeLabel(message);
|
||||
}
|
||||
|
||||
public static IEnumerable<string> AuthoredIds => EventCatalog.WorldLog.AuthoredIds;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "IdleSpectator",
|
||||
"author": "dazed",
|
||||
"version": "0.16.8",
|
||||
"version": "0.21.3",
|
||||
"description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.",
|
||||
"GUID": "com.dazed.idlespectator"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,17 @@
|
|||
"fillScoreMax": 55,
|
||||
"noticeScoreMin": 70,
|
||||
"hotScoreMin": 110,
|
||||
"dwellFill": 6,
|
||||
"dwellNormal": 10,
|
||||
"dwellHot": 14,
|
||||
"fillRotateSeconds": 10,
|
||||
"dwellFill": 8,
|
||||
"dwellNormal": 15,
|
||||
"dwellHot": 15,
|
||||
"fillRotateSeconds": 8,
|
||||
"minCameraDwell": 15,
|
||||
|
||||
"maxWatchMoment": 15,
|
||||
"maxWatchStatus": 30,
|
||||
"maxWatchGrief": 45,
|
||||
"maxWatchCombat": 60,
|
||||
"maxWatchEpic": 50,
|
||||
|
||||
"massFighterThreshold": 4,
|
||||
"massBaseBonus": 28,
|
||||
|
|
@ -88,7 +95,7 @@
|
|||
"IdleSpectator/WorldActivityScanner.cs",
|
||||
"IdleSpectator/InterestDirector.cs",
|
||||
"IdleSpectator/InterestVariety.cs",
|
||||
"IdleSpectator/InterestFeeds.cs"
|
||||
"IdleSpectator/Events/Feeds/InterestFeeds.cs"
|
||||
],
|
||||
"designIntent": [
|
||||
"Actions matter much more than characters.",
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -10,21 +10,20 @@ WorldBox NeoModLoader mod: AFK Idle Spectator camera director.
|
|||
|
||||
## What it follows
|
||||
|
||||
Priority (wiki-aligned):
|
||||
The camera follows **ranked events** from live feeds (wars, fights, relationships, buildings, statuses, plots, …).
|
||||
`EventStrength` decides who wins; quieter events still take the shot when nothing stronger is pending.
|
||||
Character fill only runs when the event queue is empty (no orange reason; task chip shows the live job).
|
||||
|
||||
1. **Epic** - wars, kingdom fall/shatter, city destroyed, disasters
|
||||
2. **Story** - kings, clans, alliances, favorites, new kingdoms/cities
|
||||
3. **Action** - live battles/skirmishes (civ or wild), fighting units
|
||||
4. **Curiosity** - new subspecies (low priority; will not interrupt fights)
|
||||
5. **Ambient** - highest-scoring alive creature on the map (works on fresh animal-only worlds)
|
||||
While watching, a **dossier caption** shows who they are and an orange **event sentence** (why the camera is here), e.g. `Kaszus is fighting Waaaf`.
|
||||
See [`docs/event-reason.md`](docs/event-reason.md).
|
||||
|
||||
While watching, a **dossier caption** shows who they are (species, kills, age, roles).
|
||||
Lingering on someone writes a **Chronicle** line; F9 lists them and click jumps the camera.
|
||||
|
||||
## Layout
|
||||
|
||||
- `IdleSpectator/` - mod source (`mod.json` + C#), loaded/compiled by NML
|
||||
- `scripts/verify-nml.sh` - load verification helper
|
||||
- `docs/event-reason.md` - events-own-camera + EventReason contract
|
||||
|
||||
## Deploy
|
||||
|
||||
|
|
|
|||
82
docs/event-audit.md
Normal file
82
docs/event-audit.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Event audit (post-expand)
|
||||
|
||||
Inventory policy after the full gap expand.
|
||||
Camera interrupt is score-margin only (`cutInMargin`); no Signal/Ambient ownership gate.
|
||||
|
||||
**Single inventory:** [`EventCatalog`](../IdleSpectator/Events/EventCatalog.cs) (partials under `Events/Catalogs/`).
|
||||
Nested domains: `Status`, `Happiness`, `WorldLog`, `Trait`, `Combat`, `Spell`, …
|
||||
Feeds/patches register from those dials; `InterestDirector` only ranks candidates.
|
||||
|
||||
## Dials
|
||||
|
||||
| Dial | Role |
|
||||
|------|------|
|
||||
| `CreatesInterest` | Whether a live id may register at all |
|
||||
| `EventStrength` | Base contribution to `TotalScore` (interrupt via +`cutInMargin`) |
|
||||
| `Completion` | Hold-until-complete kind |
|
||||
| `MaxWatch` | Hard cap (class defaults in `scoring-model.json`) |
|
||||
| `EventReason` | Orange dossier sentence |
|
||||
|
||||
## MaxWatch class defaults
|
||||
|
||||
| Class | Seconds | JSON field |
|
||||
|-------|--------:|------------|
|
||||
| Moment / FixedDwell | 15 | `maxWatchMoment` |
|
||||
| Status | 30 | `maxWatchStatus` |
|
||||
| Grief | 45 | `maxWatchGrief` |
|
||||
| Combat | 60 | `maxWatchCombat` |
|
||||
| Epic world | 50 | `maxWatchEpic` |
|
||||
|
||||
Peer rotate / soft cut on EventLed floors at `minCameraDwell` (15s). Score-margin interrupts bypass that floor.
|
||||
Ambient / Character fill is exempt - events may take the camera immediately.
|
||||
|
||||
## Sources (wired)
|
||||
|
||||
| Source | Primary owner | Reason factory | Notes |
|
||||
|--------|---------------|----------------|-------|
|
||||
| WorldLog | `InterestFeeds.OnWorldLogMessage` | catalog / EventReason | Strength from `EventCatalog.WorldLog` |
|
||||
| Happiness | `IngestHappiness` | `EventCatalog.Happiness` + EventReason | Prose must match game call sites - see [event-prose-audit.md](event-prose-audit.md) |
|
||||
| Status | `OnStatusChange` | EventReason.Status | `drowning` CreatesInterest=true, strength ~50; notable via CharacterSignificance |
|
||||
| Scanner combat | WorldActivityScanner | EventReason.Fight / Battle | Completion CombatActive; dials `EventCatalog.Combat` |
|
||||
| War | WarEventPatches + WarInterestFeed.Tick | war type labels | |
|
||||
| Plot | PlotEventPatches + PlotInterestFeed.Tick | `EventCatalog.Plot` | |
|
||||
| Relationship | RelationshipEventPatches + happiness lovers | EventReason.* | |
|
||||
| Trait | TraitEventPatches | EventReason.Trait | Spawn window filters routine traits |
|
||||
| Building | BuildingEventPatches | EventReason.Building* | Eat/damage/falls |
|
||||
| Boat | BoatEventPatches | EventReason.Boat | |
|
||||
| Book | BookEventPatches | `EventCatalog.Book` sentences | |
|
||||
| Era / Meta | MetaEventPatches | EventReason.MetaNew / era labels | |
|
||||
| Spell | CombatActionLibrary.tryToCastSpell | EventReason.Library | Ambient CreatesInterest=false except spectacle ids |
|
||||
| Power | PlayerControl.clickedFinal | EventReason.HumanizeId | Spectacle ids create interest |
|
||||
| Decision | setDecisionCooldown | EventReason.Library | IsCameraWorthy filter |
|
||||
| Item | ItemManager | EventReason.Library | Legendary CreatesInterest |
|
||||
| Gene | Subspecies.mutateFrom | EventReason.Library | Ambient CreatesInterest=false |
|
||||
| Phenotype | generatePhenotypeAndShade | EventReason.Library | |
|
||||
| World law | WorldLaws.enable / toggle | EventReason.HumanizeId | Location-only |
|
||||
| Biome | catalog ready | EventReason.HumanizeId | Ambient CreatesInterest=false; emit via power terraform path |
|
||||
| Subspecies trait | SubspeciesManager biome hooks | EventReason.Library | |
|
||||
| Character fill | InterestDirector.TryCharacterFill | empty Label | Only when no pending EventLed |
|
||||
|
||||
## Ambient statuses (intentional off)
|
||||
|
||||
`festive_spirit`, `laughing`, `singing`, `sleeping`, `handsome_migrant` - CreatesInterest=false.
|
||||
|
||||
## Same-moment ownership
|
||||
|
||||
Prefer typed feeds over WorldLog when both fire (relationship / status / combat).
|
||||
Registry Upsert merges same key and refreshes `LastSeenAt` for sticky combat/status/grief.
|
||||
|
||||
## Interrupt / hold / grace
|
||||
|
||||
1. Instant cut when `next.TotalScore >= current + cutInMargin` (no settle; ignores min dwell).
|
||||
2. Ambient fill: any EventLed may take the camera immediately (no `minCameraDwell`).
|
||||
3. Else hold while `InterestCompletion.IsActive` and under MaxWatch (moments floor to `minCameraDwell`; combat uses class 60s).
|
||||
4. Soft peer rotates on EventLed only after `minCameraDwell` (15s).
|
||||
5. Quiet grace 6s: still-worth-watching filter, then Character fill with empty reason.
|
||||
|
||||
## Mutation gap triage
|
||||
|
||||
`mutation_discovery` refreshes `.harness/mutation_gaps.tsv`.
|
||||
Open rows are unexplained Wire candidates only.
|
||||
False positives (clear/load/UI/FX/getters) and covered-by-primary paths are filtered or listed in `AlreadyWired`.
|
||||
Library fields are inventory-complete via catalogs / activity / WorldLog (`mutation_libraries.tsv` notes).
|
||||
61
docs/event-prose-audit.md
Normal file
61
docs/event-prose-audit.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Event prose audit (vs game call sites)
|
||||
|
||||
Authored IdleSpectator sentences must match what the game actually does when an id fires.
|
||||
Source of truth: `Assembly-CSharp` apply sites (`changeHappiness`, relationship patches, status receive), not English guesses from the id string.
|
||||
|
||||
Audit date: 2026-07-16.
|
||||
Scope: Happiness (all authored), Relationship (all), Status/WorldLog/Plot/etc. sampled.
|
||||
|
||||
## Fixed this pass
|
||||
|
||||
| Id | Was | Actual | Now |
|
||||
|----|-----|--------|-----|
|
||||
| `just_born` | "is born into the world" / "first breath" | `Actor.newCreature` spawn; may precede egg form | "appears in the world" / "is brought into existence"; strength 48; no hatch strength floor |
|
||||
| `just_kissed` | "shares a kiss" | Mating afterglow (`BehCheckForBabiesFromSexualReproduction`) | "mates with …" / "shares intimacy …" |
|
||||
| `just_killed` | "takes a life" | Only with trait `bloodlust` | Explicit bloodlust wording |
|
||||
| `add_child` / `EventReason.NewChild` | "{a} and {b} have a new child" | `{b}` is the child | "{a} gains a child, {b}" |
|
||||
| `baby_created` / `EventReason.BabyBorn` | "{a} welcomes a baby" | `{a}` is the baby | "{a} is created as a baby" |
|
||||
| `just_had_child` | Named newborn `{related}` | Related newborn not wired | "has a child" / parenthood only |
|
||||
| `just_started_war` | Anyone cheers war | Culture trait `happiness_from_war` | "is glad war has started" / culture-loves-war |
|
||||
| `just_talked` | Always warm | Talk can be good or bad | Neutral "finishes talking" |
|
||||
| `just_inspired` | Onset of inspiration | Status `inspired` **end** | "comes down from inspiration" |
|
||||
| `slept_outside` | Rough night over | Sleep start, no house | "has to sleep without a house" |
|
||||
| `just_rebelled` | Joins / rises | Whole city units swept | "is swept up as the city rebels" |
|
||||
| `just_forced_power` | Vague forced power | God force shove | "is flung by a god power" |
|
||||
| `got_caught` | Shady catch Signal | No game caller found | Ambient, weak strength |
|
||||
| `lost_fight` | Decisive loss | Yield / submit mid-fight | "is forced to yield" |
|
||||
| `just_talked_gossip` | Generic juicy gossip | `gossip_lovers` culture | Lover-gossip wording |
|
||||
| `become_king` | "crowned king" | Any sex ruler | "takes the crown" / "becomes ruler" |
|
||||
| `become_alpha` | Fight for alpha | Oldest living family unit | "becomes the family alpha" |
|
||||
| Dreams | "wakes from …" | Dream status on receive | "has a … dream" |
|
||||
|
||||
## Policy notes
|
||||
|
||||
- Hatch camera floor (`Max(strength, 88)`) applies only to `just_got_out_of_egg` / `just_hatched`, not `just_born`.
|
||||
- True hatch prose (`just_got_out_of_egg` → "hatches from an egg") matches `Actor.eventHatchFromEgg`.
|
||||
- Live parent beat is `just_had_child`, not `just_born`.
|
||||
|
||||
## Still clean (sample)
|
||||
|
||||
Grief deaths, `fallen_in_love`, gifts, house find/lose, `just_got_out_of_egg`, `just_became_adult`, WorldLog `king_new` template roles, Status `egg` / `pregnant` / `drowning` prose.
|
||||
|
||||
## Hatch camera path (fixed)
|
||||
|
||||
Hatch was Activity-only for many units:
|
||||
- Status `egg` has `CreatesInterest=false` (becoming an egg is not a camera beat).
|
||||
- `OnStatusChange` previously ignored **losses**, so "Hatches from an egg" never became interest.
|
||||
- Happiness `just_got_out_of_egg` is often suppressed (`!hasEmotions()`).
|
||||
- Even when a hatch Label existed, dossier identity filter dropped verbs without ` is ` (e.g. "Name hatches …").
|
||||
|
||||
Fix:
|
||||
- Egg status **loss** → `InterestFeeds.EmitHatch` + `EventReason.Hatch`, key `hatch:{id}` shared with happiness.
|
||||
- Keep `Name verb …` EventReason sentences in `UnitDossier.OwnedEventReason`.
|
||||
|
||||
## Reasons
|
||||
|
||||
EventLed camera candidates always get a `Label` (orange reason) when ownership + sentence filters pass.
|
||||
Sources: `EventReason.*`, happiness catalog `PlainClause`, or discrete catalog templates.
|
||||
Character fill / ambient may show empty reason.
|
||||
Not every Activity line is a camera event (Ambient happiness, status gains with `CreatesInterest=false`, status losses except egg).
|
||||
|
||||
|
||||
62
docs/event-reason.md
Normal file
62
docs/event-reason.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Event reasons (camera + orange dossier)
|
||||
|
||||
## Contract
|
||||
|
||||
Registered **events** own the idle camera.
|
||||
`EventStrength` / `TotalScore` ranks who wins.
|
||||
Character fill runs only when the pending **EventLed** queue is empty, and uses an empty Label (task chip only).
|
||||
|
||||
Orange dossier reason = the owning event’s `Label` while the scene is active (`InterestDirector.TryGetOwnedReasonCandidate`).
|
||||
Quiet grace / inactive dwell clears the reason even if focus has not switched yet.
|
||||
|
||||
## EventReason
|
||||
|
||||
[`IdleSpectator/Events/EventReason.cs`](../IdleSpectator/Events/EventReason.cs) is the sole Label factory for camera candidates.
|
||||
It lives in the Events module with catalogs (not in the director).
|
||||
Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply(template, a, b, id)`.
|
||||
|
||||
## Events module layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `Events/EventCatalog.cs` (+ `Catalogs/EventCatalog.*.cs`) | **One inventory** - nested domains (`Status`, `Combat`, …) |
|
||||
| `Events/Feeds/` | Build + register candidates from catalog dials |
|
||||
| `Events/Patches/` | Harmony hooks → feeds |
|
||||
| `Events/EventReason.cs` | Orange reason sentences |
|
||||
| `Events/EventFeedUtil.cs` | Shared Register path |
|
||||
| `Events/DiscreteEventEntry.cs` | Shared catalog row shape |
|
||||
|
||||
`InterestDirector` only ranks registered candidates for the camera - it does not author event inventory.
|
||||
|
||||
Rules:
|
||||
|
||||
- Sentence form with names: `{a} is fighting {b}`, `{a} is eating a beehive`, `{a} is seeking a lover`.
|
||||
- Set `RelatedUnit` when the sentence names a second party.
|
||||
- Never invent stranger subjects for unit-led events.
|
||||
- Never use engine jargon that misstates the action (`Consumes a building` → eating/foraging via `BehConsumeTargetBuilding`).
|
||||
|
||||
## Removed crumbs
|
||||
|
||||
Do not author these as orange reasons:
|
||||
|
||||
- `In action`
|
||||
- bare `Duel` / `Fight (N)` without a named sentence
|
||||
- identity titles (`King of X`, `Leader of X`, `Lone beetle`)
|
||||
- `Consumes a building` / `Damages a building: {a}` crumb form
|
||||
|
||||
## Strength dial (no ownership enum)
|
||||
|
||||
`InterestOwnership` / `OwnsCamera` were removed.
|
||||
Every registration is EventLed and may own the camera.
|
||||
`CreatesInterest` gates whether an id registers; `EventStrength` ranks.
|
||||
Instant cut-in when `next.TotalScore >= current + cutInMargin` (see `scoring-model.json`).
|
||||
|
||||
## Dossier display
|
||||
|
||||
`UnitDossier.EventLabelBeat` keeps the authored sentence.
|
||||
It only rejects identity/weak crumbs and scrubs living stranger names (`ReasonNamesStranger` / `LeadsWithForeignName`).
|
||||
|
||||
## See also
|
||||
|
||||
- [`event-audit.md`](event-audit.md) - full source inventory, MaxWatch classes, drowning policy
|
||||
- [`scoring-model.md`](scoring-model.md) - score weights
|
||||
|
|
@ -37,6 +37,7 @@ REGRESSION_SCENARIOS=(
|
|||
director_action_priority
|
||||
discovery
|
||||
world_log
|
||||
event_coverage
|
||||
chronicle_smoke
|
||||
activity_idle_smoke
|
||||
activity_status_smoke
|
||||
|
|
|
|||
|
|
@ -30,10 +30,16 @@ worldbox_running() {
|
|||
|
||||
mod_ready() {
|
||||
[[ -f "$LOG" ]] || return 1
|
||||
# Compile errors (NML prints error CS during Compile Mod IdleSpectator).
|
||||
if rg -q "error CS" "$LOG" 2>/dev/null \
|
||||
&& rg -q "Compile Mod IdleSpectator|IdleSpectator.*error CS|error CS.*IdleSpectator" "$LOG" 2>/dev/null; then
|
||||
return 2
|
||||
fi
|
||||
# Harmony PatchAll failures disable the mod without a CS error.
|
||||
if rg -q "IdleSpectator has been disabled due to an error" "$LOG" 2>/dev/null \
|
||||
|| rg -q "\[IdleSpectator\]: Harmony failed" "$LOG" 2>/dev/null; then
|
||||
return 3
|
||||
fi
|
||||
rg -q "\[IdleSpectator\]: Harmony patches applied" "$LOG" 2>/dev/null
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +109,11 @@ cmd_start() {
|
|||
rg -n "error CS|Compile Mod IdleSpectator" "$LOG" 2>/dev/null | tail -n 40 >&2 || true
|
||||
return 1
|
||||
fi
|
||||
if (( ready_rc == 3 )); then
|
||||
echo "IdleSpectator Harmony failed - see $LOG" >&2
|
||||
rg -n "Harmony failed|Parameter \"|IdleSpectator has been disabled|IL Compile Error" "$LOG" 2>/dev/null | tail -n 40 >&2 || true
|
||||
return 1
|
||||
fi
|
||||
printf '.'
|
||||
else
|
||||
printf 'o'
|
||||
|
|
|
|||
Loading…
Reference in a new issue