Compare commits

...

2 commits

Author SHA1 Message Date
04d5bcb381 Enhance theater lead handling and update combat scenarios.
- Introduce theater lead management to maintain focus during combat
- Add new steps for combat scenarios involving spectacle creatures
- Update InterestCandidate to track theater lead and combat participation
- Refactor InterestDirector to improve focus handling across maintain ticks
- Increment version to 0.28.18 in mod.json
2026-07-17 17:29:41 -05:00
03729664be Implement building completion events and enhance earthquake handling.
- Update building event patches to include completion notifications
- Introduce earthquake event handling for ongoing seismic activity
- Modify event catalog to reflect changes in ambient interest settings
- Add new harness scenarios for library asset label validation
- Increment version to 0.28.17 in mod.json
2026-07-17 17:13:43 -05:00
24 changed files with 1910 additions and 146 deletions

10
.tmp-logs/README.md Normal file
View file

@ -0,0 +1,10 @@
# Saved Player.log archives
## Player-pre-theater-lead-20260717-172418.log
Captured before the Mass/Battle theater-lead focus hold fix (mod 0.28.17 soak).
Use this to evaluate whether the director was showing interesting events overall,
separate from the combat camera thrash that was fixed in 0.28.18.
Key stretch: Evil Mage vs The Godo humans (mass fight + duel partner churn).

View file

@ -3753,11 +3753,11 @@ public static class AgentHarness
source = "boat";
break;
case "building":
id = "building_destroy";
key = "building:destroy:harness:" + EventFeedUtil.SafeId(unit);
label = EventReason.BuildingFalls("harness_hall", unit);
strength = 88f;
category = "Spectacle";
id = "building_complete";
key = "building:complete:harness:" + EventFeedUtil.SafeId(unit);
label = EventReason.BuildingComplete(unit, "temple_human", spectacle: true);
strength = 86f;
category = "Settlement";
source = "building";
break;
case "spell":
@ -7041,6 +7041,205 @@ public static class AgentHarness
+ $"familyBad={familyBad} siblingBad={siblingBad} sample='{(ids.Count > 0 ? ActivityAssetCatalog.FormatCitizenJobIdLabel(ids[0]) : "")}'";
break;
}
case "library_asset_labels_ok":
{
// Every live library id used by EventReason.Library must resolve to a tip
// without snake_case, and item tips must not use "forges" except forge-y ids.
int total = 0;
int empty = 0;
int underscored = 0;
int forgeBad = 0;
var lines = new List<string> { "kind\tid\tdisplay\tsample_label" };
IReadOnlyList<string> kinds = LibraryAssetNames.AllKinds;
for (int k = 0; k < kinds.Count; k++)
{
string kind = kinds[k];
List<string> ids = LibraryAssetNames.EnumerateLiveIds(kind);
for (int i = 0; i < ids.Count; i++)
{
string id = ids[i];
if (string.IsNullOrEmpty(id))
{
continue;
}
total++;
string display = LibraryAssetNames.DisplayName(kind, id);
string sample = EventReason.Library(null, kind, id);
lines.Add(kind + "\t" + id + "\t" + display + "\t" + sample);
if (string.IsNullOrEmpty(display))
{
empty++;
}
if (!string.IsNullOrEmpty(display) && display.IndexOf('_') >= 0)
{
underscored++;
}
if (kind == "item")
{
bool forgey = id.IndexOf("forge", StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("smith", StringComparison.OrdinalIgnoreCase) >= 0
|| id.IndexOf("anvil", StringComparison.OrdinalIgnoreCase) >= 0;
string verb = LibraryAssetNames.ItemVerb(id);
if (!forgey
&& string.Equals(verb, "forges", StringComparison.OrdinalIgnoreCase))
{
forgeBad++;
}
}
}
}
try
{
System.IO.File.WriteAllLines(
System.IO.Path.Combine(HarnessDir(), "library-asset-labels.tsv"),
lines);
}
catch
{
// diagnostic dump only
}
pass = total > 0 && empty == 0 && underscored == 0 && forgeBad == 0;
detail =
$"assets={total} empty={empty} underscored={underscored} forgeBad={forgeBad} "
+ $"kinds={kinds.Count}";
break;
}
case "library_ambient_policy_ok":
{
// Signal-only libraries: ambient dials must be B; phenotypes all B;
// spectacle spells stay A; utility spells stay B.
int dialBad = 0;
int phenotypeCamera = 0;
int spellFxCamera = 0;
int spellSpectacleMissing = 0;
int signalCamera = 0;
var lines = new List<string>
{
"domain\tid\tcreates_interest\tstrength\tnotes"
};
string[] signalOnlyKeys =
{
"spell", "power", "item", "gene", "phenotype", "worldLaw", "subspeciesTrait",
"biome", "cultureTrait", "religionTrait", "clanTrait", "languageTrait"
};
for (int i = 0; i < signalOnlyKeys.Length; i++)
{
string key = signalOnlyKeys[i];
if (EventCatalogConfig.TryGetLibraryDefaults(key, out LibraryCatalogDefaults dial)
&& dial != null
&& dial.AmbientCreatesInterest)
{
dialBad++;
lines.Add(key + "\t*\t\t\tambientCreatesInterest_true");
}
}
foreach (string id in EventCatalog.Phenotype.AuthoredIds)
{
DiscreteEventEntry e = EventCatalog.Phenotype.GetOrFallback(id);
lines.Add("phenotypes\t" + id + "\t" + e.CreatesInterest + "\t"
+ e.EventStrength.ToString("0.#") + "\t");
if (e.CreatesInterest)
{
phenotypeCamera++;
}
}
string[] fxSpells =
{
"cast_silence", "cast_grass_seeds", "spawn_vegetation", "spawn_skeleton",
"cast_shield", "cast_cure"
};
for (int i = 0; i < fxSpells.Length; i++)
{
DiscreteEventEntry e = EventCatalog.Spell.GetOrFallback(fxSpells[i]);
lines.Add("spells\t" + fxSpells[i] + "\t" + e.CreatesInterest + "\t"
+ e.EventStrength.ToString("0.#") + "\tfx");
if (e.CreatesInterest)
{
spellFxCamera++;
}
}
string[] spectacleSpells =
{
"summon_lightning", "summon_tornado", "cast_curse", "cast_fire",
"cast_blood_rain", "teleport"
};
for (int i = 0; i < spectacleSpells.Length; i++)
{
DiscreteEventEntry e = EventCatalog.Spell.GetOrFallback(spectacleSpells[i]);
lines.Add("spells\t" + spectacleSpells[i] + "\t" + e.CreatesInterest + "\t"
+ e.EventStrength.ToString("0.#") + "\tspectacle");
if (!e.CreatesInterest)
{
spellSpectacleMissing++;
}
else
{
signalCamera++;
}
}
// Spot-check Signal classes still have camera rows after demote.
int geneSignal = 0;
foreach (string id in EventCatalog.Gene.AuthoredIds)
{
if (EventCatalog.Gene.GetOrFallback(id).CreatesInterest)
{
geneSignal++;
}
}
int lawSignal = 0;
foreach (string id in EventCatalog.WorldLaw.AuthoredIds)
{
if (EventCatalog.WorldLaw.GetOrFallback(id).CreatesInterest)
{
lawSignal++;
}
}
int itemSignal = 0;
foreach (string id in EventCatalog.Item.AuthoredIds)
{
if (EventCatalog.Item.GetOrFallback(id).CreatesInterest)
{
itemSignal++;
}
}
try
{
System.IO.File.WriteAllLines(
System.IO.Path.Combine(HarnessDir(), "library-ambient-policy.tsv"),
lines);
}
catch
{
// diagnostic dump only
}
pass = dialBad == 0
&& phenotypeCamera == 0
&& spellFxCamera == 0
&& spellSpectacleMissing == 0
&& geneSignal > 0
&& lawSignal > 0
&& itemSignal > 0
&& signalCamera == spectacleSpells.Length;
detail =
$"dialBad={dialBad} phenotypeCamera={phenotypeCamera} spellFxCamera={spellFxCamera} "
+ $"spellSpectacleMissing={spellSpectacleMissing} geneSignal={geneSignal} "
+ $"lawSignal={lawSignal} itemSignal={itemSignal} spectacleOk={signalCamera}";
break;
}
case "dossier_not_contains":
{
string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value;

View file

@ -316,6 +316,8 @@ public static class DomainEventHarness
&& spells.Passed && powers.Passed && decisions.Passed && items.Passed
&& subspTraits.Passed && genes.Passed && phenotypes.Passed
&& worldLaws.Passed && biomes.Passed
&& cultureTraits.Passed && religionTraits.Passed
&& clanTraits.Passed && languageTraits.Passed
&& relMissing == 0 && relTotal > 0
&& decisionProseMissing == 0 && decisionStrengthMissing == 0
&& decisionProseTotal > 0;
@ -323,7 +325,10 @@ public static class DomainEventHarness
$"{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}; "
+ $"{worldLaws.Detail}; {biomes.Detail}; "
+ $"{cultureTraits.Detail}; {religionTraits.Detail}; "
+ $"{clanTraits.Detail}; {languageTraits.Detail}; "
+ $"relationship: authored={relTotal} missing={relMissing}; "
+ $"decision_prose: camera={decisionProseTotal} missing={decisionProseMissing} "
+ $"strengthMissing={decisionStrengthMissing} catalog={EventCatalogConfig.LoadedFromFile}";
return new DomainEventAuditResult { Passed = passed, Detail = detail };

View file

@ -399,7 +399,8 @@ public static class EventCatalogConfig
string[] keys =
{
"spell", "power", "item", "gene", "phenotype", "worldLaw", "subspeciesTrait", "biome"
"spell", "power", "item", "gene", "phenotype", "worldLaw", "subspeciesTrait", "biome",
"cultureTrait", "religionTrait", "clanTrait", "languageTrait"
};
for (int i = 0; i < keys.Length; i++)
{
@ -415,7 +416,8 @@ public static class EventCatalogConfig
SignalStrength = ExtractJsonFloat(obj, "signalStrength", 80f),
Category = ExtractJsonString(obj, "category") ?? "Event",
Label = ExtractJsonString(obj, "label") ?? "{a} · {id}",
AmbientCreatesInterest = ExtractJsonBool(obj, "ambientCreatesInterest", true)
// Large libraries default Signal-only; JSON must opt into ambient A.
AmbientCreatesInterest = ExtractJsonBool(obj, "ambientCreatesInterest", false)
};
string[] ids = ExtractStringArray(obj, "signalIds");
for (int j = 0; j < ids.Length; j++)

View file

@ -46,9 +46,11 @@ public static class EventLivePipelinesHarness
RunDisastersViaWorldLog(unit, fails);
RunWar(unit, fails);
RunEra(fails);
RunEarthquake(unit, fails);
RunBook(unit, fails);
RunPlot(unit, fails);
RunBuildingDestroy(unit, fails);
RunBuildingComplete(unit, fails);
RunBoatSmoke(unit, fails);
RunCombatSmoke(unit, fails);
RunMeta(unit, fails);
@ -579,6 +581,69 @@ public static class EventLivePipelinesHarness
}
}
private static void RunEarthquake(Actor unit, List<string> fails)
{
InterestDropLog.Clear();
InterestRegistry.ForceExpireContaining("earthquake:", Time.unscaledTime);
try
{
WorldTile tile = unit != null ? unit.current_tile : null;
if (tile == null)
{
try
{
tile = World.world?.tiles_list?.GetRandom();
}
catch
{
tile = null;
}
}
if (tile == null)
{
EventLiveCoverageLedger.Claim(
"earthquake",
"earthquake",
"unsupported",
"Earthquake.startQuake",
"no_tile");
return;
}
Earthquake.startQuake(tile, EarthquakeType.SmallDisaster);
if (InterestRegistry.CountKeysContaining("earthquake:") > 0)
{
EventLiveCoverageLedger.Claim(
"earthquake",
"earthquake",
"ok",
"Earthquake.startQuake",
"busy_bypass=ForceLiveEventFeeds;active=" + Earthquake.isQuakeActive());
}
else
{
EventLiveCoverageLedger.Claim(
"earthquake",
"earthquake",
"fail",
"Earthquake.startQuake",
"no_a_interest");
fails.Add("earthquake:earthquake no interest key");
}
}
catch (Exception ex)
{
fails.Add("earthquake:earthquake " + ex.Message);
EventLiveCoverageLedger.Claim(
"earthquake",
"earthquake",
"fail",
"Earthquake.startQuake",
ex.Message);
}
}
private static void RunBook(Actor unit, List<string> fails)
{
InterestDropLog.Clear();
@ -1494,6 +1559,208 @@ public static class EventLivePipelinesHarness
// Domains without AuthoredIds rows in ledger seed - combat/boat/building claimed in Run*Smoke.
}
private static void RunBuildingComplete(Actor unit, List<string> fails)
{
InterestDropLog.Clear();
InterestRegistry.ForceExpireContaining("building:complete", Time.unscaledTime);
try
{
Building building = FindCivBuildingNear(unit) ?? TrySpawnCivBuilding(unit);
if (building == null)
{
EventLiveCoverageLedger.Claim(
"building",
"building_complete",
"unsupported",
"Building.completeConstruction",
"no_civ_building");
return;
}
MethodInfo complete = AccessTools.Method(typeof(Building), "completeConstruction");
if (complete == null)
{
EventLiveCoverageLedger.Claim(
"building",
"building_complete",
"unsupported",
"Building.completeConstruction",
"no_method");
return;
}
try
{
if (unit != null && building.current_tile != null)
{
AccessTools.Method(typeof(Actor), "setCurrentTile", new[] { typeof(WorldTile) })
?.Invoke(unit, new object[] { building.current_tile });
unit.current_position = building.current_position;
}
}
catch
{
// ignore
}
complete.Invoke(building, null);
if (InterestRegistry.CountKeysContaining("building:complete") > 0)
{
EventLiveCoverageLedger.Claim(
"building",
"building_complete",
"ok",
"Building.completeConstruction",
"busy_bypass=ForceLiveEventFeeds");
}
else
{
EventLiveCoverageLedger.Claim(
"building",
"building_complete",
"fail",
"Building.completeConstruction",
"no_a_interest_after_complete");
fails.Add("building:building_complete no interest key");
}
}
catch (Exception ex)
{
fails.Add("building:building_complete " + ex.Message);
EventLiveCoverageLedger.Claim(
"building",
"building_complete",
"fail",
"Building.completeConstruction",
ex.Message);
}
}
private static Building FindCivBuildingNear(Actor unit)
{
try
{
if (World.world?.buildings == null)
{
return null;
}
Building best = null;
float bestDist = float.MaxValue;
Vector3 origin = unit != null ? unit.current_position : Vector3.zero;
foreach (Building b in World.world.buildings)
{
try
{
if (b == null || !b.isAlive() || b.asset == null
|| !LibraryAssetNames.IsCivBuilding(b.asset))
{
continue;
}
Vector3 pos = Vector3.zero;
try
{
if (b.current_tile != null)
{
pos = b.current_tile.posV3;
}
}
catch
{
pos = Vector3.zero;
}
float d = (pos - origin).sqrMagnitude;
bool spectacle = LibraryAssetNames.IsSpectacleBuilding(b.asset.id, b.asset);
// Prefer temples/statues when scoring distance ties.
float score = d - (spectacle ? 1000f : 0f);
if (score < bestDist)
{
bestDist = score;
best = b;
}
}
catch
{
// ignore
}
}
return best;
}
catch
{
return null;
}
}
private static Building TrySpawnCivBuilding(Actor unit)
{
try
{
WorldTile tile = unit != null ? unit.current_tile : null;
if (tile == null || World.world?.buildings == null)
{
return null;
}
MethodInfo add = AccessTools.Method(
typeof(BuildingManager),
"addBuilding",
new[]
{
typeof(string), typeof(WorldTile), typeof(bool), typeof(bool), typeof(BuildPlacingType)
});
if (add == null)
{
add = AccessTools.Method(typeof(BuildingManager), "addBuilding");
}
if (add == null)
{
return null;
}
string[] candidates = { "statue", "temple_human", "bonfire", "house_human_1", "house" };
for (int i = 0; i < candidates.Length; i++)
{
try
{
BuildingAsset asset = AssetManager.buildings?.get(candidates[i]);
if (asset == null || !LibraryAssetNames.IsCivBuilding(asset))
{
// Still try known spectacle ids even if type check fails early.
if (candidates[i] != "statue" && !candidates[i].StartsWith("temple_", StringComparison.Ordinal))
{
continue;
}
}
object built = add.GetParameters().Length >= 5
? add.Invoke(
World.world.buildings,
new object[] { candidates[i], tile, false, false, BuildPlacingType.New })
: add.Invoke(World.world.buildings, new object[] { candidates[i], tile });
if (built is Building b && b.isAlive())
{
return b;
}
}
catch
{
// try next
}
}
}
catch
{
// ignore
}
return null;
}
private static void RunBuildingDestroy(Actor unit, List<string> fails)
{
InterestDropLog.Clear();

View file

@ -54,7 +54,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("spell", 48f, 88f, "Spectacle", "{a} casts {id}", true);
LibraryCatalogDefaults d = LibOr("spell", 48f, 88f, "Spectacle", "{a} casts {id}", false);
HashSet<string> signals = d.SignalIds.Count > 0
? d.SignalIds
: new HashSet<string>(StringComparer.OrdinalIgnoreCase)
@ -70,9 +70,41 @@ public static partial class EventCatalog
d.AmbientStrength,
signals,
d.SignalStrength,
signalPredicate: IsSpectacleSpell,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
/// <summary>
/// Spectacle casts own the camera; brief FX / utility (silence, shield, cure, grass) stay B.
/// </summary>
private static bool IsSpectacleSpell(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
if (s.Contains("silence")
|| s.Contains("shield")
|| s.Contains("cure")
|| s.Contains("grass")
|| s.Contains("vegetation")
|| s.Contains("skeleton"))
{
return false;
}
return s.StartsWith("summon_", StringComparison.Ordinal)
|| s.Contains("meteor")
|| s.Contains("tornado")
|| s.Contains("lightning")
|| s.Contains("teleport")
|| s.Contains("curse")
|| s.Contains("blood_rain")
|| s.Contains("cast_fire");
}
public static IEnumerable<string> AuthoredIds
{
get
@ -85,7 +117,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("spell", 48f, 88f, "Spectacle", "{a} casts {id}", true);
LibraryCatalogDefaults d = LibOr("spell", 48f, 88f, "Spectacle", "{a} casts {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 55f);
}
}
@ -186,8 +218,11 @@ public static partial class EventCatalog
}
string s = id.ToLowerInvariant();
// Top-tier materials from live items library (mythril/adamantine) plus named relics.
return s.Contains("legendary")
|| s.Contains("mythic")
|| s.Contains("mythril")
|| s.Contains("adamantine")
|| s.Contains("artifact")
|| s.Contains("relic")
|| s.Contains("divine")
@ -207,7 +242,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("item", 36f, 80f, "Item", "{a} forges {id}", true);
LibraryCatalogDefaults d = LibOr("item", 36f, 80f, "Item", "{a} gains {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveItemIds,
@ -232,7 +267,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("item", 36f, 80f, "Item", "{a} forges {id}", true);
LibraryCatalogDefaults d = LibOr("item", 36f, 80f, "Item", "{a} gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 40f);
}
}
@ -283,7 +318,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("subspeciesTrait", 34f, 78f, "Evolution", "{a} evolves {id}", true);
LibraryCatalogDefaults d = LibOr("subspeciesTrait", 34f, 78f, "Evolution", "{a} evolves {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds,
@ -308,7 +343,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("subspeciesTrait", 34f, 78f, "Evolution", "{a} evolves {id}", true);
LibraryCatalogDefaults d = LibOr("subspeciesTrait", 34f, 78f, "Evolution", "{a} evolves {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 36f);
}
}
@ -335,7 +370,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("gene", 30f, 70f, "Genetics", "{a} gains gene {id}", true);
LibraryCatalogDefaults d = LibOr("gene", 30f, 70f, "Genetics", "{a} gains gene {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveGeneIds,
@ -360,7 +395,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("gene", 30f, 70f, "Genetics", "{a} gains gene {id}", true);
LibraryCatalogDefaults d = LibOr("gene", 30f, 70f, "Genetics", "{a} gains gene {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f);
}
}
@ -387,7 +422,8 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("phenotype", 28f, 60f, "Genetics", "{a} shows {id}", true);
// Skin/shade phenotypes are ambient noise - no Signal predicate, all B.
LibraryCatalogDefaults d = LibOr("phenotype", 28f, 60f, "Genetics", "{a} shows {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLivePhenotypeIds,
@ -411,7 +447,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("phenotype", 28f, 60f, "Genetics", "{a} shows {id}", true);
LibraryCatalogDefaults d = LibOr("phenotype", 28f, 60f, "Genetics", "{a} shows {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 28f);
}
}
@ -438,7 +474,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("worldLaw", 40f, 70f, "WorldLaw", "Law changed: {id}", true);
LibraryCatalogDefaults d = LibOr("worldLaw", 40f, 70f, "WorldLaw", "Law changed: {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveWorldLawIds,
@ -447,10 +483,27 @@ public static partial class EventCatalog
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: id => id != null && (id.Contains("war") || id.Contains("death") || id.Contains("plague")),
signalPredicate: IsDramaticWorldLaw,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
private static bool IsDramaticWorldLaw(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
return s.Contains("war")
|| s.Contains("death")
|| s.Contains("plague")
|| s.Contains("hunger")
|| s.Contains("rebell")
|| s.Contains("disaster")
|| s.Contains("angry");
}
public static IEnumerable<string> AuthoredIds
{
get
@ -463,7 +516,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("worldLaw", 40f, 70f, "WorldLaw", "Law changed: {id}", true);
LibraryCatalogDefaults d = LibOr("worldLaw", 40f, 70f, "WorldLaw", "Law changed: {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 40f);
}
}
@ -568,7 +621,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("cultureTrait", 34f, 74f, "Culture", "{a} culture gains {id}", true);
LibraryCatalogDefaults d = LibOr("cultureTrait", 34f, 74f, "Culture", "{a} culture gains {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveCultureTraitIds,
@ -593,7 +646,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("cultureTrait", 34f, 74f, "Culture", "{a} culture gains {id}", true);
LibraryCatalogDefaults d = LibOr("cultureTrait", 34f, 74f, "Culture", "{a} culture gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 34f);
}
}
@ -620,7 +673,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("religionTrait", 34f, 74f, "Religion", "{a} faith gains {id}", true);
LibraryCatalogDefaults d = LibOr("religionTrait", 34f, 74f, "Religion", "{a} faith gains {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveReligionTraitIds,
@ -645,7 +698,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("religionTrait", 34f, 74f, "Religion", "{a} faith gains {id}", true);
LibraryCatalogDefaults d = LibOr("religionTrait", 34f, 74f, "Religion", "{a} faith gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 34f);
}
}
@ -672,7 +725,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("clanTrait", 32f, 72f, "Clan", "{a} clan gains {id}", true);
LibraryCatalogDefaults d = LibOr("clanTrait", 32f, 72f, "Clan", "{a} clan gains {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveClanTraitIds,
@ -697,7 +750,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("clanTrait", 32f, 72f, "Clan", "{a} clan gains {id}", true);
LibraryCatalogDefaults d = LibOr("clanTrait", 32f, 72f, "Clan", "{a} clan gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 32f);
}
}
@ -724,7 +777,7 @@ public static partial class EventCatalog
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("languageTrait", 30f, 68f, "Language", "{a} tongue gains {id}", true);
LibraryCatalogDefaults d = LibOr("languageTrait", 30f, 68f, "Language", "{a} tongue gains {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveLanguageTraitIds,
@ -749,7 +802,7 @@ public static partial class EventCatalog
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
LibraryCatalogDefaults d = LibOr("languageTrait", 30f, 68f, "Language", "{a} tongue gains {id}", true);
LibraryCatalogDefaults d = LibOr("languageTrait", 30f, 68f, "Language", "{a} tongue gains {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f);
}
}

View file

@ -26,8 +26,9 @@ public sealed class DiscreteEventEntry
public string MakeLabel(string a, string b)
{
string template = string.IsNullOrEmpty(LabelTemplate) ? "{id}" : LabelTemplate;
string displayId = string.IsNullOrEmpty(Id) ? "" : EventReason.HumanizeId(Id);
return template
.Replace("{id}", Id ?? "")
.Replace("{id}", displayId)
.Replace("{a}", a ?? "")
.Replace("{b}", b ?? "");
}

View file

@ -814,43 +814,102 @@ public static class EventReason
public static string Library(Actor a, string kind, string assetId)
{
string an = NameOrSomeone(a);
string id = HumanizeId(assetId);
string k = HumanizeId(kind);
string k = (kind ?? "").Trim().ToLowerInvariant();
string id = LibraryAssetNames.DisplayName(k, assetId);
if (string.IsNullOrEmpty(id))
{
return an + " triggers " + (string.IsNullOrEmpty(k) ? "an event" : k);
id = HumanizeId(assetId);
}
switch ((kind ?? "").Trim().ToLowerInvariant())
bool hasActor = a != null && a.isAlive();
string an = hasActor ? NameOrSomeone(a) : "";
if (string.IsNullOrEmpty(id))
{
if (!hasActor)
{
return string.IsNullOrEmpty(k) ? "An event" : HumanizeId(k);
}
return an + " triggers " + (string.IsNullOrEmpty(k) ? "an event" : HumanizeId(k));
}
switch (k)
{
case "spell":
return an + " casts " + id;
return hasActor ? an + " casts " + id : id + " is cast";
case "item":
return an + " forges " + id;
return hasActor
? an + " " + LibraryAssetNames.ItemVerb(assetId) + " " + id
: id + " is gained";
case "decision":
return Decision(a, assetId);
case "gene":
return an + " gains gene " + id;
return hasActor ? an + " gains gene " + id : "Gene " + id;
case "phenotype":
return an + " shows phenotype " + id;
return hasActor ? an + " shows " + id : "Phenotype " + id;
case "power":
return an + " channels " + id;
return hasActor ? an + " channels " + id : "God power: " + id;
case "worldlaw":
case "world_law":
return "Law changed: " + id;
case "biome":
return "Biome shifts: " + id;
case "subspecies_trait":
return an + " gains " + id;
return hasActor ? an + " evolves " + id : id + " evolves";
case "culture_trait":
return an + "'s culture gains " + id;
return hasActor ? an + "'s culture gains " + id : "Culture gains " + id;
case "religion_trait":
return an + "'s faith gains " + id;
return hasActor ? an + "'s faith gains " + id : "Faith gains " + id;
case "clan_trait":
return an + "'s clan gains " + id;
return hasActor ? an + "'s clan gains " + id : "Clan gains " + id;
case "language_trait":
return an + "'s language gains " + id;
return hasActor ? an + "'s language gains " + id : "Language gains " + id;
default:
return an + " · " + id;
return hasActor ? an + " · " + id : id;
}
}
public static string Earthquake(Actor near)
{
if (near != null && near.isAlive())
{
string n = Name(near);
if (!string.IsNullOrEmpty(n))
{
return n + " is caught in an earthquake";
}
}
return "Earthquake shakes the land";
}
/// <summary>Civ construction finished - temple/statue spectacle vs ordinary house.</summary>
public static string BuildingComplete(Actor near, string buildingId, bool spectacle)
{
string id = LibraryAssetNames.DisplayName("building", buildingId);
if (string.IsNullOrEmpty(id))
{
id = HumanizeId(buildingId);
}
string what = string.IsNullOrEmpty(id)
? (spectacle ? "a wonder" : "a building")
: (spectacle ? "wonder " + id : id);
if (near != null && near.isAlive())
{
string n = Name(near);
if (!string.IsNullOrEmpty(n))
{
return n + " finishes " + what;
}
}
return spectacle ? "Wonder finished: " + (string.IsNullOrEmpty(id) ? "structure" : id)
: "Building finished: " + (string.IsNullOrEmpty(id) ? "structure" : id);
}
public static string Decision(Actor a, string decisionId)
{
string an = NameOrSomeone(a);
@ -869,8 +928,10 @@ public static class EventReason
public static string Apply(string template, Actor a, Actor b = null, string id = "")
{
string t = string.IsNullOrEmpty(template) ? "{a}" : template;
string displayId = string.IsNullOrEmpty(id) ? "" : HumanizeId(id);
// Prefer humanized id in catalog templates so `{id}` never dumps snake_case.
return t
.Replace("{id}", id ?? "")
.Replace("{id}", displayId)
.Replace("{a}", Name(a) ?? "")
.Replace("{b}", Name(b) ?? "");
}

View file

@ -68,7 +68,7 @@ public static class DeferredInterestFeed
"power:" + entry.Id,
"power",
entry.Category,
EventReason.HumanizeId(entry.Id),
EventReason.Library(null, "power", entry.Id),
entry.Id,
entry.EventStrength,
position,
@ -127,7 +127,7 @@ public static class DeferredInterestFeed
"worldlaw:" + entry.Id,
"worldlaw",
entry.Category,
EventReason.HumanizeId(entry.Id),
EventReason.Library(null, "worldlaw", entry.Id),
entry.Id,
entry.EventStrength,
position,
@ -197,7 +197,7 @@ public static class DeferredInterestFeed
"biome:" + entry.Id,
"biome",
entry.Category,
EventReason.HumanizeId(entry.Id),
EventReason.Library(null, "biome", entry.Id),
entry.Id,
entry.EventStrength,
position,

View file

@ -95,4 +95,66 @@ public static class MetaInterestFeed
follow.current_position,
follow);
}
/// <summary>
/// Ongoing earthquake while <see cref="Earthquake.isQuakeActive"/> - covers god-tool quakes
/// and disaster-spawned quakes (WorldLog may also fire for the latter).
/// </summary>
public static void EmitEarthquake(WorldTile tile)
{
if (!AgentHarness.LiveFeedsAllowed)
{
return;
}
Vector3 pos = Vector3.zero;
try
{
if (tile != null)
{
pos = tile.posV3;
}
}
catch
{
pos = Vector3.zero;
}
if (pos == Vector3.zero)
{
try
{
if (Camera.main != null)
{
Vector3 cam = Camera.main.transform.position;
pos = new Vector3(cam.x, cam.y, 0f);
}
}
catch
{
pos = new Vector3(1f, 1f, 0f);
}
}
if (pos == Vector3.zero)
{
pos = new Vector3(1f, 1f, 0f);
}
Actor follow = WorldActivityScanner.FindNearestAliveUnit(pos, 80f);
string label = EventReason.Earthquake(follow);
EventFeedUtil.Register(
"earthquake:active",
"earthquake",
"Disaster",
label,
"earthquake",
95f,
pos,
follow,
locationOnly: follow == null,
minWatch: 8f,
maxWatch: 45f,
completion: InterestCompletionKind.EarthquakeActive);
}
}

View file

@ -1,9 +1,10 @@
using ai.behaviours;
using HarmonyLib;
using UnityEngine;
namespace IdleSpectator;
/// <summary>Building damage/destroy/eat interest patches from mutation discovery.</summary>
/// <summary>Building damage/destroy/eat/complete interest patches from mutation discovery.</summary>
[HarmonyPatch]
public static class BuildingEventPatches
{
@ -49,6 +50,58 @@ public static class BuildingEventPatches
_ = __instance;
}
/// <summary>
/// Civ construction finished (houses, temples, statues).
/// Nature/tree completes stay silent - not settlement spectacle.
/// </summary>
[HarmonyPatch(typeof(Building), nameof(Building.completeConstruction))]
[HarmonyPostfix]
public static void PostfixCompleteConstruction(Building __instance)
{
if (!AgentHarness.LiveFeedsAllowed || __instance?.asset == null)
{
return;
}
if (!LibraryAssetNames.IsCivBuilding(__instance.asset))
{
return;
}
string buildingId = "";
try
{
buildingId = __instance.asset.id ?? "";
}
catch
{
buildingId = "";
}
bool spectacle = LibraryAssetNames.IsSpectacleBuilding(buildingId, __instance.asset);
float strength = spectacle ? 86f : 72f;
Vector3 pos = BuildingWorldPos(__instance);
Actor follow = WorldActivityScanner.FindNearestAliveUnit(pos, 48f);
string label = EventReason.BuildingComplete(follow, buildingId, spectacle);
string key = "building:complete:"
+ (string.IsNullOrEmpty(buildingId) ? "unknown" : buildingId)
+ ":"
+ EventFeedUtil.SafeId(follow);
EventFeedUtil.Register(
key,
"building",
"Settlement",
label,
string.IsNullOrEmpty(buildingId) ? "building_complete" : buildingId,
strength,
pos,
follow,
locationOnly: follow == null,
minWatch: 6f,
maxWatch: spectacle ? 28f : 18f);
}
private static void Emit(string eventId, float strength, Actor actor, string label)
{
if (!AgentHarness.LiveFeedsAllowed)
@ -67,4 +120,28 @@ public static class BuildingEventPatches
actor.current_position,
actor);
}
private static Vector3 BuildingWorldPos(Building building)
{
try
{
if (building.current_tile != null)
{
return building.current_tile.posV3;
}
}
catch
{
// fall through
}
try
{
return building.current_position;
}
catch
{
return Vector3.zero;
}
}
}

View file

@ -13,6 +13,22 @@ public static class MetaEventPatches
MetaInterestFeed.EmitEra(ReadCurrentEraId());
}
/// <summary>
/// Ongoing quake hold. Age change is already covered by <see cref="PostfixStartNextAge"/>
/// (called from <c>WorldAgeManager.update</c> when progress completes).
/// </summary>
[HarmonyPatch(typeof(Earthquake), nameof(Earthquake.startQuake))]
[HarmonyPostfix]
public static void PostfixStartQuake(WorldTile pTile)
{
if (!Earthquake.isQuakeActive())
{
return;
}
MetaInterestFeed.EmitEarthquake(pTile);
}
[HarmonyPatch(typeof(CultureManager), nameof(CultureManager.newCulture))]
[HarmonyPostfix]
public static void PostfixNewCulture(Culture __result)

View file

@ -107,6 +107,8 @@ internal static class HarnessScenarios
return EventSuite();
case "domain_event_audit":
return DomainEventAudit();
case "library_asset_labels_ok":
return LibraryAssetLabelsOk();
case "event_tracking":
return EventTracking();
case "family_trigger_smoke":
@ -1142,6 +1144,35 @@ internal static class HarnessScenarios
Step("cf45", "assert", expect: "tip_matches_focus"),
Step("cf46", "assert", expect: "dossier_matches_focus"),
Step("cf47", "assert", expect: "has_focus", value: "true"),
// Theater lead: collective maintain must not thrash Follow across the mob.
Step("cf47a", "remember_focus"),
Step("cf47b", "combat_maintain_focus"),
Step("cf47c", "wait", value: "0.35"),
Step("cf47d", "combat_maintain_focus"),
Step("cf47e", "wait", value: "0.35"),
Step("cf47f", "combat_maintain_focus"),
Step("cf47g", "assert", expect: "focus_same"),
// Spectacle theater lead: evil mage vs human mob holds the mage, not a random human.
Step("cf48", "interest_end_session"),
Step("cf48b", "spawn", asset: "evil_mage", count: 1),
Step("cf48c", "spawn", asset: "human", count: 1),
Step("cf48d", "pick_unit", asset: "evil_mage"),
Step("cf48e", "interest_combat_session", asset: "evil_mage", value: "human", expect: "cf_mage"),
Step("cf48f", "combat_ensemble_escalate", value: "8"),
Step("cf48g", "combat_wire_attack_sides", asset: "evil_mage", value: "human"),
Step("cf48h", "wait", value: "0.35"),
Step("cf48i", "combat_maintain_focus"),
Step("cf48j", "assert", expect: "unit_asset", asset: "evil_mage"),
Step("cf48k", "remember_focus"),
Step("cf48l", "combat_maintain_focus"),
Step("cf48m", "wait", value: "0.35"),
Step("cf48n", "combat_maintain_focus"),
Step("cf48o", "wait", value: "0.35"),
Step("cf48p", "combat_maintain_focus"),
Step("cf48q", "assert", expect: "focus_same"),
Step("cf48r", "assert", expect: "unit_asset", asset: "evil_mage"),
Step("cf48s", "assert", expect: "tip_matches_any", value: "Skirmish -|Battle -|Mass -"),
// Natural Duel → multi: seed packs without tip rewrite; maintain must escalate.
Step("cf50", "interest_end_session"),
@ -1224,6 +1255,14 @@ internal static class HarnessScenarios
Step("csl45b", "assert", expect: "tip_not_contains", value: "melee"),
Step("csl46", "assert", expect: "unit_asset", value: "human|wolf"),
Step("csl47", "assert", expect: "dossier_matches_focus"),
// Theater lead hold across maintain ticks (no mob Follow thrash).
Step("csl47a", "remember_focus"),
Step("csl47b", "combat_maintain_focus"),
Step("csl47c", "wait", value: "0.35"),
Step("csl47d", "combat_maintain_focus"),
Step("csl47e", "wait", value: "0.35"),
Step("csl47f", "combat_maintain_focus"),
Step("csl47g", "assert", expect: "focus_same"),
// Chore bystander: park Follow on a sleeping rostered unit; maintain must retarget.
Step("csl48", "combat_park_bystander_follow", asset: "human", value: "wolf"),
@ -1938,10 +1977,22 @@ internal static class HarnessScenarios
{
Step("dea0", "wait_world"),
Step("dea1", "assert", expect: "domain_event_audit"),
Step("dea1b", "assert", expect: "library_asset_labels_ok"),
Step("dea1c", "assert", expect: "library_ambient_policy_ok"),
Step("dea2", "snapshot")
};
}
private static List<HarnessCommand> LibraryAssetLabelsOk()
{
return new List<HarnessCommand>
{
Step("lal0", "wait_world"),
Step("lal1", "assert", expect: "library_asset_labels_ok"),
Step("lal2", "snapshot")
};
}
private static List<HarnessCommand> EventTracking()
{
return new List<HarnessCommand>

View file

@ -32,7 +32,9 @@ public enum InterestCompletionKind
/// <summary>Sticky family pack while members / alpha remain resolvable.</summary>
FamilyPack = 9,
/// <summary>Sticky status outbreak while clustered carriers remain resolvable.</summary>
StatusOutbreak = 10
StatusOutbreak = 10,
/// <summary>Sticky while <c>Earthquake.isQuakeActive()</c> (ongoing shake poll).</summary>
EarthquakeActive = 11
}
/// <summary>
@ -68,6 +70,13 @@ public sealed class InterestCandidate
/// </summary>
public long PairOwnerId;
public long PairPartnerId;
/// <summary>
/// Collective Mass/Battle camera owner: best fighter across both sticky sides.
/// Held across attack-target gaps so maintain ticks cannot thrash the follow.
/// </summary>
public long TheaterLeadId;
/// <summary>Unscaled time the theater lead was last a live combat participant.</summary>
public float TheaterLeadLastCombatAt = -999f;
public string Label = "";
public string AssetId = "";
public string SpeciesId = "";
@ -161,7 +170,11 @@ public sealed class InterestCandidate
public bool HasStickyCombatSides => Sticky.HasOpposingSides;
public void ClearCombatSticky() => Sticky.Clear();
public void ClearCombatSticky()
{
Sticky.Clear();
ClearTheaterLead();
}
/// <summary>Lock durable 1v1 owner/partner ids (no-op when owner missing).</summary>
public void StampCombatPair(Actor owner, Actor partner)
@ -186,6 +199,23 @@ public sealed class InterestCandidate
public bool HasCombatPairLock => PairOwnerId != 0;
public void StampTheaterLead(Actor lead)
{
long id = EventFeedUtil.SafeId(lead);
if (id == 0)
{
return;
}
TheaterLeadId = id;
}
public void ClearTheaterLead()
{
TheaterLeadId = 0;
TheaterLeadLastCombatAt = -999f;
}
public InterestEvent ToInterestEvent()
{
return new InterestEvent
@ -225,6 +255,8 @@ public sealed class InterestCandidate
RelatedId = RelatedId,
PairOwnerId = PairOwnerId,
PairPartnerId = PairPartnerId,
TheaterLeadId = TheaterLeadId,
TheaterLeadLastCombatAt = TheaterLeadLastCombatAt,
Label = Label,
AssetId = AssetId,
SpeciesId = SpeciesId,

View file

@ -54,6 +54,9 @@ public static class InterestCompletion
case InterestCompletionKind.StatusOutbreak:
return StatusOutbreakStillActive(candidate);
case InterestCompletionKind.EarthquakeActive:
return EarthquakeStillActive(candidate);
case InterestCompletionKind.ActivityActive:
return ActivityStillActive(candidate);
@ -71,6 +74,19 @@ public static class InterestCompletion
}
}
private static bool EarthquakeStillActive(InterestCandidate c)
{
_ = c;
try
{
return Earthquake.isQuakeActive();
}
catch
{
return false;
}
}
private static bool CombatStillActive(InterestCandidate c)
{
Actor unit = c.FollowUnit;

View file

@ -53,6 +53,15 @@ public static class InterestDirector
private const int CombatFocusLargeParticipantThreshold = 16;
/// <summary>Require this much extra focus weight before stealing the camera mid-fight.</summary>
private const float CombatFocusSwitchMargin = 12f;
/// <summary>
/// Collective Mass/Battle: hold theater lead through brief attack_target gaps
/// so maintain ticks cannot hop across equally-scored mob fighters.
/// </summary>
private const float CombatTheaterLeadGraceSeconds = 4f;
/// <summary>Only a clearly hotter fighter may steal theater lead mid-hold.</summary>
private const float CombatTheaterLeadSwitchMargin = 40f;
/// <summary>Boost the thinner sticky side's best (1-vs-mob spectacles).</summary>
private const float CombatTheaterLeadOutnumberedBonus = 28f;
private static readonly List<InterestCandidate> PendingScratch = new List<InterestCandidate>(96);
public static string CurrentTierName => CurrentScoreLabel;
@ -1021,70 +1030,79 @@ public static class InterestDirector
_current.PairPartnerId = 0;
}
// Prefer live fighters over rostered bystanders (Idle / Following / Breeding / chores).
bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
bool bestFighting = LiveEnsemble.IsCombatParticipant(best);
if (!curFighting)
// Collective Mass/Battle: hold the theater lead (best across both sides).
// Attack-target gaps used to hop the camera across the mob every maintain tick.
if (TryApplyTheaterLeadHold(_current, ref best, ref foe, fighters))
{
if (bestFighting)
{
foe = ResolveAttackFoe(best) ?? foe;
}
else if (TryRetargetCombatFollow(_current, out Actor fighter, out Actor fighterFoe))
{
best = fighter;
foe = fighterFoe ?? foe;
if (ensemble != null)
{
label = EventReason.Combat(ensemble);
}
}
else if (curFollow != null && curFollow.isAlive())
{
// Nobody fighting - keep ownership rather than jump to a random sleeper.
best = curFollow;
foe = ResolveAttackFoe(best) ?? curRelated ?? foe;
}
// Theater lead owns Follow/Related for this maintain.
}
else if (!bestFighting)
else
{
best = curFollow;
foe = ResolveAttackFoe(best) ?? foe;
}
else if (best != curFollow
&& curFollow != null
&& curFollow.isAlive()
&& !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
{
float curW = LiveEnsemble.FocusWeight(
curFollow, _current.ParticipantIds, newcomerBonus: 0f);
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
if (newW < curW + CombatFocusSwitchMargin)
// Prefer live fighters over rostered bystanders (Idle / Following / Breeding / chores).
bool curFighting = LiveEnsemble.IsCombatParticipant(curFollow);
bool bestFighting = LiveEnsemble.IsCombatParticipant(best);
if (!curFighting)
{
best = curFollow;
if (foe == null || foe == best)
if (bestFighting)
{
foe = ResolveAttackFoe(best) ?? foe;
}
else if (TryRetargetCombatFollow(_current, out Actor fighter, out Actor fighterFoe))
{
best = fighter;
foe = fighterFoe ?? foe;
if (ensemble != null)
{
label = EventReason.Combat(ensemble);
}
}
else if (curFollow != null && curFollow.isAlive())
{
// Nobody fighting - keep ownership rather than jump to a random sleeper.
best = curFollow;
foe = ResolveAttackFoe(best) ?? curRelated ?? foe;
}
}
else if (!bestFighting)
{
best = curFollow;
foe = ResolveAttackFoe(best) ?? foe;
}
else if (best != curFollow
&& curFollow != null
&& curFollow.isAlive()
&& !ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
{
float curW = LiveEnsemble.FocusWeight(
curFollow, _current.ParticipantIds, newcomerBonus: 0f);
float newW = LiveEnsemble.FocusWeight(best, _current.ParticipantIds, newcomerBonus: 8f);
if (newW < curW + CombatFocusSwitchMargin)
{
best = curFollow;
if (foe == null || foe == best)
{
foe = ResolveAttackFoe(best) ?? foe;
}
}
}
}
// Preserve duel partner when rebuild briefly loses Related (attack_target gaps).
bool duelLabeled = !string.IsNullOrEmpty(_current.Label)
&& _current.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
if (foe == null
&& curRelated != null
&& curRelated.isAlive()
&& curRelated != best
&& (duelLabeled || _current.ForceActive || fighters <= 2))
{
foe = curRelated;
}
// Preserve duel partner when rebuild briefly loses Related (attack_target gaps).
bool duelLabeled = !string.IsNullOrEmpty(_current.Label)
&& _current.Label.StartsWith("Duel", StringComparison.OrdinalIgnoreCase);
if (foe == null
&& curRelated != null
&& curRelated.isAlive()
&& curRelated != best
&& (duelLabeled || _current.ForceActive || fighters <= 2))
{
foe = curRelated;
}
// Final pair ownership lock: Duel tips must not flip A↔B across maintain ticks.
if (ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
{
ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label);
// Final pair ownership lock: Duel tips must not flip A↔B across maintain ticks.
if (ShouldHoldDuelOwnership(_current, best, foe, fighters, curFollow, curRelated))
{
ApplyNamedPairHold(_current, curFollow, curRelated, ref best, ref foe, ref fighters, ref label);
}
}
}
@ -1789,6 +1807,272 @@ public static class InterestDirector
return false;
}
/// <summary>
/// Collective sticky Mass/Battle/Skirmish (not NamedPair duel ownership).
/// </summary>
private static bool IsCollectiveCombatTheater(InterestCandidate scene)
{
if (scene == null || scene.Completion != InterestCompletionKind.CombatActive)
{
return false;
}
if (IsNamedPairCombatOwnership(scene))
{
return false;
}
if (!HasStickyCombatSides(scene))
{
return false;
}
return HasStickyCollectiveMulti(scene)
|| scene.CombatPeakParticipants >= 3
|| scene.ParticipantCount >= 3;
}
/// <summary>
/// Score a sticky-side champion for theater-lead selection (stable, no newcomer jitter).
/// </summary>
private static float TheaterLeadScore(InterestCandidate scene, Actor actor, int ownSideCount, int foeSideCount)
{
if (actor == null || !actor.isAlive())
{
return -1f;
}
float weight = LiveEnsemble.FocusWeight(actor, scene?.ParticipantIds, newcomerBonus: 0f);
if (weight < 0f)
{
return weight;
}
// 1-vs-mob: prefer the thinner side's champion (evil mage vs humans).
if (ownSideCount > 0
&& foeSideCount > 0
&& ownSideCount < foeSideCount
&& foeSideCount >= 3)
{
weight += CombatTheaterLeadOutnumberedBonus;
}
return weight;
}
/// <summary>
/// Pick the highest-scoring living fighter across both sticky sides.
/// </summary>
private static bool TryPickTheaterLead(
InterestCandidate scene,
out Actor lead,
out Actor foe)
{
lead = null;
foe = null;
if (scene?.Sticky == null || !scene.Sticky.HasOpposingSides)
{
return false;
}
LiveSceneStickyState sticky = scene.Sticky;
Actor pickA = LiveEnsemble.FindBestAliveTracked(sticky.SideAIds, preferCombat: true);
Actor pickB = LiveEnsemble.FindBestAliveTracked(sticky.SideBIds, preferCombat: true);
if ((pickA == null || !pickA.isAlive()) && (pickB == null || !pickB.isAlive()))
{
return false;
}
int countA = Mathf.Max(1, sticky.SideACount);
int countB = Mathf.Max(1, sticky.SideBCount);
float wA = TheaterLeadScore(scene, pickA, countA, countB);
float wB = TheaterLeadScore(scene, pickB, countB, countA);
if (wB > wA)
{
lead = pickB;
foe = pickA;
}
else
{
lead = pickA;
foe = pickB;
}
return lead != null && lead.isAlive();
}
/// <summary>
/// Hold the collective theater lead through attack gaps; retarget only on death,
/// leaving the scrap past grace, or a clearly hotter fighter.
/// </summary>
private static bool TryApplyTheaterLeadHold(
InterestCandidate scene,
ref Actor best,
ref Actor foe,
int fighters)
{
if (scene == null || !IsCollectiveCombatTheater(scene))
{
if (fighters < 3 || scene == null || !HasStickyCombatSides(scene))
{
return false;
}
if (IsNamedPairCombatOwnership(scene))
{
return false;
}
}
float now = Time.unscaledTime;
Actor held = null;
if (scene.TheaterLeadId != 0)
{
held = LiveEnsemble.FindTrackedActor(scene.TheaterLeadId);
}
if (held == null || !held.isAlive())
{
held = scene.FollowUnit != null && scene.FollowUnit.isAlive()
? scene.FollowUnit
: null;
}
if (held != null && LiveEnsemble.IsCombatParticipant(held))
{
scene.TheaterLeadLastCombatAt = now;
}
bool onRoster = held != null && StickyScoreboard.IsOnRoster(scene, held);
bool inGrace = held != null
&& held.isAlive()
&& (LiveEnsemble.IsCombatParticipant(held)
|| now - scene.TheaterLeadLastCombatAt <= CombatTheaterLeadGraceSeconds);
bool holdOk = held != null && held.isAlive() && onRoster && inGrace;
if (!TryPickTheaterLead(scene, out Actor pick, out Actor pickFoe))
{
if (!holdOk)
{
return false;
}
best = held;
foe = ResolveAttackFoe(held) ?? scene.RelatedUnit ?? foe;
scene.StampTheaterLead(held);
return true;
}
if (holdOk)
{
float heldW = TheaterLeadScore(
scene,
held,
SideCountForActor(scene, held),
SideCountForOpponent(scene, held));
float pickW = TheaterLeadScore(
scene,
pick,
SideCountForActor(scene, pick),
SideCountForOpponent(scene, pick));
bool steal = pick != held
&& LiveEnsemble.IsCombatParticipant(pick)
&& pickW >= heldW + CombatTheaterLeadSwitchMargin;
if (!steal)
{
best = held;
foe = ResolveAttackFoe(held)
?? OppositeSideBest(scene, held)
?? pickFoe
?? foe;
scene.StampTheaterLead(held);
return true;
}
}
best = pick;
foe = ResolveAttackFoe(pick) ?? pickFoe ?? foe;
scene.StampTheaterLead(pick);
if (LiveEnsemble.IsCombatParticipant(pick))
{
scene.TheaterLeadLastCombatAt = now;
}
return true;
}
private static int SideCountForActor(InterestCandidate scene, Actor actor)
{
if (scene?.Sticky == null || actor == null)
{
return 1;
}
long id = EventFeedUtil.SafeId(actor);
LiveSceneStickyState sticky = scene.Sticky;
for (int i = 0; i < sticky.SideAIds.Count; i++)
{
if (sticky.SideAIds[i] == id)
{
return Mathf.Max(1, sticky.SideACount);
}
}
for (int i = 0; i < sticky.SideBIds.Count; i++)
{
if (sticky.SideBIds[i] == id)
{
return Mathf.Max(1, sticky.SideBCount);
}
}
return Mathf.Max(1, sticky.SideACount);
}
private static int SideCountForOpponent(InterestCandidate scene, Actor actor)
{
if (scene?.Sticky == null || actor == null)
{
return 1;
}
long id = EventFeedUtil.SafeId(actor);
LiveSceneStickyState sticky = scene.Sticky;
for (int i = 0; i < sticky.SideAIds.Count; i++)
{
if (sticky.SideAIds[i] == id)
{
return Mathf.Max(1, sticky.SideBCount);
}
}
return Mathf.Max(1, sticky.SideACount);
}
private static Actor OppositeSideBest(InterestCandidate scene, Actor actor)
{
if (scene?.Sticky == null || actor == null)
{
return null;
}
long id = EventFeedUtil.SafeId(actor);
LiveSceneStickyState sticky = scene.Sticky;
bool onA = false;
for (int i = 0; i < sticky.SideAIds.Count; i++)
{
if (sticky.SideAIds[i] == id)
{
onA = true;
break;
}
}
return LiveEnsemble.FindBestAliveTracked(
onA ? sticky.SideBIds : sticky.SideAIds,
preferCombat: true);
}
/// <summary>
/// Pick a living sticky-roster fighter when the current follow left the scrap.
/// </summary>
@ -2253,16 +2537,37 @@ public static class InterestDirector
{
_current.PairOwnerId = 0;
_current.PairPartnerId = 0;
_current.ClearTheaterLead();
}
_current.FollowUnit = ensemble.Focus;
_current.SubjectId = EventFeedUtil.SafeId(ensemble.Focus);
_current.RelatedUnit = ensemble.Related;
_current.RelatedId = ensemble.Related != null ? EventFeedUtil.SafeId(ensemble.Related) : 0;
Actor focus = ensemble.Focus;
Actor related = ensemble.Related;
if (IsCollectiveCombatTheater(_current)
|| (authoredN >= 3 && HasStickyCombatSides(_current)))
{
if (TryPickTheaterLead(_current, out Actor lead, out Actor leadFoe))
{
focus = lead;
related = ResolveAttackFoe(lead) ?? leadFoe ?? related;
_current.StampTheaterLead(lead);
if (LiveEnsemble.IsCombatParticipant(lead))
{
_current.TheaterLeadLastCombatAt = Time.unscaledTime;
}
ensemble.Focus = focus;
ensemble.Related = related;
}
}
_current.FollowUnit = focus;
_current.SubjectId = EventFeedUtil.SafeId(focus);
_current.RelatedUnit = related;
_current.RelatedId = related != null ? EventFeedUtil.SafeId(related) : 0;
_current.Label = EventReason.Combat(ensemble);
try
{
_current.Position = ensemble.Focus.current_position;
_current.Position = focus.current_position;
}
catch
{
@ -3045,6 +3350,7 @@ public static class InterestDirector
case InterestCompletionKind.CombatActive:
case InterestCompletionKind.WarFront:
case InterestCompletionKind.PlotActive:
case InterestCompletionKind.EarthquakeActive:
classCap = w.maxWatchCombat;
break;
case InterestCompletionKind.FamilyPack:

View file

@ -0,0 +1,314 @@
using System;
using System.Collections.Generic;
using System.Reflection;
namespace IdleSpectator;
/// <summary>
/// Localized / humanized display names for live AssetManager library ids.
/// Used by library event Labels so watch tips never show raw snake_case when the game has a name.
/// </summary>
public static class LibraryAssetNames
{
private static readonly string[] LibraryKinds =
{
"spell", "power", "item", "gene", "phenotype", "worldlaw", "subspecies_trait",
"culture_trait", "religion_trait", "clan_trait", "language_trait", "biome", "building"
};
public static IReadOnlyList<string> AllKinds => LibraryKinds;
/// <summary>Spectator-facing name for a library asset id (localized when available).</summary>
public static string DisplayName(string kind, string assetId)
{
if (string.IsNullOrEmpty(assetId))
{
return "";
}
object asset = TryGetAsset(kind, assetId);
string localized = TryLocalizedName(asset);
if (!string.IsNullOrEmpty(localized))
{
return localized;
}
return EventReason.HumanizeId(assetId);
}
public static object TryGetAsset(string kind, string assetId)
{
if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(assetId))
{
return null;
}
string id = assetId.Trim();
string k = kind.Trim().ToLowerInvariant();
try
{
switch (k)
{
case "spell":
return AssetManager.spells?.get(id);
case "power":
return AssetManager.powers?.get(id);
case "item":
return AssetManager.items?.get(id);
case "gene":
return AssetManager.gene_library?.get(id);
case "phenotype":
return AssetManager.phenotype_library?.get(id);
case "worldlaw":
case "world_law":
return AssetManager.world_laws_library?.get(id);
case "subspecies_trait":
return AssetManager.subspecies_traits?.get(id);
case "culture_trait":
return AssetManager.culture_traits?.get(id);
case "religion_trait":
return AssetManager.religion_traits?.get(id);
case "clan_trait":
return AssetManager.clan_traits?.get(id);
case "language_trait":
return AssetManager.language_traits?.get(id);
case "biome":
return AssetManager.biome_library?.get(id);
case "building":
return AssetManager.buildings?.get(id);
case "trait":
return AssetManager.traits?.get(id);
default:
return null;
}
}
catch
{
return null;
}
}
public static List<string> EnumerateLiveIds(string kind)
{
string k = (kind ?? "").Trim().ToLowerInvariant();
switch (k)
{
case "spell":
return ActivityAssetCatalog.EnumerateLiveSpellIds();
case "power":
return ActivityAssetCatalog.EnumerateLivePowerIds();
case "item":
return ActivityAssetCatalog.EnumerateLiveItemIds();
case "gene":
return ActivityAssetCatalog.EnumerateLiveGeneIds();
case "phenotype":
return ActivityAssetCatalog.EnumerateLivePhenotypeIds();
case "worldlaw":
case "world_law":
return ActivityAssetCatalog.EnumerateLiveWorldLawIds();
case "subspecies_trait":
return ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds();
case "culture_trait":
return ActivityAssetCatalog.EnumerateLiveCultureTraitIds();
case "religion_trait":
return ActivityAssetCatalog.EnumerateLiveReligionTraitIds();
case "clan_trait":
return ActivityAssetCatalog.EnumerateLiveClanTraitIds();
case "language_trait":
return ActivityAssetCatalog.EnumerateLiveLanguageTraitIds();
case "biome":
return ActivityAssetCatalog.EnumerateLiveBiomeIds();
case "building":
return ActivityAssetCatalog.EnumerateLiveBuildingIds();
default:
return new List<string>();
}
}
/// <summary>
/// Item watch verb from live asset shape: equipment/weapons/armor gain; forge-y ids forge; else gain.
/// </summary>
public static string ItemVerb(string itemId)
{
if (string.IsNullOrEmpty(itemId))
{
return "gains";
}
string s = itemId.ToLowerInvariant();
if (s.Contains("forge") || s.Contains("smith") || s.Contains("anvil"))
{
return "forges";
}
try
{
ItemAsset item = AssetManager.items?.get(itemId.Trim());
if (item != null)
{
string subtype = item.equipment_subtype ?? "";
string material = item.material ?? "";
if (!string.IsNullOrEmpty(subtype)
|| item.equipment_value > 0
|| s.StartsWith("armor_", StringComparison.Ordinal)
|| s.StartsWith("weapon_", StringComparison.Ordinal)
|| s.StartsWith("helmet_", StringComparison.Ordinal)
|| s.StartsWith("boots_", StringComparison.Ordinal)
|| s.StartsWith("ring_", StringComparison.Ordinal)
|| s.StartsWith("amulet_", StringComparison.Ordinal))
{
return "gains";
}
_ = material;
}
}
catch
{
// fall through
}
return "gains";
}
public static bool IsSpectacleBuilding(string buildingId, BuildingAsset asset)
{
string id = (buildingId ?? "").ToLowerInvariant();
if (id.StartsWith("temple_", StringComparison.Ordinal)
|| id == "statue"
|| id.Contains("statue")
|| id.Contains("monument")
|| id.Contains("wonder"))
{
return true;
}
try
{
string group = asset != null ? (asset.group ?? "") : "";
if (group.IndexOf("temple", StringComparison.OrdinalIgnoreCase) >= 0
|| group.IndexOf("wonder", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
catch
{
// ignore
}
return false;
}
public static bool IsCivBuilding(BuildingAsset asset)
{
if (asset == null)
{
return false;
}
try
{
return asset.building_type == BuildingType.Building_Civ;
}
catch
{
try
{
string type = asset.type ?? "";
return type.IndexOf("civ", StringComparison.OrdinalIgnoreCase) >= 0;
}
catch
{
return false;
}
}
}
private static string TryLocalizedName(object asset)
{
if (asset == null)
{
return "";
}
try
{
MethodInfo translated = asset.GetType().GetMethod(
"getTranslatedName",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (translated != null && translated.GetParameters().Length == 0)
{
string name = translated.Invoke(asset, null) as string;
if (IsUsableDisplay(name, asset))
{
return ActivityAssetCatalog.TitleCaseWords(name.Trim());
}
}
}
catch
{
// fall through
}
try
{
MethodInfo locale = asset.GetType().GetMethod(
"getLocaleID",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (locale != null && locale.GetParameters().Length == 0)
{
string localeId = locale.Invoke(asset, null) as string;
if (!string.IsNullOrEmpty(localeId))
{
string text = LocalizedTextManager.getText(localeId);
if (IsUsableDisplay(text, asset) && !string.Equals(text, localeId, StringComparison.Ordinal))
{
return ActivityAssetCatalog.TitleCaseWords(text.Trim());
}
}
}
}
catch
{
// fall through
}
return "";
}
private static bool IsUsableDisplay(string name, object asset)
{
if (string.IsNullOrEmpty(name))
{
return false;
}
string trimmed = name.Trim();
if (trimmed.Length == 0 || trimmed.IndexOf('_') >= 0)
{
return false;
}
try
{
PropertyInfo idProp = asset.GetType().GetProperty("id");
FieldInfo idField = asset.GetType().GetField("id");
string id = idProp != null
? idProp.GetValue(asset) as string
: idField != null
? idField.GetValue(asset) as string
: null;
if (!string.IsNullOrEmpty(id) && string.Equals(trimmed, id, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
catch
{
// keep name
}
return true;
}
}

View file

@ -99,6 +99,8 @@ public static class MutationDiscoveryHarness
"BookManager.generateNewBook",
"BookManager.burnBook",
"WorldAgeManager.startNextAge",
"Earthquake.startQuake",
"Earthquake.spawnQuake",
"CultureManager.newCulture",
"ReligionManager.newReligion",
"LanguageManager.newLanguage",
@ -118,6 +120,7 @@ public static class MutationDiscoveryHarness
"BehDealDamageToTargetBuilding.execute",
"BehConsumeTargetBuilding.execute",
"Building.startDestroyBuilding",
"Building.completeConstruction",
"BehBoatTransportUnloadUnits.execute",
"BehBoatMakeTrade.execute",
"BehBoatTransportDoLoading.execute",
@ -454,7 +457,7 @@ public static class MutationDiscoveryHarness
|| name == "units"
|| name == "subspecies"
|| name == "plots"
// Meta genesis Harmony covers new*; ongoing poll is optional fill.
// Meta genesis Harmony covers new*; era/quake have dedicated wires.
|| name == "alliances"
|| name == "clans"
|| name == "cultures"
@ -467,7 +470,9 @@ public static class MutationDiscoveryHarness
|| name == "kingdoms"
|| name == "items"
|| name == "buildings"
|| name == "diplomacy";
|| name == "diplomacy"
|| name == "era_manager"
|| name == "earthquake_manager";
managersTsv.Append("World").Append('\t')
.Append(name).Append('\t')
.Append("0").Append('\t')

View file

@ -597,6 +597,15 @@ public static class WorldActivityScanner
weight += 30f;
}
// Spectacle creatures (evil mage, dragon, …) win theater-lead picks in mob fights.
// Use a dedicated floor so char-only weights among civs cannot outrank them.
if (IsSpectacle(actor))
{
ScoringWeights w = InterestScoringConfig.W;
float spectacle = w.scannerSpectacleBonus > 0f ? w.scannerSpectacleBonus : 12f;
weight += Mathf.Max(35f, spectacle);
}
return weight;
}

View file

@ -3606,7 +3606,7 @@
"signalStrength": 86,
"category": "Spectacle",
"label": "{a} casts {id}",
"ambientCreatesInterest": true,
"ambientCreatesInterest": false,
"signalIds": [
"summon_lightning",
"summon_tornado",
@ -3628,36 +3628,36 @@
"ambientStrength": 34,
"signalStrength": 76,
"category": "Item",
"label": "{a} forges {id}",
"ambientCreatesInterest": true
"label": "{a} gains {id}",
"ambientCreatesInterest": false
},
"gene": {
"ambientStrength": 28,
"signalStrength": 64,
"category": "Genetics",
"label": "{a} gains gene {id}",
"ambientCreatesInterest": true
"ambientCreatesInterest": false
},
"phenotype": {
"ambientStrength": 26,
"signalStrength": 55,
"category": "Genetics",
"label": "{a} shows {id}",
"ambientCreatesInterest": true
"ambientCreatesInterest": false
},
"worldLaw": {
"ambientStrength": 36,
"signalStrength": 68,
"category": "WorldLaw",
"label": "Law changed: {id}",
"ambientCreatesInterest": true
"ambientCreatesInterest": false
},
"subspeciesTrait": {
"ambientStrength": 32,
"signalStrength": 72,
"category": "Evolution",
"label": "{a} evolves {id}",
"ambientCreatesInterest": true
"ambientCreatesInterest": false
},
"biome": {
"ambientStrength": 22,
@ -3671,28 +3671,28 @@
"signalStrength": 74,
"category": "Culture",
"label": "{a} culture gains {id}",
"ambientCreatesInterest": true
"ambientCreatesInterest": false
},
"religionTrait": {
"ambientStrength": 34,
"signalStrength": 74,
"category": "Religion",
"label": "{a} faith gains {id}",
"ambientCreatesInterest": true
"ambientCreatesInterest": false
},
"clanTrait": {
"ambientStrength": 32,
"signalStrength": 72,
"category": "Clan",
"label": "{a} clan gains {id}",
"ambientCreatesInterest": true
"ambientCreatesInterest": false
},
"languageTrait": {
"ambientStrength": 30,
"signalStrength": 68,
"category": "Language",
"label": "{a} tongue gains {id}",
"ambientCreatesInterest": true
"ambientCreatesInterest": false
}
}
}

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.28.13",
"version": "0.28.18",
"description": "AFK Idle Spectator (I) + Lore (L). Killer button retargets dossier to the killer.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -0,0 +1,212 @@
# Event catalog discovery
Live gap map of AssetManager libraries, mutation hooks, and IdleSpectator event-catalog coverage.
Expansion of [`event-catalog.json`](../IdleSpectator/event-catalog.json) should cite this ledger, not guessed id lists.
Audit date: 2026-07-17.
WorldBox harness refresh: `mutation_discovery`, `domain_event_audit`, `activity_happiness_audit`, `activity_status_audit`, `world_log_audit`, `disaster_war_audit`, `activity_taxonomy_audit` (all PASS).
Artifacts: `IdleSpectator/.harness/` (especially `mutation_*.tsv`, `domain_event_audit.tsv`).
Machine worksheet: [`event-catalog-discovery.tsv`](event-catalog-discovery.tsv).
## Verdict
**Id inventory is complete for every EventCatalog domain.**
Authored 1:1 libraries match live counts with zero missing/orphan ids.
Live+dial libraries are fully seeded.
`mutation_gaps.tsv` is empty (`gaps=0`).
The feeling of “lots of events/tips we are not tracking” is mostly **tip quality and Layer A noise**, not missing catalog rows:
1. Library Labels still interpolate raw `{id}` (`culture gains xenophobic`, `forges armor_leather`).
2. Large ambient-A surfaces (items, genes, phenotypes, subspecies traits, meta traits, world laws) own the camera with generic templates.
3. Building **complete / wonder finish** still has no Layer A wire (destroy/consume only).
4. Meta-trait domains are live-seeded and TSV-audited but **not fail-gated** by `domain_event_audit`.
Tips here means watch tip / orange reason Labels from interest feeds, not dossier chip hover tooltips.
## How to read priorities
| Priority | Meaning |
|----------|---------|
| P0 | Missing wire or authored sync that drops a real lifecycle beat |
| P1 | Tip/prose quality for already-seeded high-visibility classes |
| P2 | New surfaces or Signal tuning after P0/P1 |
| P3 | Intentional Layer B / covered-by another feed |
## Authored 1:1 domains
Live audits: `missingAuthored=0` / `orphan=0` for all closed libraries.
| Domain | Live | Authored | Catalog mode | Camera policy (approx) | Priority |
|--------|-----:|---------:|--------------|------------------------|----------|
| happiness | 67 | 67 | authored | Signal 49 / Aggregate 13 / Ambient 5 | P3 sync done |
| status | 58 | 58 | authored | CreatesInterest 42; B FX 16 | P3 sync done |
| worldLog | 41 | 41 | authored | CreatesInterest 40 (`auto_tester` B) | P3 sync done |
| plots | 28 | 28 | authored | all A | P3 sync done |
| eras | 11 | 11 | authored | all A | P3 sync done |
| books | 12 | 12 | authored | all A | P3 sync done |
| traits | 116 | 116 | authored | all A | P3 sync done |
| disasters | 17 | 17 | authored | via WorldLog pairs | P3 sync done |
| warTypes | 5 | 5 | authored | dials | P3 sync done |
| relationship | n/a | 11 | authored discrete | all A | P3 sync done |
Happiness Ambient (stay B): `got_poked`, `got_caught`, `paid_tax`, `just_ate`, `just_pooped`.
Status B: recovery_*, egg gain, spell_silence/boost, on_guard, just_ate, afterglow, slowness, cough, shield, dodge, dash, flicked.
## Live + dial domains
Seeded via `LiveLibraryInterest.EnsureSeeded` + JSON `libraries.*` dials.
`authored=1` in `domain_event_audit.tsv` means live-seeded entry exists, not a per-id JSON prose row.
| Domain | Live | Camera A | Dial label template | Notes | Priority |
|--------|-----:|---------:|---------------------|-------|----------|
| decisions | 127 | 12 | `{a} decides to {action}` | 12 camera ids have action phrases; 2 JSON overlays are non-camera (`try_new_plot`, `warrior_train_with_dummy`) | P1 only if new interesting decisions appear |
| spells | 12 | 12 | `{a} casts {id}` | 7 signalIds @86; 5 ambient A @44 | P1 display names; P2 signal set |
| powers | 339 | 23 | `God power: {id}` | ambient CI=false; spectacle predicate | P1 display names |
| items | 111 | 111 | `{a} forges {id}` | ambient A; “forges” weak for armor/non-craft | P1 tip quality |
| genes | 47 | 47 | `{a} gains gene {id}` | ambient A | P1 names; P2 demote ambient spam |
| phenotypes | 52 | 52 | `{a} shows {id}` | ambient A | P1 names; P2 demote ambient spam |
| world_laws | 49 | 49 | `Law changed: {id}` | ambient A | P1 names |
| subspecies_traits | 204 | 204 | `{a} evolves {id}` | ambient A | P1 names; P2 Signal heuristics |
| biomes | 29 | 0 | `Biome shifts: {id}` | ambient CI=false | P3 stay B |
| culture_traits | 78 | 78 | `{a} culture gains {id}` | ~14 dramatic @74; rest ambient @34 | P1 names; hygiene gate |
| religion_traits | 40 | 40 | `{a} faith gains {id}` | dramatic heuristic | P1 names; hygiene gate |
| clan_traits | 29 | 29 | `{a} clan gains {id}` | dramatic heuristic | P1 names; hygiene gate |
| language_traits | 26 | 26 | `{a} tongue gains {id}` | dramatic heuristic | P1 names; hygiene gate |
Decision camera set (all have authored action phrases):
`try_to_steal_money`, `kill_unruly_clan_members`, `banish_unruly_clan_members`, `find_lover`,
`king_change_kingdom_{language,culture,religion}`, `leader_change_city_{language,culture,religion}`,
`warrior_try_join_army_group`, `warrior_army_follow_leader`.
Spell signalIds (strength 86): `summon_lightning`, `summon_tornado`, `cast_curse`, `cast_fire`, `cast_blood_rain`, `summon_meteor`, `teleport`.
Ambient-A spells @44: `cast_silence`, `cast_grass_seeds`, `spawn_vegetation`, `spawn_skeleton`, `cast_shield`, `cast_cure`.
## Patch-only / no catalog rows
| Surface | Live / notes | Wire status | Priority |
|---------|--------------|-------------|----------|
| buildings | 618 | consume A; ambient destroy demoted B; **no complete/wonder A** | P0 complete/wonder |
| boats | patch | trade/unload A; load B | P3 |
| combat | policy in `EventCatalog.Combat` | scanner + fight feeds | P3 |
| architecture / city_build_orders | 53 / 3 | covered_by_building_activity | P3 |
| kingdoms_traits | 5 | covered_by_trait_meta_feeds | P3 |
| items_modifiers | 59 | covered_by_item_feed | P3 |
| combat_action_library | 11 | covered_by_spell_combat_feeds | P2 only if distinct beats |
| loyalty / opinion / knowledge / communication / story | 29/24/11/10/1 | covered_by_worldlog_happiness | P3 |
| citizen_job / professions / personalities | 13/5/5 | Activity/dossier labels | P3 |
| tasks_actor | 217 | Activity taxonomy (unmappedTasks=0) | P3 |
| actor_library | 322 | SpeciesDiscovery / voice (taxonomy PASS) | P3 |
| plot_category_library | 9 | categories only | P3 |
WorldLog already covers end beats that inventory once flagged as open wire work:
`alliance_dissolved`, `kingdom_destroyed`, `kingdom_shattered`, `kingdom_fractured`, `city_destroyed`.
Manager `removeObject` is intentionally not event-worthy in `MutationDiscoveryHarness` because those WorldLog rows exist.
## Mutation discovery
| Metric | Value |
|--------|------:|
| managers | 57 |
| managerMethods | 189 |
| actorMethods | 256 |
| beh | 93 |
| libraries | 39 |
| libraryAssets | 2893 |
| gaps | 0 |
`World.era_manager` / `World.earthquake_manager` report `null` on this build (unwired PollState, not gap rows).
Age change remains wired via `WorldAgeManager.startNextAge`.
Ongoing era/earthquake poll is still optional fill (P2), not a missing id catalog row.
Unwired ManagerEnd / Beh rows exist in TSVs but are filtered out of `mutation_gaps` by design (Beh → activity path; `removeObject` → WorldLog).
## Tip quality gap (main expansion lever)
`EventReason` / discrete `MakeLabel` replace `{id}` with the raw asset id.
Status/trait Activity and dossier paths already use locale/`getTranslatedName` helpers (`LiveEnsemble.StatusDisplayName`, trait display names).
Library event Labels do not.
Examples of weak watch tips today:
- `{a} culture gains xenophobic` instead of a localized culture-trait name
- `{a} forges armor_leather` (wrong verb + raw id)
- `{a} gains gene warfare_3`
- `God power: atomic_bomb` (readable but not localized)
Recommended class fix: resolve `{id}` through live asset display names inside Label builders for library domains (same pattern as status), not thousands of JSON rows.
## Audit hygiene
`DomainEventHarness` diffs culture/religion/clan/language traits into `domain_event_audit.tsv` but omits those diffs from `Passed`.
If meta traits are first-class EventCatalog domains (they are), the pass condition should include them.
## Expansion waves
### Wave 0 - done by this audit
Authored 1:1 sync and live seed inventory: no missing ids.
### Wave 1 (first implementation PR) - tip quality + building complete
Status (2026-07-17): **implemented** in mod `0.28.14`.
1. `LibraryAssetNames` + `EventReason.Library` resolve live display names (localized when available, else humanized).
2. Item verb is `gains` by default; `forges` only for forge/smith/anvil ids. Catalog template `{a} gains {id}`.
3. `Building.completeConstruction` wires Layer A for `Building_Civ` (temple/statue spectacle strength bump).
4. `domain_event_audit` fail-gates culture/religion/clan/language trait diffs; `library_asset_labels_ok` dumps `.harness/library-asset-labels.tsv`.
### Wave 2 - Signal / ambient A tuning
Status (2026-07-17): **implemented** in mod `0.28.16`.
1. `ambientCreatesInterest=false` for spell/item/gene/phenotype/worldLaw/subspecies/meta trait dials (Signal predicates / signalIds own Layer A).
2. Spells: `IsSpectacleSpell` keeps summon/meteor/tornado/lightning/teleport/curse/blood/fire as A; silence/shield/cure/grass/vegetation/skeleton stay B.
3. World laws: dramatic class includes hunger/rebellion/disaster/angry (plus war/death/plague).
4. Phenotypes: all B (no Signal predicate).
5. Items: Signal for mythril/adamantine (+ named relic tokens); leather/iron ambient B.
6. JSON loader now ingests culture/religion/clan/language trait dials; default ambient CI is false.
7. Gate: `library_ambient_policy_ok` (nested in `domain_event_audit`).
Era change was already wired (`WorldAgeManager.update` → `startNextAge`).
Earthquake ongoing hold: **implemented** (`Earthquake.startQuake` → `EarthquakeActive` sticky) in `0.28.17`.
### Wave 3 - new surfaces only if playtests demand
Status (2026-07-17): **triaged closed** - no new EventCatalog domains.
| Surface | Live | Decision |
|---------|------|----------|
| combat_action_library (11) | melee/range/spell/dodge/… | Covered by combat scanner + `tryToCastSpell` → spell catalog |
| story / knowledge / communication / loyalty / opinion | small libs | Covered by WorldLog / happiness civic paths |
| personalities / professions | 5 / 5 | Activity/dossier labels only |
| architecture / city_build_orders | build helpers | Building complete / consume path |
Do not mass-author powers/items/buildings into JSON.
## First implementation PR recommendation
**Title focus:** Improve library event tip labels and wire building completion.
**Scope:**
- Display-name resolution for library Labels (class fix)
- Item label template improvement
- Building complete / wonder Layer A path + harness gate
- `domain_event_audit` pass includes culture/religion/clan/language trait diffs
- Refresh this doc + [`world-event-inventory.md`](world-event-inventory.md) counts
**Out of scope for that PR:** mass JSON rows; scoring-model changes; dossier chip tooltips.
## Refresh commands
```bash
./scripts/worldbox-ctl.sh status
./scripts/harness-run.sh --no-launch mutation_discovery
./scripts/harness-run.sh --no-launch domain_event_audit
./scripts/harness-run.sh --no-launch activity_happiness_audit
./scripts/harness-run.sh --no-launch activity_status_audit
./scripts/harness-run.sh --no-launch world_log_audit
./scripts/harness-run.sh --no-launch disaster_war_audit
./scripts/harness-run.sh --no-launch activity_taxonomy_audit
```

View file

@ -0,0 +1,52 @@
library_or_hook live_count catalog_mode missing_ids camera_a wire_status tip_quality priority notes
happiness_library 67 authored 0 49_Signal wired authored_prose P3 Ambient5 Aggregate13; audit PASS
status 58 authored 0 42 wired authored_prose P3 16 CreatesInterest=false FX; audit PASS
world_log_library 41 authored 0 40 wired authored_labels P3 auto_tester B; audit PASS
plots_library 28 authored 0 28 wired authored_labels P3 domain_event_audit PASS
era_library 11 authored 0 11 wired authored_labels P3 age change via WorldAgeManager; era_manager field null
book_types 12 authored 0 12 wired authored_labels P3
traits 116 authored 0 116 wired authored_labels P3
disasters 17 authored 0 via_worldlog wired authored_labels P3 disaster_war_audit PASS
war_types_library 5 authored 0 dials wired authored_labels P3
relationship 11 authored_discrete 0 11 wired authored_labels P3 no AssetManager lib
decisions_library 127 live+dial 0 12 wired 12_action_phrases P1 try_new_plot and warrior_train_with_dummy authored non-camera
spells 12 live+dial 0 12 wired raw_id_in_label P1 7 signalIds@86; 5 ambient@44
powers 339 live+dial 0 23 wired raw_id_in_label P1 ambient CI=false
items 111 live+dial 0 111 wired raw_id_forges_verb P1 ambient A; forge verb weak
genes 47 live+dial 0 47 wired raw_id_in_label P1/P2 ambient A spam risk
phenotypes 52 live+dial 0 52 wired raw_id_in_label P1/P2 ambient A spam risk
world_laws_library 49 live+dial 0 49 wired raw_id_in_label P1/P2 ambient A
subspecies_traits 204 live+dial 0 204 wired raw_id_in_label P1/P2 ambient A
biome_library 29 live+dial 0 0 wired raw_id_template P3 ambient CI=false
culture_traits 78 live+dial 0 78 wired raw_id_in_label P1 TSV audited; not fail-gated
religion_traits 40 live+dial 0 40 wired raw_id_in_label P1 TSV audited; not fail-gated
clan_traits 29 live+dial 0 29 wired raw_id_in_label P1 TSV audited; not fail-gated
language_traits 26 live+dial 0 26 wired raw_id_in_label P1 TSV audited; not fail-gated
buildings 618 patch_only n/a consume_and_civ_complete partial EventReason.BuildingEat_BuildingComplete P3 civ complete/wonder A; destroy demoted B
boats n/a patch_only n/a trade_unload wired EventReason.Boat P3 load skipped
combat n/a csharp_policy n/a pair_keys wired EventReason.Fight P3
architecture_library 53 none n/a 0 covered_by_building_activity n/a P3
city_build_orders 3 none n/a 0 covered_by_building_activity n/a P3
kingdoms_traits 5 none n/a 0 covered_by_trait_meta_feeds n/a P3
items_modifiers 59 none n/a 0 covered_by_item_feed n/a P3
combat_action_library 11 none n/a 0 covered_by_spell_combat_feeds n/a P2 only if distinct beats
loyalty_library 29 none n/a 0 covered_by_worldlog_happiness n/a P3
opinion_library 24 none n/a 0 covered_by_worldlog_happiness n/a P3
knowledge_library 11 none n/a 0 covered_by_worldlog_happiness n/a P3
communication_library 10 none n/a 0 covered_by_worldlog_happiness n/a P3
story_library 1 none n/a 0 covered_by_worldlog_happiness n/a P3
citizen_job_library 13 none n/a 0 covered_by_activity_dossier n/a P3
professions 5 none n/a 0 covered_by_activity_dossier n/a P3
personalities 5 none n/a 0 covered_by_activity_dossier n/a P3
tasks_actor 217 none n/a 0 activity_taxonomy n/a P3 unmappedTasks=0
actor_library 322 scoring_overlay n/a 0 species_discovery n/a P3 taxonomy PASS
plot_category_library 9 none n/a 0 category_only n/a P3
AllianceManager.removeObject n/a worldlog 0 via_alliance_dissolved worldlog_covers authored_worldlog P3 not mutation gap
KingdomManager.removeObject n/a worldlog 0 via_kingdom_destroyed worldlog_covers authored_worldlog P3 not mutation gap
building_complete_wonder n/a patch n/a civ_complete Building.completeConstruction EventReason.BuildingComplete P3 temple/statue spectacle strength
World.era_manager_poll n/a wired n/a era_change WorldAgeManager.startNextAge EventCatalog.Era P3 update→startNextAge already emits
World.earthquake_manager_poll n/a wired n/a quake_sticky Earthquake.startQuake EarthquakeActive P3 holds while isQuakeActive
combat_action_library 11 covered_by_spell_combat n/a 0 CombatActionLibrary.tryToCastSpell n/a P3 no separate catalog
story_knowledge_opinion n/a covered_by_worldlog_happiness n/a 0 n/a n/a P3 Wave3 closed
personalities_professions 5+5 covered_by_activity_dossier n/a 0 n/a n/a P3 Wave3 closed
mutation_gaps 0 harness 0 n/a gaps_empty n/a P3 managers=57 libraryAssets=2893
1 library_or_hook live_count catalog_mode missing_ids camera_a wire_status tip_quality priority notes
2 happiness_library 67 authored 0 49_Signal wired authored_prose P3 Ambient5 Aggregate13; audit PASS
3 status 58 authored 0 42 wired authored_prose P3 16 CreatesInterest=false FX; audit PASS
4 world_log_library 41 authored 0 40 wired authored_labels P3 auto_tester B; audit PASS
5 plots_library 28 authored 0 28 wired authored_labels P3 domain_event_audit PASS
6 era_library 11 authored 0 11 wired authored_labels P3 age change via WorldAgeManager; era_manager field null
7 book_types 12 authored 0 12 wired authored_labels P3
8 traits 116 authored 0 116 wired authored_labels P3
9 disasters 17 authored 0 via_worldlog wired authored_labels P3 disaster_war_audit PASS
10 war_types_library 5 authored 0 dials wired authored_labels P3
11 relationship 11 authored_discrete 0 11 wired authored_labels P3 no AssetManager lib
12 decisions_library 127 live+dial 0 12 wired 12_action_phrases P1 try_new_plot and warrior_train_with_dummy authored non-camera
13 spells 12 live+dial 0 12 wired raw_id_in_label P1 7 signalIds@86; 5 ambient@44
14 powers 339 live+dial 0 23 wired raw_id_in_label P1 ambient CI=false
15 items 111 live+dial 0 111 wired raw_id_forges_verb P1 ambient A; forge verb weak
16 genes 47 live+dial 0 47 wired raw_id_in_label P1/P2 ambient A spam risk
17 phenotypes 52 live+dial 0 52 wired raw_id_in_label P1/P2 ambient A spam risk
18 world_laws_library 49 live+dial 0 49 wired raw_id_in_label P1/P2 ambient A
19 subspecies_traits 204 live+dial 0 204 wired raw_id_in_label P1/P2 ambient A
20 biome_library 29 live+dial 0 0 wired raw_id_template P3 ambient CI=false
21 culture_traits 78 live+dial 0 78 wired raw_id_in_label P1 TSV audited; not fail-gated
22 religion_traits 40 live+dial 0 40 wired raw_id_in_label P1 TSV audited; not fail-gated
23 clan_traits 29 live+dial 0 29 wired raw_id_in_label P1 TSV audited; not fail-gated
24 language_traits 26 live+dial 0 26 wired raw_id_in_label P1 TSV audited; not fail-gated
25 buildings 618 patch_only n/a consume_and_civ_complete partial EventReason.BuildingEat_BuildingComplete P3 civ complete/wonder A; destroy demoted B
26 boats n/a patch_only n/a trade_unload wired EventReason.Boat P3 load skipped
27 combat n/a csharp_policy n/a pair_keys wired EventReason.Fight P3
28 architecture_library 53 none n/a 0 covered_by_building_activity n/a P3
29 city_build_orders 3 none n/a 0 covered_by_building_activity n/a P3
30 kingdoms_traits 5 none n/a 0 covered_by_trait_meta_feeds n/a P3
31 items_modifiers 59 none n/a 0 covered_by_item_feed n/a P3
32 combat_action_library 11 none n/a 0 covered_by_spell_combat_feeds n/a P2 only if distinct beats
33 loyalty_library 29 none n/a 0 covered_by_worldlog_happiness n/a P3
34 opinion_library 24 none n/a 0 covered_by_worldlog_happiness n/a P3
35 knowledge_library 11 none n/a 0 covered_by_worldlog_happiness n/a P3
36 communication_library 10 none n/a 0 covered_by_worldlog_happiness n/a P3
37 story_library 1 none n/a 0 covered_by_worldlog_happiness n/a P3
38 citizen_job_library 13 none n/a 0 covered_by_activity_dossier n/a P3
39 professions 5 none n/a 0 covered_by_activity_dossier n/a P3
40 personalities 5 none n/a 0 covered_by_activity_dossier n/a P3
41 tasks_actor 217 none n/a 0 activity_taxonomy n/a P3 unmappedTasks=0
42 actor_library 322 scoring_overlay n/a 0 species_discovery n/a P3 taxonomy PASS
43 plot_category_library 9 none n/a 0 category_only n/a P3
44 AllianceManager.removeObject n/a worldlog 0 via_alliance_dissolved worldlog_covers authored_worldlog P3 not mutation gap
45 KingdomManager.removeObject n/a worldlog 0 via_kingdom_destroyed worldlog_covers authored_worldlog P3 not mutation gap
46 building_complete_wonder n/a patch n/a civ_complete Building.completeConstruction EventReason.BuildingComplete P3 temple/statue spectacle strength
47 World.era_manager_poll n/a wired n/a era_change WorldAgeManager.startNextAge EventCatalog.Era P3 update→startNextAge already emits
48 World.earthquake_manager_poll n/a wired n/a quake_sticky Earthquake.startQuake EarthquakeActive P3 holds while isQuakeActive
49 combat_action_library 11 covered_by_spell_combat n/a 0 CombatActionLibrary.tryToCastSpell n/a P3 no separate catalog
50 story_knowledge_opinion n/a covered_by_worldlog_happiness n/a 0 n/a n/a P3 Wave3 closed
51 personalities_professions 5+5 covered_by_activity_dossier n/a 0 n/a n/a P3 Wave3 closed
52 mutation_gaps 0 harness 0 n/a gaps_empty n/a P3 managers=57 libraryAssets=2893

View file

@ -2,8 +2,9 @@
Source of truth for what IdleSpectator covers across the **whole** game.
Refresh via live libraries + `mutation_discovery` / domain audits under `.harness/`.
Full gap map and expansion waves: [`event-catalog-discovery.md`](event-catalog-discovery.md).
Audit date: 2026-07-16.
Audit date: 2026-07-17.
## Policy
@ -24,57 +25,65 @@ Only true brief FX, cooldowns, civic Aggregates, and god-tool / biome spam stay
| Library | ~Count | Owner |
|---------|-------:|-------|
| happiness_library | 67 | EventCatalog.Happiness |
| status | 58 | EventCatalog.Status |
| world_log_library | 41 | EventCatalog.WorldLog |
| happiness_library | 67 | EventCatalog.Happiness (1:1 authored) |
| status | 58 | EventCatalog.Status (1:1 authored) |
| world_log_library | 41 | EventCatalog.WorldLog (1:1 authored) |
| disasters | 17 | via WorldLog |
| war_types_library | 5 | EventCatalog.WarType |
| traits | 116 | EventCatalog.Trait (all CI; strength ranks) |
| plots_library | 28 | EventCatalog.Plot |
| era_library | 11 | EventCatalog.Era |
| book_types | 12 | EventCatalog.Book (all A) |
| decisions / powers / spells / items | 127 / 339 / 12 / 111 | EventCatalog.Libraries |
| decisions / powers / spells / items | 127 / 339 / 12 / 111 | EventCatalog.Libraries / Decision |
| subspecies_traits / genes / phenotypes | 204 / 47 / 52 | Libraries |
| buildings | 618 | patches (consume/destroy A; damage skipped) |
| world_laws / biomes | 49 / 29 | Libraries |
| culture / religion / clan / language traits | 78 / 40 / 29 / 26 | EventCatalog.Libraries (live+dial) |
| buildings | 618 | patches (consume A; damage/ambient destroy skipped) |
| actor_library | 322 | SpeciesDiscovery / scoring |
| culture/religion/clan/language traits | 78 / 40 / 29 / 26 | **no event catalog yet** |
| tasks_actor | 217 | Activity taxonomy |
## Layer A sizes (policy)
| Domain | Authored | Layer A |
|--------|--------:|--------:|
| Domain | Authored / seeded | Layer A |
|--------|------------------:|--------|
| Happiness | 67 | ~49 Signal (5 Ambient FX; 13 Aggregate civic) |
| Status | 58 | ~42 CreatesInterest (FX/cooldowns + egg gain stay B) |
| WorldLog | 41 | ~40 |
| Relationship | 11 | all interesting discrete (seeker/pack at low strength) |
| Relationship | 11 | all interesting discrete |
| Plot / Era / War / Book | 28 / 11 / 5 / 12 | all A |
| Trait | 116 | all CI (no noticeScoreMin hard drop) |
| Spell / Item / Gene / Phenotype / WorldLaw / SubspeciesTrait | live | ambient A at low strength; Signal bumps spectacle |
| Power / Decision / Biome | live | Signal predicate only (tools / idle AI / terrain) |
| Trait | 116 | all CI |
| Decision | 127 live / 14 prose overlays | 12 camera-interesting |
| Spell / Item / Gene / Phenotype / WorldLaw / SubspeciesTrait | live | Signal-only A (ambient CI=false); phenotypes all B |
| Culture / Religion / Clan / LanguageTrait | live dials | Signal-only via dramatic heuristic |
| Power / Biome | live | Signal predicate only / ambient CI=false |
| Building / Boat | none | destroy/consume/trade/unload; damage/load skipped |
## Wired hooks (major)
Happiness, status (+ egg-loss hatch), WorldLog, wars, plots (new/cancel/join/leave/finish),
meta genesis (culture/religion/language/clan/army/alliance/city/kingdom),
books, traits, deferred libraries, boats (trade/unload), buildings (consume/destroy),
relationship (lover/child/family/baby/alpha), Family.setAlpha, SpeciesDiscovery.
books, traits, deferred libraries, boats (trade/unload), buildings (consume; civ complete/wonder; ambient destroy demoted),
relationship (lover/child/family/baby/alpha), Family.setAlpha, SpeciesDiscovery,
meta trait gain (`MetaObjectWithTraits.addTrait` pipelines),
earthquake sticky (`Earthquake.startQuake` while active).
## Known gaps / thin areas
| Gap | Why it matters | Status |
|-----|----------------|--------|
| Culture/religion/clan/language trait gain | Meta drama libraries exist | **Live** (MetaObjectWithTraits.addTrait in pipelines) |
| Building complete / wonder finish | Settlement spectacle | **Open** |
| Alliance/kingdom destroy managers | End beats if WorldLog thin | **Open** |
| Era/earthquake poll (ongoing) | Age change is wired; poll not | **Open** |
| Building complete / wonder finish | Settlement spectacle | **Live** (`Building.completeConstruction`, civ only) |
| Library tip Labels use raw `{id}` | Watch tips look untracked / ugly | **Live** (`LibraryAssetNames` + humanize) |
| Large ambient-A libraries (items/genes/phenotypes/subspecies/meta) | Camera noise with weak prose | **Live** (Signal-only A; `library_ambient_policy_ok`) |
| Meta-trait diffs not fail-gated in `domain_event_audit` | Regressions easy to miss | **Live** (fail-gated + `library_asset_labels_ok`) |
| Era/earthquake ongoing poll | Age via `startNextAge`; quake sticky via `Earthquake.startQuake` | **Live** |
| Alliance/kingdom destroy managers | End beats covered by WorldLog (`alliance_dissolved`, `kingdom_destroyed`, …) | **Closed via WorldLog** |
## Stay B (true noise)
- Happiness: `got_poked`, `got_caught`, `paid_tax`, `just_ate`, `just_pooped` + all Aggregates
- Status: dash/dodge/shield/recovery_*/spell_*/cough/flicked/just_ate/on_guard/afterglow/slowness + egg gain
- Libraries: Power ambient (god paint tools), Decision ambient (idle AI; interesting decisions are Signal), Biome ambient
- Patches: `building_damage`, `boat_load`
- Patches: `building_damage`, ambient building destroy, `boat_load`
## Scoring notes
@ -88,4 +97,9 @@ CharacterSignificance includes favorite/king/leader/renown/kills/level and speci
./scripts/harness-run.sh --no-launch mutation_discovery
./scripts/harness-run.sh --no-launch domain_event_audit
./scripts/harness-run.sh --no-launch activity_happiness_audit
./scripts/harness-run.sh --no-launch activity_status_audit
./scripts/harness-run.sh --no-launch world_log_audit
./scripts/harness-run.sh --no-launch disaster_war_audit
```
See also [`event-catalog-discovery.md`](event-catalog-discovery.md) for the prioritized expansion waves.