the great migration

This commit is contained in:
DazedAnon 2026-07-16 14:21:48 -05:00
parent f4be23e23d
commit cc0d44ae2e
26 changed files with 6781 additions and 2045 deletions

View file

@ -1855,6 +1855,7 @@ public static class AgentHarness
case "scoring_reload":
{
bool ok = InterestScoringConfig.Reload();
bool catalogOk = EventCatalogConfig.Reload();
if (ok)
{
_cmdOk++;
@ -1868,11 +1869,33 @@ public static class AgentHarness
cmd,
ok: ok,
detail: ok
? $"loaded v={InterestScoringConfig.LoadedVersion} path={InterestScoringConfig.LoadedPath}"
? $"loaded v={InterestScoringConfig.LoadedVersion} path={InterestScoringConfig.LoadedPath} "
+ $"eventCatalog={catalogOk} decisions={EventCatalogConfig.DecisionCount}"
: "scoring_reload_failed");
break;
}
case "event_catalog_reload":
{
bool ok = EventCatalogConfig.Reload();
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok: ok,
detail: ok
? $"loaded v={EventCatalogConfig.LoadedVersion} path={EventCatalogConfig.LoadedPath}"
: "event_catalog_reload_failed");
break;
}
case "interest_variety_seed":
{
// value = "events:chars" e.g. "2:10"

View file

@ -230,16 +230,58 @@ public static class DomainEventHarness
CatalogCoverageAudit.WriteReport(outputDirectory, "domain_event_audit.tsv", tsv.ToString());
// Camera decisions must have authored ActionPhrase + EventStrength (event-catalog.json).
int decisionProseMissing = 0;
int decisionStrengthMissing = 0;
int decisionProseTotal = 0;
List<string> liveDecisions = ActivityAssetCatalog.EnumerateLiveDecisionIds();
for (int i = 0; i < liveDecisions.Count; i++)
{
string id = liveDecisions[i];
if (!EventCatalog.Decision.IsCameraWorthy(id))
{
continue;
}
decisionProseTotal++;
DiscreteEventEntry entry = EventCatalog.Decision.GetOrFallback(id);
if (!EventCatalog.Decision.TryGetAuthoredActionPhrase(id, out _))
{
decisionProseMissing++;
tsv.Append("decision_prose\t").Append(id).Append("\t0\t1\t\tmissing_action_phrase").AppendLine();
}
else if (entry.EventStrength <= 0f)
{
decisionStrengthMissing++;
tsv.Append("decision_prose\t").Append(id).Append("\t0\t1\t")
.Append(entry.EventStrength.ToString("0.#"))
.Append("\tmissing_strength").AppendLine();
}
else
{
string phrase = EventCatalog.Decision.ActionPhraseFor(id);
tsv.Append("decision_prose\t").Append(id).Append("\t1\t1\t")
.Append(entry.EventStrength.ToString("0.#")).Append('\t')
.Append(phrase).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;
&& relMissing == 0 && relTotal > 0
&& decisionProseMissing == 0 && decisionStrengthMissing == 0
&& decisionProseTotal > 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}";
+ $"{worldLaws.Detail}; {biomes.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

@ -0,0 +1,726 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using NeoModLoader.services;
namespace IdleSpectator;
/// <summary>One decision row from event-catalog.json (action phrase + EventStrength).</summary>
public sealed class DecisionCatalogRow
{
public string Id = "";
public string Action = "";
public float Strength;
}
/// <summary>Library dial defaults from event-catalog.json <c>libraries</c>.</summary>
public sealed class LibraryCatalogDefaults
{
public float AmbientStrength = 40f;
public float SignalStrength = 80f;
public string Category = "Event";
public string Label = "{a} · {id}";
public bool AmbientCreatesInterest = true;
public readonly HashSet<string> SignalIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Loads event-catalog.json — SoT for per-event strength/prose overlays.
/// <c>action</c> is decision-only (clause after "decides to"); other domains use <c>label</c> / happiness variants.
/// </summary>
public static class EventCatalogConfig
{
public const string FileName = "event-catalog.json";
public const float DefaultDecisionStrength = 86f;
private static readonly Dictionary<string, DecisionCatalogRow> Decisions =
new Dictionary<string, DecisionCatalogRow>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, float> SpeciesBonuses =
new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, float> CharacterBonuses =
new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, HappinessCatalogEntry> Happiness =
new Dictionary<string, HappinessCatalogEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, StatusInterestEntry> Status =
new Dictionary<string, StatusInterestEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, WorldLogEventEntry> WorldLog =
new Dictionary<string, WorldLogEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Plots =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Relationship =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Traits =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Books =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DiscreteEventEntry> Eras =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, DisasterInterestEntry> Disasters =
new Dictionary<string, DisasterInterestEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, WarTypeInterestEntry> WarTypes =
new Dictionary<string, WarTypeInterestEntry>(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<string, LibraryCatalogDefaults> Libraries =
new Dictionary<string, LibraryCatalogDefaults>(StringComparer.OrdinalIgnoreCase);
private static string _loadedPath = "";
private static string _loadedVersion = "";
private static bool _loadedFromFile;
private static int _generation;
public static bool LoadedFromFile => _loadedFromFile;
public static string LoadedPath => _loadedPath;
public static string LoadedVersion => _loadedVersion;
public static int Generation => _generation;
public static IEnumerable<string> DecisionIds => Decisions.Keys;
public static int DecisionCount => Decisions.Count;
public static int SpeciesCount => SpeciesBonuses.Count;
public static int CharacterCount => CharacterBonuses.Count;
public static int HappinessCount => Happiness.Count;
public static int StatusCount => Status.Count;
public static int WorldLogCount => WorldLog.Count;
public static int PlotCount => Plots.Count;
public static int RelationshipCount => Relationship.Count;
public static int TraitCount => Traits.Count;
public static int BookCount => Books.Count;
public static int EraCount => Eras.Count;
public static int DisasterCount => Disasters.Count;
public static int WarTypeCount => WarTypes.Count;
public static int LibraryCount => Libraries.Count;
public static void LoadFromModFolder(string modFolder)
{
ClearAll();
_loadedFromFile = false;
_loadedPath = "";
_loadedVersion = "";
if (string.IsNullOrEmpty(modFolder))
{
LogService.LogInfo("[IdleSpectator] event-catalog.json: no mod folder, using C# fallbacks");
BumpAndInvalidate();
return;
}
string path = Path.Combine(modFolder, FileName);
if (!File.Exists(path))
{
LogService.LogInfo($"[IdleSpectator] event-catalog.json missing at {path}, using C# fallbacks");
BumpAndInvalidate();
return;
}
try
{
string json = File.ReadAllText(path);
if (!TryParseDocument(json, out string version))
{
LogService.LogInfo("[IdleSpectator] event-catalog.json parse failed, using C# fallbacks");
ClearAll();
BumpAndInvalidate();
return;
}
_loadedFromFile = Happiness.Count > 0 || Decisions.Count > 0 || Status.Count > 0;
_loadedPath = path;
_loadedVersion = version ?? "";
BumpAndInvalidate();
LogService.LogInfo(
$"[IdleSpectator] event-catalog.json loaded v={_loadedVersion} "
+ $"happiness={Happiness.Count} status={Status.Count} worldLog={WorldLog.Count} "
+ $"plots={Plots.Count} relationship={Relationship.Count} traits={Traits.Count} "
+ $"books={Books.Count} eras={Eras.Count} disasters={Disasters.Count} "
+ $"warTypes={WarTypes.Count} decisions={Decisions.Count} libraries={Libraries.Count} "
+ $"species={SpeciesBonuses.Count} character={CharacterBonuses.Count}");
}
catch (Exception ex)
{
ClearAll();
LogService.LogInfo($"[IdleSpectator] event-catalog.json failed ({ex.Message}), using C# fallbacks");
BumpAndInvalidate();
}
}
private static void ClearAll()
{
Decisions.Clear();
SpeciesBonuses.Clear();
CharacterBonuses.Clear();
Happiness.Clear();
Status.Clear();
WorldLog.Clear();
Plots.Clear();
Relationship.Clear();
Traits.Clear();
Books.Clear();
Eras.Clear();
Disasters.Clear();
WarTypes.Clear();
Libraries.Clear();
}
private static void BumpAndInvalidate()
{
_generation++;
EventCatalog.InvalidateAllOverlays();
}
internal static bool TryParseDocument(string json, out string version)
{
version = "";
if (string.IsNullOrEmpty(json))
{
return false;
}
version = ExtractJsonString(json, "modVersion") ?? "";
if (TryExtractArray(json, "decisions", out string a)) IngestDecisionObjects(a);
if (TryExtractArray(json, "species", out a)) IngestBonusObjects(a, SpeciesBonuses);
if (TryExtractArray(json, "character", out a)) IngestBonusObjects(a, CharacterBonuses);
if (TryExtractArray(json, "happiness", out a)) IngestHappiness(a);
if (TryExtractArray(json, "status", out a)) IngestStatus(a);
if (TryExtractArray(json, "worldLog", out a)) IngestWorldLog(a);
if (TryExtractArray(json, "plots", out a)) IngestDiscrete(a, Plots);
if (TryExtractArray(json, "relationship", out a)) IngestDiscrete(a, Relationship);
if (TryExtractArray(json, "traits", out a)) IngestDiscrete(a, Traits);
if (TryExtractArray(json, "books", out a)) IngestDiscrete(a, Books);
if (TryExtractArray(json, "eras", out a)) IngestDiscrete(a, Eras);
if (TryExtractArray(json, "disasters", out a)) IngestDisasters(a);
if (TryExtractArray(json, "warTypes", out a)) IngestWarTypes(a);
IngestLibraries(json);
return Happiness.Count > 0 || Decisions.Count > 0 || Status.Count > 0 || Plots.Count > 0;
}
public static int FillHappiness(Dictionary<string, HappinessCatalogEntry> into)
{
into.Clear();
foreach (KeyValuePair<string, HappinessCatalogEntry> kv in Happiness)
{
into[kv.Key] = kv.Value;
}
return into.Count;
}
public static int FillStatus(Dictionary<string, StatusInterestEntry> into)
{
into.Clear();
foreach (KeyValuePair<string, StatusInterestEntry> kv in Status)
{
into[kv.Key] = kv.Value;
}
return into.Count;
}
public static int FillWorldLog(Dictionary<string, WorldLogEventEntry> into)
{
into.Clear();
foreach (KeyValuePair<string, WorldLogEventEntry> kv in WorldLog)
{
into[kv.Key] = kv.Value;
}
return into.Count;
}
public static int FillPlots(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Plots, into);
public static int FillRelationship(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Relationship, into);
public static int FillTraits(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Traits, into);
public static int FillBooks(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Books, into);
public static int FillEras(Dictionary<string, DiscreteEventEntry> into) => FillDiscrete(Eras, into);
public static int FillDisasters(Dictionary<string, DisasterInterestEntry> into)
{
into.Clear();
foreach (KeyValuePair<string, DisasterInterestEntry> kv in Disasters)
{
into[kv.Key] = kv.Value;
}
return into.Count;
}
public static int FillWarTypes(Dictionary<string, WarTypeInterestEntry> into)
{
into.Clear();
foreach (KeyValuePair<string, WarTypeInterestEntry> kv in WarTypes)
{
into[kv.Key] = kv.Value;
}
return into.Count;
}
private static int FillDiscrete(
Dictionary<string, DiscreteEventEntry> src,
Dictionary<string, DiscreteEventEntry> into)
{
into.Clear();
foreach (KeyValuePair<string, DiscreteEventEntry> kv in src)
{
into[kv.Key] = kv.Value;
}
return into.Count;
}
public static bool TryGetLibraryDefaults(string key, out LibraryCatalogDefaults defaults)
{
defaults = null;
if (string.IsNullOrEmpty(key))
{
return false;
}
return Libraries.TryGetValue(key.Trim(), out defaults) && defaults != null;
}
private static void IngestHappiness(string arrayJson)
{
foreach (string obj in EnumerateObjects(arrayJson))
{
string id = ExtractJsonString(obj, "id");
if (string.IsNullOrEmpty(id)) continue;
var entry = new HappinessCatalogEntry
{
Id = id.Trim(),
EventStrength = ExtractJsonFloat(obj, "strength", 40f),
Presentation = ParseEnum(obj, "presentation", HappinessPresentationTier.Signal),
Context = ParseEnum(obj, "context", HappinessContextRequirement.None),
Life = ParseEnum(obj, "life", HappinessLifePolicy.ActivityOnly),
Overlap = ParseEnum(obj, "overlap", HappinessOverlapPolicy.Independent),
RelationRole = ParseEnum(obj, "relationRole", HappinessRelationRole.None),
InterestCategory = ExtractJsonString(obj, "category") ?? "",
CanonicalMilestoneKey = ExtractJsonString(obj, "canonicalMilestoneKey") ?? "",
StatusOverlapId = ExtractJsonString(obj, "statusOverlapId") ?? "",
VariantsWithRelated = ExtractStringArray(obj, "variantsWithRelated"),
VariantsWithoutRelated = ExtractStringArray(obj, "variantsWithoutRelated")
};
Happiness[entry.Id] = entry;
}
}
private static void IngestStatus(string arrayJson)
{
foreach (string obj in EnumerateObjects(arrayJson))
{
string id = ExtractJsonString(obj, "id");
if (string.IsNullOrEmpty(id)) continue;
Status[id.Trim()] = new StatusInterestEntry
{
Id = id.Trim(),
EventStrength = ExtractJsonFloat(obj, "strength", 40f),
Category = ExtractJsonString(obj, "category") ?? "Status",
CreatesInterest = ExtractJsonBool(obj, "camera", false),
ExtendsGrief = ExtractJsonBool(obj, "extendsGrief", false)
};
}
}
private static void IngestWorldLog(string arrayJson)
{
foreach (string obj in EnumerateObjects(arrayJson))
{
string id = ExtractJsonString(obj, "id");
if (string.IsNullOrEmpty(id)) continue;
bool camera = ExtractJsonBool(obj, "camera", true);
float strength = ExtractJsonFloat(obj, "strength", 40f);
bool chronicle = ExtractJsonBool(obj, "chronicle", camera && strength >= 65f);
WorldLog[id.Trim()] = new WorldLogEventEntry
{
Id = id.Trim(),
EventStrength = strength,
Category = ExtractJsonString(obj, "category") ?? "WorldLog",
CreatesInterest = camera,
ChronicleEligible = chronicle,
LabelTemplate = ExtractJsonString(obj, "label") ?? "{id}: {a}",
MinWatch = ExtractJsonFloat(obj, "minWatch", 6f),
MaxWatch = ExtractJsonFloat(obj, "maxWatch", 28f)
};
}
}
private static void IngestDiscrete(string arrayJson, Dictionary<string, DiscreteEventEntry> into)
{
foreach (string obj in EnumerateObjects(arrayJson))
{
string id = ExtractJsonString(obj, "id");
if (string.IsNullOrEmpty(id)) continue;
into[id.Trim()] = new DiscreteEventEntry
{
Id = id.Trim(),
EventStrength = ExtractJsonFloat(obj, "strength", 40f),
Category = ExtractJsonString(obj, "category") ?? "Event",
CreatesInterest = ExtractJsonBool(obj, "camera", true),
LabelTemplate = ExtractJsonString(obj, "label") ?? "{a} · {id}",
ActionPhrase = ExtractJsonString(obj, "action") ?? ""
};
}
}
private static void IngestDisasters(string arrayJson)
{
foreach (string obj in EnumerateObjects(arrayJson))
{
string id = ExtractJsonString(obj, "id");
if (string.IsNullOrEmpty(id)) continue;
Disasters[id.Trim()] = new DisasterInterestEntry
{
Id = id.Trim(),
EventStrength = ExtractJsonFloat(obj, "strength", 90f),
Category = ExtractJsonString(obj, "category") ?? "Disaster",
CreatesInterest = ExtractJsonBool(obj, "camera", true),
WorldLogId = ExtractJsonString(obj, "worldLogId") ?? ""
};
}
}
private static void IngestWarTypes(string arrayJson)
{
foreach (string obj in EnumerateObjects(arrayJson))
{
string id = ExtractJsonString(obj, "id");
if (string.IsNullOrEmpty(id)) continue;
WarTypes[id.Trim()] = new WarTypeInterestEntry
{
Id = id.Trim(),
EventStrength = ExtractJsonFloat(obj, "strength", 85f),
Category = ExtractJsonString(obj, "category") ?? "Politics",
CreatesInterest = ExtractJsonBool(obj, "camera", true),
LabelTemplate = ExtractJsonString(obj, "label") ?? "War ({id})"
};
}
}
private static void IngestLibraries(string json)
{
if (!TryExtractObject(json, "libraries", out string libsJson))
{
return;
}
string[] keys =
{
"spell", "power", "item", "gene", "phenotype", "worldLaw", "subspeciesTrait", "biome"
};
for (int i = 0; i < keys.Length; i++)
{
string key = keys[i];
if (!TryExtractObject(libsJson, key, out string obj))
{
continue;
}
var d = new LibraryCatalogDefaults
{
AmbientStrength = ExtractJsonFloat(obj, "ambientStrength", 40f),
SignalStrength = ExtractJsonFloat(obj, "signalStrength", 80f),
Category = ExtractJsonString(obj, "category") ?? "Event",
Label = ExtractJsonString(obj, "label") ?? "{a} · {id}",
AmbientCreatesInterest = ExtractJsonBool(obj, "ambientCreatesInterest", true)
};
string[] ids = ExtractStringArray(obj, "signalIds");
for (int j = 0; j < ids.Length; j++)
{
if (!string.IsNullOrEmpty(ids[j]))
{
d.SignalIds.Add(ids[j]);
}
}
Libraries[key] = d;
}
}
private static int IngestDecisionObjects(string arrayJson)
{
int n = 0;
foreach (string obj in EnumerateObjects(arrayJson))
{
string id = ExtractJsonString(obj, "id");
string action = ExtractJsonString(obj, "action");
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(action)) continue;
float strength = ExtractJsonFloat(obj, "strength", DefaultDecisionStrength);
Decisions[id.Trim()] = new DecisionCatalogRow
{
Id = id.Trim(),
Action = action,
Strength = strength > 0f ? strength : DefaultDecisionStrength
};
n++;
}
return n;
}
private static int IngestBonusObjects(string arrayJson, Dictionary<string, float> into)
{
int n = 0;
foreach (string obj in EnumerateObjects(arrayJson))
{
string id = ExtractJsonString(obj, "id");
if (string.IsNullOrEmpty(id)) continue;
into[id.Trim()] = ExtractJsonFloat(obj, "bonus", 0f);
n++;
}
return n;
}
private static TEnum ParseEnum<TEnum>(string json, string key, TEnum fallback) where TEnum : struct
{
string raw = ExtractJsonString(json, key);
if (string.IsNullOrEmpty(raw)) return fallback;
return Enum.TryParse(raw, true, out TEnum value) ? value : fallback;
}
private static string[] ExtractStringArray(string json, string key)
{
if (!TryExtractArray(json, key, out string arr) || string.IsNullOrEmpty(arr))
{
return Array.Empty<string>();
}
var list = new List<string>();
int i = 0;
while (i < arr.Length)
{
int q = arr.IndexOf('"', i);
if (q < 0) break;
int end = q + 1;
var sb = new StringBuilder();
while (end < arr.Length)
{
char c = arr[end++];
if (c == '\\' && end < arr.Length) { sb.Append(arr[end++]); continue; }
if (c == '"') break;
sb.Append(c);
}
list.Add(sb.ToString());
i = end;
}
return list.ToArray();
}
private static bool ExtractJsonBool(string json, string key, bool fallback)
{
string needle = "\"" + key + "\"";
int keyAt = json.IndexOf(needle, StringComparison.Ordinal);
if (keyAt < 0) return fallback;
int colon = json.IndexOf(':', keyAt + needle.Length);
if (colon < 0) return fallback;
int i = colon + 1;
while (i < json.Length && char.IsWhiteSpace(json[i])) i++;
if (i + 4 <= json.Length && string.Compare(json, i, "true", 0, 4, StringComparison.OrdinalIgnoreCase) == 0)
return true;
if (i + 5 <= json.Length && string.Compare(json, i, "false", 0, 5, StringComparison.OrdinalIgnoreCase) == 0)
return false;
return fallback;
}
private static IEnumerable<string> EnumerateObjects(string arrayJson)
{
if (string.IsNullOrEmpty(arrayJson)) yield break;
int i = 0;
while (i < arrayJson.Length)
{
int start = arrayJson.IndexOf('{', i);
if (start < 0) yield break;
int depth = 0;
bool inString = false;
bool escape = false;
for (int j = start; j < arrayJson.Length; j++)
{
char c = arrayJson[j];
if (inString)
{
if (escape) { escape = false; continue; }
if (c == '\\') { escape = true; continue; }
if (c == '"') inString = false;
continue;
}
if (c == '"') { inString = true; continue; }
if (c == '{') depth++;
else if (c == '}')
{
depth--;
if (depth == 0)
{
yield return arrayJson.Substring(start, j - start + 1);
i = j + 1;
break;
}
}
}
if (depth != 0) yield break;
}
}
private static string ExtractJsonString(string json, string key)
{
string needle = "\"" + key + "\"";
int keyAt = json.IndexOf(needle, StringComparison.Ordinal);
if (keyAt < 0) return null;
int colon = json.IndexOf(':', keyAt + needle.Length);
if (colon < 0) return null;
int i = colon + 1;
while (i < json.Length && char.IsWhiteSpace(json[i])) i++;
if (i >= json.Length || json[i] != '"') return null;
i++;
var sb = new StringBuilder();
while (i < json.Length)
{
char c = json[i++];
if (c == '\\' && i < json.Length) { sb.Append(json[i++]); continue; }
if (c == '"') break;
sb.Append(c);
}
return sb.ToString();
}
private static float ExtractJsonFloat(string json, string key, float fallback)
{
string needle = "\"" + key + "\"";
int keyAt = json.IndexOf(needle, StringComparison.Ordinal);
if (keyAt < 0) return fallback;
int colon = json.IndexOf(':', keyAt + needle.Length);
if (colon < 0) return fallback;
int i = colon + 1;
while (i < json.Length && char.IsWhiteSpace(json[i])) i++;
int start = i;
while (i < json.Length && (char.IsDigit(json[i]) || json[i] == '.' || json[i] == '-' || json[i] == '+'))
i++;
if (i <= start) return fallback;
string token = json.Substring(start, i - start);
return float.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)
? value : fallback;
}
private static bool TryExtractArray(string json, string key, out string arrayJson)
{
arrayJson = null;
string keyColon = "\"" + key + "\"";
int searchFrom = 0;
int keyAt = -1;
while (true)
{
int at = json.IndexOf(keyColon, searchFrom, StringComparison.Ordinal);
if (at < 0) break;
int after = at + keyColon.Length;
while (after < json.Length && char.IsWhiteSpace(json[after])) after++;
if (after < json.Length && json[after] == ':') { keyAt = at; break; }
searchFrom = at + 1;
}
if (keyAt < 0) return false;
int bracket = json.IndexOf('[', keyAt + keyColon.Length);
if (bracket < 0) return false;
int depth = 0;
bool inString = false;
bool escape = false;
for (int i = bracket; i < json.Length; i++)
{
char c = json[i];
if (inString)
{
if (escape) { escape = false; continue; }
if (c == '\\') { escape = true; continue; }
if (c == '"') inString = false;
continue;
}
if (c == '"') { inString = true; continue; }
if (c == '[') depth++;
else if (c == ']')
{
depth--;
if (depth == 0)
{
arrayJson = json.Substring(bracket, i - bracket + 1);
return true;
}
}
}
return false;
}
private static bool TryExtractObject(string json, string key, out string objectJson)
{
objectJson = null;
string keyColon = "\"" + key + "\"";
int searchFrom = 0;
int keyAt = -1;
while (true)
{
int at = json.IndexOf(keyColon, searchFrom, StringComparison.Ordinal);
if (at < 0) break;
int after = at + keyColon.Length;
while (after < json.Length && char.IsWhiteSpace(json[after])) after++;
if (after < json.Length && json[after] == ':') { keyAt = at; break; }
searchFrom = at + 1;
}
if (keyAt < 0) return false;
int brace = json.IndexOf('{', keyAt + keyColon.Length);
if (brace < 0) return false;
int depth = 0;
bool inString = false;
bool escape = false;
for (int i = brace; i < json.Length; i++)
{
char c = json[i];
if (inString)
{
if (escape) { escape = false; continue; }
if (c == '\\') { escape = true; continue; }
if (c == '"') inString = false;
continue;
}
if (c == '"') { inString = true; continue; }
if (c == '{') depth++;
else if (c == '}')
{
depth--;
if (depth == 0)
{
objectJson = json.Substring(brace, i - brace + 1);
return true;
}
}
}
return false;
}
public static bool TryGetDecision(string id, out DecisionCatalogRow row)
{
row = null;
if (string.IsNullOrEmpty(id)) return false;
return Decisions.TryGetValue(id.Trim(), out row) && row != null;
}
public static bool TryGetDecisionAction(string id, out string action)
{
action = "";
if (!TryGetDecision(id, out DecisionCatalogRow row)) return false;
action = row.Action ?? "";
return !string.IsNullOrEmpty(action);
}
public static float DecisionStrengthOrDefault(string id, float fallback = DefaultDecisionStrength)
{
if (TryGetDecision(id, out DecisionCatalogRow row) && row.Strength > 0f) return row.Strength;
return fallback;
}
public static float SpeciesBonus(string speciesId)
{
if (string.IsNullOrEmpty(speciesId)) return 0f;
return SpeciesBonuses.TryGetValue(speciesId.Trim(), out float bonus) ? bonus : 0f;
}
public static float CharacterBonus(string roleId, float scoringModelFallback)
{
if (string.IsNullOrEmpty(roleId)) return scoringModelFallback;
if (CharacterBonuses.TryGetValue(roleId.Trim(), out float bonus)) return bonus;
return scoringModelFallback;
}
public static bool Reload()
{
string folder = ModClass.ModFolder;
if (string.IsNullOrEmpty(folder)) return false;
LoadFromModFolder(folder);
return _loadedFromFile;
}
}

View file

@ -3,7 +3,7 @@ using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored interest overlay for every live book_types asset.</summary>
/// <summary>Book dials from <c>event-catalog.json</c> <c>books</c>.</summary>
public static partial class EventCatalog
{
public static class Book
@ -11,46 +11,43 @@ public static partial class EventCatalog
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
static Book()
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
// All authored books are Layer A; EventStrength ranks them.
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");
Entries.Clear();
_appliedGeneration = -1;
}
private static void Add(
string id,
float strength,
string category,
string label)
private static void Ensure()
{
Entries[id] = new DiscreteEventEntry
if (_appliedGeneration == EventCatalogConfig.Generation)
{
Id = id,
EventStrength = strength,
Category = category,
CreatesInterest = true,
LabelTemplate = label
};
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillBooks(Entries);
}
public static IEnumerable<string> AuthoredIds => Entries.Keys;
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static bool HasAuthored(string id) =>
!string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
public static bool HasAuthored(string id)
{
Ensure();
return !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
if (!string.IsNullOrEmpty(id) && Entries.TryGetValue(id.Trim(), out DiscreteEventEntry entry))
{
return entry;

View file

@ -0,0 +1,406 @@
using System;
using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Decision interest dials + authored action phrases.
/// <para>
/// <b>Source of truth:</b> <c>event-catalog.json</c> (<c>decisions</c> rows: action + strength).
/// <see cref="BuiltinActionPhrases"/> is the offline fallback when that file is missing.
/// Live ids still seed via <see cref="LiveLibraryInterest"/>; camera Signal uses
/// <see cref="IsCameraWorthy"/>; missing ActionPhrase falls back to a generic humanize.
/// </para>
/// </summary>
public static partial class EventCatalog
{
public static class Decision
{
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedOverlayGeneration = -1;
/// <summary>
/// Offline fallback infinitive clauses when event-catalog.json is missing a row.
/// Prefer editing <c>event-catalog.json</c> instead.
/// </summary>
private static readonly Dictionary<string, string> BuiltinActionPhrases =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
// Social / crime
["find_lover"] = "find a lover",
["try_to_steal_money"] = "try to steal money",
["banish_unruly_clan_members"] = "banish unruly clan members",
["kill_unruly_clan_members"] = "kill unruly clan members",
// Plots
["try_new_plot"] = "try a new plot",
// King kingdom policy
["king_change_kingdom_culture"] = "change the kingdom's culture",
["king_change_kingdom_language"] = "change the kingdom's language",
["king_change_kingdom_religion"] = "change the kingdom's religion",
// City leader policy
["leader_change_city_culture"] = "change the city's culture",
["leader_change_city_language"] = "change the city's language",
["leader_change_city_religion"] = "change the city's religion",
// Army / warrior (keep train-with-dummy authored for if we ever Signal it)
["warrior_army_follow_leader"] = "follow their leader",
["warrior_train_with_dummy"] = "train with a dummy",
["warrior_try_join_army_group"] = "try to join an army",
};
/// <summary>Drop cached rows so the next Ensure re-applies JSON overlays.</summary>
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedOverlayGeneration = -1;
}
private static bool IsInterestingDecision(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
string s = id.ToLowerInvariant();
// Pure AI ticks / daily fluff - not story beats.
if (s.Contains("idle")
|| s.Contains("walking")
|| s.Contains("check")
|| s.Contains("wait")
|| s.Contains("sleep")
|| s.Contains("random")
|| s.Contains("play")
|| s.Contains("jump")
|| s.Contains("flip")
|| s.Contains("diet_")
|| s.Contains("move")
|| s.Contains("swim")
|| s.Contains("follow_parent")
|| s.Contains("follow_desire")
|| s.Contains("reflection")
|| s.Contains("poop"))
{
return false;
}
return HasToken(s, "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("army")
|| s.Contains("lover")
|| s.Contains("plot")
|| s.Contains("marry")
|| s.Contains("banish")
|| s.Contains("steal")
|| s.Contains("kill_unruly")
|| HasToken(s, "king")
|| HasToken(s, "leader")
|| s.Contains("clan")
|| s.Contains("religion")
|| s.Contains("culture")
|| s.Contains("baby_make")
|| s.Contains("have_child")
|| s.Contains("birth");
}
/// <summary>Underscore-token match so <c>war</c> does not hit <c>warrior</c>.</summary>
private static bool HasToken(string haystack, string token)
{
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(token))
{
return false;
}
if (haystack == token)
{
return true;
}
return haystack.StartsWith(token + "_", StringComparison.Ordinal)
|| haystack.EndsWith("_" + token, StringComparison.Ordinal)
|| haystack.Contains("_" + token + "_");
}
/// <summary>True when a decision id should be allowed to own the idle camera.</summary>
public static bool IsCameraWorthy(string id) => IsInterestingDecision(id);
/// <summary>Authored action-phrase ids (JSON catalog builtin fallback).</summary>
public static IEnumerable<string> AuthoredActionPhraseIds
{
get
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string id in EventCatalogConfig.DecisionIds)
{
if (seen.Add(id))
{
yield return id;
}
}
foreach (string id in BuiltinActionPhrases.Keys)
{
if (seen.Add(id))
{
yield return id;
}
}
}
}
public static bool TryGetAuthoredActionPhrase(string id, out string actionPhrase)
{
actionPhrase = "";
if (string.IsNullOrEmpty(id))
{
return false;
}
string key = id.Trim();
if (EventCatalogConfig.TryGetDecisionAction(key, out actionPhrase))
{
return true;
}
return BuiltinActionPhrases.TryGetValue(key, out actionPhrase)
&& !string.IsNullOrEmpty(actionPhrase);
}
/// <summary>
/// Infinitive clause for <c>decides to …</c>. Prefers event-catalog.json / builtin,
/// else a generic humanize of the asset id.
/// </summary>
public static string ActionPhraseFor(string decisionId)
{
Ensure();
if (string.IsNullOrEmpty(decisionId))
{
return "";
}
string key = decisionId.Trim();
if (Entries.TryGetValue(key, out DiscreteEventEntry entry)
&& !string.IsNullOrEmpty(entry.ActionPhrase))
{
return entry.ActionPhrase;
}
if (TryGetAuthoredActionPhrase(key, out string authored))
{
return authored;
}
return FallbackHumanize(key);
}
private static void Ensure()
{
if (_appliedOverlayGeneration != EventCatalogConfig.Generation)
{
Entries.Clear();
_appliedOverlayGeneration = EventCatalogConfig.Generation;
}
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveDecisionIds,
"Politics",
"{a} decides to act",
ambientStrength: 40f,
signalIds: null,
signalStrength: EventCatalogConfig.DefaultDecisionStrength,
signalPredicate: IsInterestingDecision,
ambientCreatesInterest: false);
// Apply authored prose + per-id strength (JSON wins over builtin).
foreach (string id in AuthoredActionPhraseIds)
{
if (!TryGetAuthoredActionPhrase(id, out string action) || string.IsNullOrEmpty(action))
{
continue;
}
float strength = EventCatalogConfig.DecisionStrengthOrDefault(
id,
IsInterestingDecision(id) ? EventCatalogConfig.DefaultDecisionStrength : 40f);
string label = "{a} decides to " + action;
if (Entries.TryGetValue(id, out DiscreteEventEntry existing))
{
existing.ActionPhrase = action;
existing.LabelTemplate = label;
if (EventCatalogConfig.TryGetDecision(id, out _))
{
existing.EventStrength = strength;
}
continue;
}
Entries[id] = new DiscreteEventEntry
{
Id = id,
EventStrength = strength,
Category = "Politics",
CreatesInterest = IsInterestingDecision(id),
ActionPhrase = action,
LabelTemplate = label
};
}
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
DiscreteEventEntry entry = LiveLibraryInterest.Lookup(
Entries, id, "Politics", "{a} decides to act", 35f);
if (!entry.IsFallback && string.IsNullOrEmpty(entry.ActionPhrase))
{
// Attach fallback humanize so callers can still read a clause.
entry.ActionPhrase = FallbackHumanize(entry.Id);
if (!string.IsNullOrEmpty(entry.ActionPhrase))
{
entry.LabelTemplate = "{a} decides to " + entry.ActionPhrase;
}
}
return entry;
}
/// <summary>
/// Generic id → infinitive clause when no authored row exists.
/// Prefer adding a row to <c>event-catalog.json</c> instead of relying on this.
/// </summary>
public static string FallbackHumanize(string decisionId)
{
if (string.IsNullOrEmpty(decisionId))
{
return "";
}
string raw = decisionId.Trim().Replace('-', '_').ToLowerInvariant();
if (raw.StartsWith("type_", StringComparison.Ordinal))
{
raw = raw.Substring(5);
}
string[] parts = raw.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
return "";
}
int start = 0;
while (start < parts.Length
&& (parts[start] == "child"
|| parts[start] == "baby"
|| parts[start] == "adult"
|| parts[start] == "warrior"
|| parts[start] == "city"
|| parts[start] == "actor"
|| parts[start] == "animal"
|| parts[start] == "herd"
|| parts[start] == "civ"
|| parts[start] == "mob"
|| parts[start] == "king"
|| parts[start] == "leader"))
{
start++;
}
if (start >= parts.Length)
{
start = 0;
}
var words = new List<string>(parts.Length - start);
for (int i = start; i < parts.Length; i++)
{
words.Add(parts[i]);
}
if (words.Count == 0)
{
return "";
}
if (words.Count >= 3
&& words[0] == "change"
&& (words[1] == "kingdom" || words[1] == "city" || words[1] == "clan"))
{
return "change the " + words[1] + "'s " + string.Join(" ", words.GetRange(2, words.Count - 2));
}
if (words[0] == "diet" && words.Count >= 2)
{
words[0] = "eat";
}
if (words.Count >= 2 && words[0] == "try" && words[1] == "new")
{
words.Insert(1, "a");
return string.Join(" ", words);
}
if (words.Count == 2 && words[0] == "find")
{
return "find a " + words[1];
}
if (words[0] == "join" && words.Count >= 2 && words[1] == "army")
{
return "join an army";
}
if (words[0] == "try" && words.Count >= 2 && words[1] != "to")
{
words.Insert(1, "to");
}
if (words.Count >= 4
&& words[0] == "try"
&& words[1] == "to"
&& words[2] == "join"
&& words[3] == "army")
{
return "try to join an army";
}
if (words.Count >= 3 && words[0] == "train" && words[1] == "with")
{
return "train with a " + string.Join(" ", words.GetRange(2, words.Count - 2));
}
if (words.Count >= 2
&& words[words.Count - 1] == "leader"
&& (words[0] == "follow" || (words[0] == "army" && words[1] == "follow")))
{
return "follow their leader";
}
return string.Join(" ", words);
}
}
}

View file

@ -13,82 +13,77 @@ public sealed class DisasterInterestEntry
public bool IsFallback;
}
/// <summary>Authored interest overlay for every live disasters library asset.</summary>
/// <summary>Disaster dials from <c>event-catalog.json</c> <c>disasters</c>.</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 readonly Dictionary<string, DisasterInterestEntry> Entries =
new Dictionary<string, DisasterInterestEntry>(StringComparer.OrdinalIgnoreCase);
private static void Add(string id, float strength, string worldLogId)
{
Entries[id] = new DisasterInterestEntry
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
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;
Entries.Clear();
_appliedGeneration = -1;
}
return Entries.TryGetValue(disasterId.Trim(), out entry);
}
public static DisasterInterestEntry GetOrFallback(string disasterId)
{
if (TryGet(disasterId, out DisasterInterestEntry entry))
private static void Ensure()
{
return entry;
if (_appliedGeneration == EventCatalogConfig.Generation)
{
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillDisasters(Entries);
}
string id = string.IsNullOrEmpty(disasterId) ? "unknown" : disasterId.Trim();
return new DisasterInterestEntry
public static IEnumerable<string> AuthoredIds
{
Id = id,
EventStrength = 90f,
Category = "Disaster",
WorldLogId = "",
CreatesInterest = true,
IsFallback = true
};
get
{
Ensure();
return Entries.Keys;
}
}
public static bool HasAuthored(string disasterId)
{
Ensure();
return !string.IsNullOrEmpty(disasterId) && Entries.ContainsKey(disasterId.Trim());
}
public static bool TryGet(string disasterId, out DisasterInterestEntry entry)
{
Ensure();
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
};
}
}
}
}

View file

@ -3,66 +3,66 @@ using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored interest overlay for every live era_library asset.</summary>
/// <summary>Era dials from <c>event-catalog.json</c> <c>eras</c>.</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 readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Add(
string id,
float strength,
string category,
string label)
{
Entries[id] = new DiscreteEventEntry
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
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;
Entries.Clear();
_appliedGeneration = -1;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
private static void Ensure()
{
Id = key,
EventStrength = 70f,
Category = "World",
LabelTemplate = "Era ({id})",
IsFallback = true
};
if (_appliedGeneration == EventCatalogConfig.Generation)
{
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillEras(Entries);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static bool HasAuthored(string id)
{
Ensure();
return !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
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})",
CreatesInterest = true,
IsFallback = true
};
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,428 +3,519 @@ using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Live AssetManager library event dials (powers, genes, …).</summary>
/// <summary>
/// Live AssetManager library event dials (powers, genes, …).
/// Strengths/labels from <c>event-catalog.json</c> <c>libraries</c>; signal predicates stay in C#.
/// </summary>
public static partial class EventCatalog
{
private static LibraryCatalogDefaults LibOr(
string key,
float ambient,
float signal,
string category,
string label,
bool ambientCreatesInterest)
{
if (EventCatalogConfig.TryGetLibraryDefaults(key, out LibraryCatalogDefaults d) && d != null)
{
return d;
}
return new LibraryCatalogDefaults
{
AmbientStrength = ambient,
SignalStrength = signal,
Category = category,
Label = label,
AmbientCreatesInterest = ambientCreatesInterest
};
}
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 readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveSpellIds,
"Spectacle",
"{a} casts {id}",
ambientStrength: 48f,
SignalIds,
signalStrength: 88f,
ambientCreatesInterest: true);
private static int _appliedGeneration = -1;
public static IEnumerable<string> AuthoredIds
{
get
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("spell", 48f, 88f, "Spectacle", "{a} casts {id}", true);
HashSet<string> signals = d.SignalIds.Count > 0
? d.SignalIds
: new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"summon_lightning", "summon_tornado", "cast_curse", "cast_fire",
"cast_blood_rain", "summon_meteor", "teleport"
};
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveSpellIds,
d.Category,
d.Label,
d.AmbientStrength,
signals,
d.SignalStrength,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return Entries.Keys;
LibraryCatalogDefaults d = LibOr("spell", 48f, 88f, "Spectacle", "{a} casts {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 55f);
}
}
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))
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
return false;
Entries.Clear();
_appliedGeneration = -1;
}
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 bool IsSpectaclePower(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLivePowerIds,
"Spectacle",
"God power: {id}",
ambientStrength: 28f,
signalIds: null,
signalStrength: 92f,
signalPredicate: IsSpectaclePower,
ambientCreatesInterest: 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");
}
public static IEnumerable<string> AuthoredIds
{
get
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("power", 28f, 92f, "Spectacle", "God power: {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLivePowerIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsSpectaclePower,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return Entries.Keys;
LibraryCatalogDefaults d = LibOr("power", 28f, 92f, "Spectacle", "God power: {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f);
}
}
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();
// Pure AI ticks - not story beats.
if (s.Contains("idle")
|| s.Contains("walking")
|| s.Contains("check")
|| s.Contains("wait")
|| s.Contains("sleep")
|| s.Contains("random"))
{
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("army")
|| s.Contains("lover")
|| s.Contains("plot")
|| s.Contains("marry")
|| s.Contains("baby")
|| s.Contains("child")
|| s.Contains("king")
|| s.Contains("leader")
|| s.Contains("clan")
|| s.Contains("religion")
|| s.Contains("culture");
}
/// <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: 40f,
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))
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
return false;
Entries.Clear();
_appliedGeneration = -1;
}
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 bool IsLegendaryItem(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveItemIds,
"Item",
"{a} forges {id}",
ambientStrength: 36f,
signalIds: null,
signalStrength: 80f,
signalPredicate: IsLegendaryItem,
ambientCreatesInterest: true);
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");
}
public static IEnumerable<string> AuthoredIds
{
get
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("item", 36f, 80f, "Item", "{a} forges {id}", true);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveItemIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsLegendaryItem,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return Entries.Keys;
LibraryCatalogDefaults d = LibOr("item", 36f, 80f, "Item", "{a} forges {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 40f);
}
}
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))
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
return false;
Entries.Clear();
_appliedGeneration = -1;
}
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 bool IsDramatic(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
private static void Ensure() =>
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds,
"Evolution",
"{a} evolves {id}",
ambientStrength: 34f,
signalIds: null,
signalStrength: 78f,
signalPredicate: IsDramatic,
ambientCreatesInterest: true);
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");
}
public static IEnumerable<string> AuthoredIds
{
get
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("subspeciesTrait", 34f, 78f, "Evolution", "{a} evolves {id}", true);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveSubspeciesTraitIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: IsDramatic,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return Entries.Keys;
LibraryCatalogDefaults d = LibOr("subspeciesTrait", 34f, 78f, "Evolution", "{a} evolves {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 36f);
}
}
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: true);
public static IEnumerable<string> AuthoredIds
{
get
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("gene", 30f, 70f, "Genetics", "{a} gains gene {id}", true);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveGeneIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: id => id != null && (id.Contains("warfare") || id.Contains("magic") || id.Contains("mutation")),
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return Entries.Keys;
LibraryCatalogDefaults d = LibOr("gene", 30f, 70f, "Genetics", "{a} gains gene {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 30f);
}
}
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: true);
public static IEnumerable<string> AuthoredIds
{
get
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("phenotype", 28f, 60f, "Genetics", "{a} shows {id}", true);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLivePhenotypeIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return Entries.Keys;
LibraryCatalogDefaults d = LibOr("phenotype", 28f, 60f, "Genetics", "{a} shows {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 28f);
}
}
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: true);
public static IEnumerable<string> AuthoredIds
{
get
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("worldLaw", 40f, 70f, "WorldLaw", "Law changed: {id}", true);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveWorldLawIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
signalPredicate: id => id != null && (id.Contains("war") || id.Contains("death") || id.Contains("plague")),
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return Entries.Keys;
LibraryCatalogDefaults d = LibOr("worldLaw", 40f, 70f, "WorldLaw", "Law changed: {id}", true);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 40f);
}
}
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
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
Entries.Clear();
_appliedGeneration = -1;
}
private static void Ensure()
{
if (_appliedGeneration == EventCatalogConfig.Generation && Entries.Count > 0)
{
return;
}
Entries.Clear();
_appliedGeneration = EventCatalogConfig.Generation;
LibraryCatalogDefaults d = LibOr("biome", 26f, 50f, "Biome", "Biome shifts: {id}", false);
LiveLibraryInterest.EnsureSeeded(
Entries,
ActivityAssetCatalog.EnumerateLiveBiomeIds,
d.Category,
d.Label,
d.AmbientStrength,
signalIds: null,
d.SignalStrength,
ambientCreatesInterest: d.AmbientCreatesInterest);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return Entries.Keys;
LibraryCatalogDefaults d = LibOr("biome", 26f, 50f, "Biome", "Biome shifts: {id}", false);
return LiveLibraryInterest.Lookup(Entries, id, d.Category, d.Label, 26f);
}
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
return LiveLibraryInterest.Lookup(Entries, id, "Biome", "Biome shifts: {id}", 26f);
}
}
}

View file

@ -3,83 +3,66 @@ using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored interest overlay for every live plots_library asset.</summary>
/// <summary>Plot dials from <c>event-catalog.json</c> <c>plots</c>.</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 readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Add(
string id,
float strength,
string category,
string label)
{
Entries[id] = new DiscreteEventEntry
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
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;
Entries.Clear();
_appliedGeneration = -1;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
private static void Ensure()
{
Id = key,
EventStrength = 55f,
Category = "Plot",
LabelTemplate = "{a} · {id}",
IsFallback = true
};
if (_appliedGeneration == EventCatalogConfig.Generation)
{
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillPlots(Entries);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static bool HasAuthored(string id)
{
Ensure();
return !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
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}",
CreatesInterest = false,
IsFallback = true
};
}
}
}
}

View file

@ -3,69 +3,66 @@ using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored discrete relationship lifecycle events (not a live asset library).</summary>
/// <summary>Relationship dials from <c>event-catalog.json</c> <c>relationship</c>.</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");
// Seeking / pack churn are still candidates; low strength lets the director bury them.
Add("find_lover", 45f, "Relationship", "{a} is seeking a lover");
Add("become_alpha", 72f, "LifeChapter", "{a} becomes the family alpha");
Add("family_group_new", 60f, "Relationship", "{a} forms a family pack");
Add("family_group_join", 36f, "Relationship", "{a} joins a family pack");
Add("family_group_leave", 36f, "Relationship", "{a} leaves a family pack");
Add("baby_created", 70f, "Relationship", "{a} is created as a baby");
}
private static readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Add(
string id,
float strength,
string category,
string label,
bool createsInterest = true)
{
Entries[id] = new DiscreteEventEntry
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
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;
Entries.Clear();
_appliedGeneration = -1;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
private static void Ensure()
{
Id = key,
EventStrength = 40f,
Category = "Relationship",
LabelTemplate = "{a} · {id}",
CreatesInterest = false,
IsFallback = true
};
if (_appliedGeneration == EventCatalogConfig.Generation)
{
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillRelationship(Entries);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static bool HasAuthored(string id)
{
Ensure();
return !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
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
};
}
}
}
}

View file

@ -13,142 +13,76 @@ public sealed class StatusInterestEntry
public bool IsFallback;
}
/// <summary>
/// Authored interest policy for every live status asset. Prose stays in ActivityStatusProse.
/// Default: story-readable statuses are Layer A. Only brief FX / cooldowns stay B-only.
/// </summary>
/// <summary>Status interest dials from <c>event-catalog.json</c> <c>status</c>.</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()
{
// Spectacles / crisis
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, "StatusAmbient", true);
Add("cursed", 60f, "StatusTransformation", true);
Add("soul_harvested", 75f, "StatusTransformation", true);
Add("voices_in_my_head", 55f, "StatusAmbient", true);
Add("tantrum", 52f, "StatusTransformation", true);
Add("magnetized", 55f, "StatusAmbient", true);
Add("invincible", 50f, "StatusTransformation", true);
Add("powerup", 54f, "StatusAmbient", true);
Add("enchanted", 52f, "StatusTransformation", true);
Add("ash_fever", 50f, "StatusAmbient", true);
Add("starving", 52f, "StatusAmbient", true);
Add("surprised", 42f, "StatusAmbient", true);
Add("confused", 40f, "StatusAmbient", true);
Add("strange_urge", 45f, "StatusAmbient", true);
Add("inspired", 42f, "Emotion", true);
Add("motivated", 40f, "Emotion", true);
Add("fell_in_love", 58f, "Relationship", true);
Add("pregnant", 58f, "LifeChapter", true);
Add("pregnant_parthenogenesis", 58f, "LifeChapter", true);
Add("crying", 50f, "Grief", true, extendsGrief: true);
private static readonly Dictionary<string, StatusInterestEntry> Entries =
new Dictionary<string, StatusInterestEntry>(StringComparer.OrdinalIgnoreCase);
// Life / social readable beats (lower strength; still camera-eligible)
Add("festive_spirit", 36f, "StatusAmbient", true);
Add("laughing", 34f, "StatusAmbient", true);
Add("singing", 34f, "StatusAmbient", true);
Add("sleeping", 32f, "StatusAmbient", true);
Add("handsome_migrant", 44f, "StatusAmbient", true);
Add("being_suspicious", 40f, "StatusAmbient", true);
Add("budding", 48f, "LifeChapter", true);
Add("caffeinated", 36f, "StatusAmbient", true);
Add("had_bad_dream", 40f, "Emotion", true);
Add("had_good_dream", 38f, "Emotion", true);
Add("had_nightmare", 44f, "Emotion", true);
Add("swearing", 34f, "StatusAmbient", true);
Add("taking_roots", 46f, "LifeChapter", true);
Add("uprooting", 46f, "LifeChapter", true);
private static int _appliedGeneration = -1;
// Egg gain is becoming an egg (not a camera beat). Loss → EmitHatch.
Add("egg", 40f, "StatusTransformation", false);
// Brief FX / combat cooldowns - ticker only (Layer B).
AddNoInterest("afterglow");
AddNoInterest("cough");
AddNoInterest("dash");
AddNoInterest("dodge");
AddNoInterest("flicked");
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");
}
private static void Add(
string id,
float strength,
string category,
bool createsInterest,
bool extendsGrief = false)
{
Entries[id] = new StatusInterestEntry
public static void InvalidateOverlays()
{
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;
Entries.Clear();
_appliedGeneration = -1;
}
return Entries.TryGetValue(statusId.Trim(), out entry);
}
public static StatusInterestEntry GetOrFallback(string statusId)
{
if (TryGet(statusId, out StatusInterestEntry entry))
private static void Ensure()
{
return entry;
if (_appliedGeneration == EventCatalogConfig.Generation)
{
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillStatus(Entries);
}
string id = string.IsNullOrEmpty(statusId) ? "unknown" : statusId.Trim();
return new StatusInterestEntry
public static IEnumerable<string> AuthoredIds
{
Id = id,
EventStrength = 40f,
Category = "Status",
CreatesInterest = false,
IsFallback = true
};
get
{
Ensure();
return Entries.Keys;
}
}
public static bool HasAuthored(string statusId)
{
Ensure();
return !string.IsNullOrEmpty(statusId) && Entries.ContainsKey(statusId.Trim());
}
public static bool TryGet(string statusId, out StatusInterestEntry entry)
{
Ensure();
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
};
}
}
}
}

View file

@ -3,167 +3,66 @@ using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>Authored interest overlay for every live traits library asset.</summary>
/// <summary>Trait dials from <c>event-catalog.json</c> <c>traits</c>.</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 readonly Dictionary<string, DiscreteEventEntry> Entries =
new Dictionary<string, DiscreteEventEntry>(StringComparer.OrdinalIgnoreCase);
private static void Add(string id, float strength)
{
Entries[id] = new DiscreteEventEntry
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
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;
Entries.Clear();
_appliedGeneration = -1;
}
string key = string.IsNullOrEmpty(id) ? "unknown" : id.Trim();
return new DiscreteEventEntry
private static void Ensure()
{
Id = key,
EventStrength = 40f,
Category = "Trait",
LabelTemplate = "Trait: {id} ({a})",
IsFallback = true
};
if (_appliedGeneration == EventCatalogConfig.Generation)
{
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillTraits(Entries);
}
public static IEnumerable<string> AuthoredIds
{
get
{
Ensure();
return Entries.Keys;
}
}
public static bool HasAuthored(string id)
{
Ensure();
return !string.IsNullOrEmpty(id) && Entries.ContainsKey(id.Trim());
}
public static DiscreteEventEntry GetOrFallback(string id)
{
Ensure();
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})",
CreatesInterest = true,
IsFallback = true
};
}
}
}
}

View file

@ -13,83 +13,90 @@ public sealed class WarTypeInterestEntry
public bool IsFallback;
}
/// <summary>Authored interest overlay for every live war_types_library asset.</summary>
/// <summary>War type dials from <c>event-catalog.json</c> <c>warTypes</c>.</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 readonly Dictionary<string, WarTypeInterestEntry> Entries =
new Dictionary<string, WarTypeInterestEntry>(StringComparer.OrdinalIgnoreCase);
private static void Add(string id, float strength, string labelTemplate)
{
Entries[id] = new WarTypeInterestEntry
private static int _appliedGeneration = -1;
public static void InvalidateOverlays()
{
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;
Entries.Clear();
_appliedGeneration = -1;
}
return Entries.TryGetValue(warTypeId.Trim(), out entry);
}
public static WarTypeInterestEntry GetOrFallback(string warTypeId)
{
if (TryGet(warTypeId, out WarTypeInterestEntry entry))
private static void Ensure()
{
return entry;
if (_appliedGeneration == EventCatalogConfig.Generation)
{
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillWarTypes(Entries);
}
string id = string.IsNullOrEmpty(warTypeId) ? "unknown" : warTypeId.Trim();
return new WarTypeInterestEntry
public static IEnumerable<string> AuthoredIds
{
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";
get
{
Ensure();
return Entries.Keys;
}
}
string template = string.IsNullOrEmpty(entry.LabelTemplate) ? "War ({id})" : entry.LabelTemplate;
return template
.Replace("{id}", entry.Id ?? "")
.Replace("{a}", special1 ?? "");
public static bool HasAuthored(string warTypeId)
{
Ensure();
return !string.IsNullOrEmpty(warTypeId) && Entries.ContainsKey(warTypeId.Trim());
}
public static bool TryGet(string warTypeId, out WarTypeInterestEntry entry)
{
Ensure();
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 ?? "");
}
}
}
}

View file

@ -39,155 +39,105 @@ public sealed class WorldLogEventEntry
}
}
/// <summary>
/// Authored policy for every live WorldLog asset. Game library is inventory; this is presentation/scoring only.
/// </summary>
/// <summary>WorldLog dials from <c>event-catalog.json</c> <c>worldLog</c>.</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);
private static readonly Dictionary<string, WorldLogEventEntry> Entries =
new Dictionary<string, WorldLogEventEntry>(StringComparer.OrdinalIgnoreCase);
// Favorites
Add("favorite_dead", 82f, "Politics", true, "Favorite fell: {a}", 6f, 28f);
Add("favorite_killed", 85f, "Politics", true, "Favorite fell: {a}", 6f, 28f);
private static int _appliedGeneration = -1;
// 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
public static void InvalidateOverlays()
{
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;
Entries.Clear();
_appliedGeneration = -1;
}
return Entries.TryGetValue(assetId.Trim(), out entry);
}
public static WorldLogEventEntry GetOrFallback(string assetId)
{
if (TryGet(assetId, out WorldLogEventEntry entry))
private static void Ensure()
{
return entry;
if (_appliedGeneration == EventCatalogConfig.Generation)
{
return;
}
_appliedGeneration = EventCatalogConfig.Generation;
EventCatalogConfig.FillWorldLog(Entries);
}
string id = string.IsNullOrEmpty(assetId) ? "unknown" : assetId.Trim();
float strength = 40f;
string category = "WorldLog";
if (id.IndexOf("disaster", StringComparison.OrdinalIgnoreCase) >= 0)
public static IEnumerable<string> AuthoredIds
{
strength = 95f;
category = "Disaster";
get
{
Ensure();
return Entries.Keys;
}
}
return new WorldLogEventEntry
public static bool HasAuthored(string assetId)
{
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";
Ensure();
return !string.IsNullOrEmpty(assetId) && Entries.ContainsKey(assetId.Trim());
}
return GetOrFallback(message.asset_id).MakeLabel(message);
public static bool TryGet(string assetId, out WorldLogEventEntry entry)
{
Ensure();
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);
}
}
}
}

View file

@ -11,6 +11,11 @@ public sealed class DiscreteEventEntry
public string Category = "Event";
public bool CreatesInterest = true;
public string LabelTemplate = "{a}";
/// <summary>
/// Optional infinitive clause for decision-style reasons
/// (e.g. <c>change the kingdom's culture</c> → <c>{a} decides to …</c>).
/// </summary>
public string ActionPhrase = "";
public bool IsFallback;
public string MakeLabel(Actor a, Actor b = null)

View file

@ -25,6 +25,30 @@ namespace IdleSpectator;
/// </summary>
public static partial class EventCatalog
{
/// <summary>Drop cached catalog rows so the next Ensure reloads from event-catalog.json.</summary>
public static void InvalidateAllOverlays()
{
Decision.InvalidateOverlays();
Happiness.InvalidateOverlays();
Status.InvalidateOverlays();
WorldLog.InvalidateOverlays();
Plot.InvalidateOverlays();
Relationship.InvalidateOverlays();
Trait.InvalidateOverlays();
Book.InvalidateOverlays();
Era.InvalidateOverlays();
Disaster.InvalidateOverlays();
WarType.InvalidateOverlays();
Spell.InvalidateOverlays();
Power.InvalidateOverlays();
Item.InvalidateOverlays();
Gene.InvalidateOverlays();
Phenotype.InvalidateOverlays();
WorldLaw.InvalidateOverlays();
SubspeciesTrait.InvalidateOverlays();
Biome.InvalidateOverlays();
}
/// <summary>
/// Layer A gate for happiness: Signal may own the camera; Ambient/Aggregate are B-only.
/// </summary>

View file

@ -263,7 +263,7 @@ public static class EventReason
case "item":
return an + " forges " + id;
case "decision":
return an + " decides " + id;
return Decision(a, assetId);
case "gene":
return an + " gains gene " + id;
case "phenotype":
@ -277,6 +277,18 @@ public static class EventReason
}
}
public static string Decision(Actor a, string decisionId)
{
string an = NameOrSomeone(a);
string action = EventCatalog.Decision.ActionPhraseFor(decisionId);
if (string.IsNullOrEmpty(action))
{
return an + " makes a decision";
}
return an + " decides to " + action;
}
/// <summary>
/// Apply a sentence template with {a}/{b}/{id}. Prefer typed helpers for new copy.
/// </summary>

View file

@ -1225,6 +1225,16 @@ internal static class HarnessScenarios
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("et45b", "interest_force_session", asset: "human",
label: "{a} decides to declare war", tier: "Action", expect: "decision_prose",
value: "lead=event;evt=86"),
Step("et45c", "assert", expect: "dossier_contains", value: "decides to declare war"),
Step("et45d", "assert", expect: "dossier_not_contains", value: "decides Declare"),
Step("et45e", "interest_force_session", asset: "human",
label: "{a} decides to change the kingdom's culture", tier: "Action", expect: "king_decision_prose",
value: "lead=event;evt=86"),
Step("et45f", "assert", expect: "dossier_contains", value: "decides to change the kingdom's culture"),
Step("et45g", "assert", expect: "dossier_not_contains", value: "decides to king"),
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"),

View file

@ -183,16 +183,16 @@ public static class InterestScoring
float sig = 0f;
if (snap.Favorite)
{
sig += w.metaFavorite;
sig += EventCatalogConfig.CharacterBonus("favorite", w.metaFavorite);
}
if (snap.King)
{
sig += w.metaKing;
sig += EventCatalogConfig.CharacterBonus("king", w.metaKing);
}
else if (snap.Leader)
{
sig += w.metaLeader;
sig += EventCatalogConfig.CharacterBonus("leader", w.metaLeader);
}
float renownDiv = w.metaRenownDivisor > 0f ? w.metaRenownDivisor : 120f;
@ -200,6 +200,7 @@ public static class InterestScoring
sig += Mathf.Min(w.metaKillsCap, snap.Kills * w.metaKillsFactor);
sig += Mathf.Min(w.metaLevelCap, snap.Level * w.metaLevelFactor);
sig += SpeciesRarityBonus(snap.SpeciesId, w);
sig += EventCatalogConfig.SpeciesBonus(snap.SpeciesId);
snap.CharacterSignificance = sig;
MetaCache[id] = snap;
return snap;

View file

@ -55,6 +55,7 @@ public class ModClass : MonoBehaviour, IMod, IConfigurable, ILocalizable
ModFolder = pModDecl.FolderPath;
_config = ModSettings.Load(pModDecl);
InterestScoringConfig.LoadFromModFolder(ModFolder);
EventCatalogConfig.LoadFromModFolder(ModFolder);
_harmony = new Harmony(pModDecl.UID);
ApplyHarmonyPatches(pModDecl.Name);

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.24.0",
"description": "AFK Idle Spectator (I) + Lore (L). Interesting beats are candidates; director ranks.",
"version": "0.25.0",
"description": "AFK Idle Spectator (I) + Lore (L). Event strengths/prose in event-catalog.json.",
"GUID": "com.dazed.idlespectator"
}

View file

@ -38,7 +38,13 @@ Feeds call typed helpers (`Fight`, `BuildingEat`, `SeekingLover`, …) or `Apply
Rules:
- Sentence form with names: `{a} is fighting {b}`, `{a} is eating a beehive`, `{a} is seeking a lover`.
- Sentence form with names: `{a} is fighting {b}`, `{a} is eating a beehive`, `{a} decides to change the kingdom's culture`.
Decision orange reasons use the `action` field (infinitive after "decides to").
Other domains use `label` or happiness `variantsWithRelated` / `variantsWithoutRelated`.
All of that - plus per-id `strength` - lives in [`event-catalog.json`](../IdleSpectator/event-catalog.json).
`EventReason.Decision` only formats `{name} decides to {phrase}`.
Species/character score overlays live in the same JSON; global formula stays in `scoring-model.json`.
- 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.

View file

@ -1,9 +1,17 @@
# Scoring model
**Source of truth (game loads this):** [`IdleSpectator/scoring-model.json`](../IdleSpectator/scoring-model.json)
**Global formula:** [`IdleSpectator/scoring-model.json`](../IdleSpectator/scoring-model.json)
**Per-event inventory (strength + prose):** [`IdleSpectator/event-catalog.json`](../IdleSpectator/event-catalog.json)
- `scoring-model.json` - margins, multipliers, rarity caps
- `event-catalog.json` - every authored event id (`happiness`, `status`, `worldLog`, `plots`, `decisions`, …)
- Decisions use `action` (clause after "decides to …")
- Most other domains use `label`
- Happiness uses `variantsWithRelated` / `variantsWithoutRelated`
- Optional `species` / `character` bonuses
**Visual tracker (open beside chat):** [scoring-model canvas](/home/dazed/.cursor/projects/home-dazed-Projects-worldbox-mods-idle-mode/canvases/scoring-model.canvas.tsx)
Edit the `weights` block in the JSON to change live scoring.
The canvas charts and calculator mirror those weights.
After changing the JSON, refresh the canvas `W` snapshot so the visual stays accurate.
Edit the `weights` block in scoring-model.json to change live scoring formula knobs.
Edit event-catalog.json to change what each event is worth and what it says.

View file

@ -0,0 +1,843 @@
#!/usr/bin/env python3
"""Export IdleSpectator/event-catalog.json from EventCatalog C# sources.
Parses authored catalog partials under IdleSpectator/Events/Catalogs/ and merges
with existing decisions/species/character overlays from event-catalog.json
(JSON decisions strengths/actions win).
Usage (from repo root):
python3 scripts/export-event-catalog-from-cs.py
python3 scripts/export-event-catalog-from-cs.py --out IdleSpectator/event-catalog.json
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
REPO = Path(__file__).resolve().parents[1]
CATALOGS = REPO / "IdleSpectator" / "Events" / "Catalogs"
DEFAULT_OUT = REPO / "IdleSpectator" / "event-catalog.json"
DEFAULT_EXISTING = DEFAULT_OUT
MOD_VERSION = "0.25.0"
UPDATED = "2026-07-16"
NOTE = (
"Authored IdleSpectator event catalog exported from EventCatalog C# sources "
"(happiness, status, worldLog, plots, relationship, traits, books, eras, "
"disasters, warTypes, libraries defaults). decisions/species/character merge "
"from prior JSON (JSON wins). Loaded by EventCatalogConfig; scoring-model.json "
"remains the global formula."
)
# ---------------------------------------------------------------------------
# C# text helpers
# ---------------------------------------------------------------------------
def strip_csharp_comments(text: str) -> str:
"""Remove // and /* */ comments outside of string literals."""
out: list[str] = []
i = 0
n = len(text)
while i < n:
c = text[i]
if c == '"':
j = i + 1
while j < n:
if text[j] == "\\":
j += 2
continue
if text[j] == '"':
j += 1
break
j += 1
out.append(text[i:j])
i = j
continue
if c == "/" and i + 1 < n:
nxt = text[i + 1]
if nxt == "/":
while i < n and text[i] not in "\r\n":
i += 1
continue
if nxt == "*":
i += 2
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
i += 1
i = min(n, i + 2)
continue
out.append(c)
i += 1
return "".join(out)
def enum_leaf(expr: str) -> str:
"""HappinessPresentationTier.Signal -> Signal."""
s = expr.strip().rstrip(",")
if "." in s:
return s.rsplit(".", 1)[-1]
return s
def parse_csharp_string(token: str) -> str:
token = token.strip()
if len(token) >= 2 and token[0] == '"' and token[-1] == '"':
inner = token[1:-1]
return (
inner.replace(r"\\", "\0")
.replace(r"\"", '"')
.replace(r"\n", "\n")
.replace(r"\t", "\t")
.replace("\0", "\\")
)
return token
def parse_number(token: str) -> float | int:
t = token.strip().rstrip("fFdDmM")
if re.fullmatch(r"-?\d+", t):
return int(t)
return float(t)
def parse_bool(token: str) -> bool:
t = token.strip().lower()
if t == "true":
return True
if t == "false":
return False
raise ValueError(f"not a bool: {token!r}")
def split_top_level_args(arg_text: str) -> list[str]:
"""Split comma-separated C# call args, respecting strings/parens/braces."""
args: list[str] = []
buf: list[str] = []
depth_paren = depth_brace = depth_bracket = 0
in_string = False
escape = False
i = 0
while i < len(arg_text):
c = arg_text[i]
if in_string:
buf.append(c)
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
i += 1
continue
if c == '"':
in_string = True
buf.append(c)
i += 1
continue
if c == "(":
depth_paren += 1
buf.append(c)
elif c == ")":
depth_paren -= 1
buf.append(c)
elif c == "{":
depth_brace += 1
buf.append(c)
elif c == "}":
depth_brace -= 1
buf.append(c)
elif c == "[":
depth_bracket += 1
buf.append(c)
elif c == "]":
depth_bracket -= 1
buf.append(c)
elif (
c == ","
and depth_paren == 0
and depth_brace == 0
and depth_bracket == 0
):
args.append("".join(buf).strip())
buf = []
else:
buf.append(c)
i += 1
tail = "".join(buf).strip()
if tail:
args.append(tail)
return args
def parse_call_args(arg_text: str) -> tuple[list[Any], dict[str, Any]]:
"""Parse positional + named (name: value) C# call arguments."""
positional: list[Any] = []
named: dict[str, Any] = {}
for raw in split_top_level_args(arg_text):
if not raw:
continue
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", raw, re.S)
if m and not raw.lstrip().startswith('"'):
name, val = m.group(1), m.group(2).strip()
named[name] = coerce_literal(val)
else:
positional.append(coerce_literal(raw))
return positional, named
def is_seed_call(arg_text: str) -> bool:
"""True when the first arg is a string literal (seed), not a method signature."""
parts = split_top_level_args(arg_text)
return bool(parts) and parts[0].lstrip().startswith('"')
def coerce_literal(token: str) -> Any:
t = token.strip()
if not t:
return t
if t.startswith('"'):
return parse_csharp_string(t)
low = t.lower()
if low in ("true", "false"):
return low == "true"
if re.fullmatch(r"-?\d+(\.\d+)?[fFdDmM]?", t):
return parse_number(t)
if t.startswith("new[]") or t.startswith("new string[]"):
return parse_string_array(t)
return t
def parse_string_array(expr: str) -> list[str]:
m = re.search(r"\{(.*)\}", expr, re.S)
if not m:
return []
inner = m.group(1).strip()
if not inner:
return []
return [parse_csharp_string(p) for p in split_top_level_args(inner) if p.strip()]
def find_method_calls(text: str, method: str) -> list[tuple[int, str]]:
"""Return (index, arg_text) for method(...); calls, balanced parens."""
results: list[tuple[int, str]] = []
pattern = re.compile(rf"\b{re.escape(method)}\s*\(")
for m in pattern.finditer(text):
start = m.end()
depth = 1
i = start
in_string = False
escape = False
while i < len(text) and depth > 0:
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
results.append((m.start(), text[start:i]))
break
i += 1
return results
def read_catalog(name: str) -> str:
path = CATALOGS / name
if not path.is_file():
raise FileNotFoundError(path)
return strip_csharp_comments(path.read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# Section parsers
# ---------------------------------------------------------------------------
_FAILURES: list[str] = []
def fail(msg: str) -> None:
_FAILURES.append(msg)
print(f"PARSE FAIL: {msg}", file=sys.stderr)
def parse_happiness(text: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
# Match ["id"] = new HappinessCatalogEntry { ... },
pattern = re.compile(
r'\["([^"]+)"\]\s*=\s*new\s+HappinessCatalogEntry\s*\{',
re.M,
)
for m in pattern.finditer(text):
entry_id = m.group(1)
body_start = m.end()
depth = 1
i = body_start
in_string = False
escape = False
while i < len(text) and depth > 0:
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
break
i += 1
if depth != 0:
fail(f"happiness: unclosed block for {entry_id}")
continue
body = text[body_start:i]
try:
entries.append(parse_happiness_body(entry_id, body))
except Exception as ex: # noqa: BLE001
fail(f"happiness[{entry_id}]: {ex}")
return entries
def parse_happiness_body(entry_id: str, body: str) -> dict[str, Any]:
props: dict[str, str] = {}
# Property = value; (value may be new[] { ... })
prop_re = re.compile(
r"(\w+)\s*=\s*"
r"("
r"new\s*(?:string\s*)?\[\s*\]\s*\{(?:[^{}]|\{[^{}]*\})*\}"
r'|"[^"\\]*(?:\\.[^"\\]*)*"'
r"|[^,\n]+"
r")\s*,?",
re.M,
)
for m in prop_re.finditer(body):
props[m.group(1)] = m.group(2).strip().rstrip(",")
def req(name: str) -> str:
if name not in props:
raise ValueError(f"missing {name}")
return props[name]
strength = parse_number(req("EventStrength"))
row: dict[str, Any] = {
"id": parse_csharp_string(props["Id"]) if "Id" in props else entry_id,
"strength": strength,
"presentation": enum_leaf(req("Presentation")),
"context": enum_leaf(req("Context")),
"life": enum_leaf(req("Life")),
"overlap": enum_leaf(req("Overlap")),
"relationRole": enum_leaf(req("RelationRole")),
"category": parse_csharp_string(req("InterestCategory"))
if req("InterestCategory").startswith('"')
else req("InterestCategory").strip('"'),
"variantsWithRelated": parse_string_array(req("VariantsWithRelated"))
if "VariantsWithRelated" in props
else [],
"variantsWithoutRelated": parse_string_array(req("VariantsWithoutRelated"))
if "VariantsWithoutRelated" in props
else [],
}
if "CanonicalMilestoneKey" in props:
key = props["CanonicalMilestoneKey"]
val = parse_csharp_string(key) if key.startswith('"') else key
if val:
row["canonicalMilestoneKey"] = val
if "StatusOverlapId" in props:
key = props["StatusOverlapId"]
val = parse_csharp_string(key) if key.startswith('"') else key
if val:
row["statusOverlapId"] = val
return row
def parse_status(text: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
for _, args in find_method_calls(text, "AddNoInterest"):
if not is_seed_call(args):
continue
pos, _ = parse_call_args(args)
if not pos:
fail(f"status AddNoInterest: empty args ({args!r})")
continue
entries.append(
{
"id": pos[0],
"strength": 20,
"category": "StatusAmbient",
"camera": False,
"extendsGrief": False,
}
)
for _, args in find_method_calls(text, "Add"):
if not is_seed_call(args):
continue
pos, named = parse_call_args(args)
if len(pos) < 4:
continue
try:
entry = {
"id": pos[0],
"strength": _num(pos[1]),
"category": pos[2],
"camera": bool(pos[3]),
"extendsGrief": bool(named.get("extendsGrief", False)),
}
entries.append(entry)
except Exception as ex: # noqa: BLE001
fail(f"status Add({args[:60]}…): {ex}")
return entries
def parse_world_log(text: str) -> list[dict[str, Any]]:
entries: list[dict[str, Any]] = []
for _, args in find_method_calls(text, "Add"):
if not is_seed_call(args):
continue
pos, _ = parse_call_args(args)
# Seed calls: id, strength, category, createsInterest, label, minWatch, maxWatch
if len(pos) < 7:
fail(f"worldLog Add: expected 7 args, got {len(pos)} ({args[:60]}…)")
continue
try:
strength = float(pos[1])
camera = bool(pos[3])
chronicle = camera and strength >= 65.0
row: dict[str, Any] = {
"id": pos[0],
"strength": _num(pos[1]),
"category": pos[2],
"camera": camera,
"label": pos[4],
"minWatch": _num(pos[5]),
"maxWatch": _num(pos[6]),
"chronicle": chronicle,
}
entries.append(row)
except Exception as ex: # noqa: BLE001
fail(f"worldLog Add({args[:60]}…): {ex}")
return entries
def parse_simple_add(
text: str,
*,
default_category: str | None = None,
default_label: str | None = None,
default_camera: bool = True,
min_pos: int = 2,
style: str = "id_strength_category_label",
) -> list[dict[str, Any]]:
"""Parse Add(...) seed calls for DiscreteEventEntry-style catalogs."""
entries: list[dict[str, Any]] = []
for _, args in find_method_calls(text, "Add"):
if not is_seed_call(args):
continue
pos, named = parse_call_args(args)
if len(pos) < min_pos:
continue
try:
if style == "id_strength_only":
# Trait: Add(id, strength) - apply defaults
if len(pos) != 2 or not isinstance(pos[0], str):
continue
row = {
"id": pos[0],
"strength": _num(pos[1]),
"category": default_category or "Trait",
"camera": default_camera,
"label": default_label or "Trait: {id} ({a})",
}
elif style == "id_strength_category_label":
# Plot/Book/Era/Relationship: Add(id, strength, category, label [, createsInterest])
if len(pos) < 4:
continue
camera = bool(named.get("createsInterest", pos[4] if len(pos) > 4 else default_camera))
row = {
"id": pos[0],
"strength": _num(pos[1]),
"category": pos[2],
"camera": camera,
"label": pos[3],
}
elif style == "disaster":
# Add(id, strength, worldLogId)
if len(pos) < 3:
continue
row = {
"id": pos[0],
"strength": _num(pos[1]),
"category": "Disaster",
"camera": True,
"worldLogId": pos[2],
}
elif style == "war_type":
# Add(id, strength, labelTemplate)
if len(pos) < 3:
continue
row = {
"id": pos[0],
"strength": _num(pos[1]),
"category": "Politics",
"camera": True,
"label": pos[2],
}
else:
fail(f"unknown add style {style}")
continue
entries.append(row)
except Exception as ex: # noqa: BLE001
fail(f"{style} Add({args[:60]}…): {ex}")
return entries
def _num(v: Any) -> int | float:
if isinstance(v, float) and v.is_integer():
return int(v)
return v
def parse_libraries(text: str) -> dict[str, Any]:
"""Emit defaults per library class; include signalIds only when HashSet listed."""
# Class blocks: public static class Spell { ... }
class_re = re.compile(
r"public\s+static\s+class\s+(\w+)\s*\{",
re.M,
)
classes = list(class_re.finditer(text))
wanted_order = [
("Spell", "spell"),
("Power", "power"),
("Item", "item"),
("Gene", "gene"),
("Phenotype", "phenotype"),
("WorldLaw", "worldLaw"),
("SubspeciesTrait", "subspeciesTrait"),
("Biome", "biome"),
]
by_cs: dict[str, dict[str, Any]] = {}
for idx, m in enumerate(classes):
name = m.group(1)
if name not in {cs for cs, _ in wanted_order}:
continue
start = m.end()
end = classes[idx + 1].start() if idx + 1 < len(classes) else len(text)
block = text[start:end]
defaults = extract_ensure_seeded_defaults(name, block)
if defaults is None:
fail(f"libraries[{name}]: could not parse EnsureSeeded defaults")
continue
by_cs[name] = defaults
libs: dict[str, Any] = {}
for cs_name, json_key in wanted_order:
if cs_name in by_cs:
libs[json_key] = by_cs[cs_name]
return libs
def extract_ensure_seeded_defaults(name: str, block: str) -> dict[str, Any] | None:
# Find EnsureSeeded( ... ) call args
calls = find_method_calls(block, "EnsureSeeded")
if not calls:
# LiveLibraryInterest.EnsureSeeded via expression body
calls = find_method_calls(block, "LiveLibraryInterest.EnsureSeeded")
if not calls:
return None
_, arg_text = calls[0]
pos, named = parse_call_args(arg_text)
# Signature (positional):
# Entries, enumerate, category, label, ambientStrength, signalIds, signalStrength,
# [signalPredicate], ambientCreatesInterest
# Named kwargs also used: ambientStrength, signalIds, signalStrength,
# signalPredicate, ambientCreatesInterest
category = None
label = None
ambient_strength = named.get("ambientStrength")
signal_strength = named.get("signalStrength")
ambient_creates = named.get("ambientCreatesInterest")
signal_ids_expr = named.get("signalIds")
# Positional layout from LiveLibraryInterest.EnsureSeeded - read that file if needed.
# From usage:
# Entries, Enumerate..., "Category", "label", ambientStrength: N, SignalIds|null,
# signalStrength: N, [signalPredicate: ...], ambientCreatesInterest: bool
if len(pos) >= 4:
category = pos[2] if isinstance(pos[2], str) else category
label = pos[3] if isinstance(pos[3], str) else label
if ambient_strength is None and len(pos) >= 5 and isinstance(pos[4], (int, float)):
ambient_strength = pos[4]
# signalIds may be positional after ambientStrength
if signal_ids_expr is None and len(pos) >= 6:
signal_ids_expr = pos[5]
if signal_strength is None:
# often named; sometimes after signalIds
for p in pos[6:]:
if isinstance(p, (int, float)):
signal_strength = p
break
if ambient_creates is None:
for p in reversed(pos):
if isinstance(p, bool):
ambient_creates = p
break
# Also pull named ambientStrength etc. from the raw call via regex for robustness
am = re.search(r"ambientStrength\s*:\s*([\d.]+)f?", block)
if am and ambient_strength is None:
ambient_strength = parse_number(am.group(1))
sm = re.search(r"signalStrength\s*:\s*([\d.]+)f?", block)
if sm and signal_strength is None:
signal_strength = parse_number(sm.group(1))
acm = re.search(r"ambientCreatesInterest\s*:\s*(true|false)", block)
if acm and ambient_creates is None:
ambient_creates = acm.group(1) == "true"
# Category / label from EnsureSeeded positional strings
cat_m = re.search(
r"EnsureSeeded\s*\(\s*Entries\s*,\s*[^,]+,\s*\"([^\"]+)\"\s*,\s*\"([^\"]*)\"",
block,
)
if cat_m:
category = cat_m.group(1)
label = cat_m.group(2)
if category is None or label is None or ambient_strength is None or signal_strength is None:
return None
if ambient_creates is None:
ambient_creates = True
out: dict[str, Any] = {
"ambientStrength": _num(ambient_strength),
"signalStrength": _num(signal_strength),
"category": category,
"label": label,
"ambientCreatesInterest": bool(ambient_creates),
}
# signalIds HashSet initializer if present
hs = re.search(
r"HashSet\s*<\s*string\s*>\s+\w+\s*=\s*new\s+HashSet\s*<\s*string\s*>\s*"
r"(?:\([^)]*\))?\s*\{([^}]*)\}",
block,
re.S,
)
if hs:
ids = []
for sm in re.finditer(r'"([^"]+)"', hs.group(1)):
ids.append(sm.group(1))
if ids:
out["signalIds"] = ids
return out
def load_existing_overlays(path: Path) -> dict[str, Any]:
if not path.is_file():
return {"decisions": [], "species": [], "character": []}
data = json.loads(path.read_text(encoding="utf-8"))
return {
"decisions": data.get("decisions") or [],
"species": data.get("species") or [],
"character": data.get("character") or [],
}
def merge_decisions(
existing: list[dict[str, Any]],
decision_cs: str,
) -> list[dict[str, Any]]:
"""Keep JSON decisions (strength/action win). Fill gaps from BuiltinActionPhrases."""
by_id: dict[str, dict[str, Any]] = {}
# Builtin phrases from C#
m = re.search(
r"BuiltinActionPhrases\s*=\s*new\s+Dictionary[^\{]*\{(.*)\};\s*\n\s*///",
decision_cs,
re.S,
)
if not m:
# fallback: grab until closing of static field
m = re.search(
r"BuiltinActionPhrases\s*=\s*new\s+Dictionary[^\{]*\{(.*?)^\s*\};",
decision_cs,
re.S | re.M,
)
builtins: dict[str, str] = {}
if m:
for km in re.finditer(r'\["([^"]+)"\]\s*=\s*"([^"]*)"', m.group(1)):
builtins[km.group(1)] = km.group(2)
else:
fail("decisions: could not parse BuiltinActionPhrases")
for row in existing:
rid = row.get("id")
if not rid:
continue
by_id[rid] = {
"id": rid,
"action": row.get("action") or builtins.get(rid, ""),
"strength": row.get("strength", 86),
}
# Do not invent strengths for builtin-only ids not in JSON (user: JSON wins / keep from existing)
# Optionally add builtin ids missing from JSON with no strength? User said keep from existing JSON.
# So only existing decisions list.
return list(by_id.values()) if by_id else [
{"id": k, "action": v, "strength": 86} for k, v in builtins.items()
]
def build_document(existing_path: Path) -> dict[str, Any]:
overlays = load_existing_overlays(existing_path)
happiness = parse_happiness(read_catalog("EventCatalog.Happiness.cs"))
status = parse_status(read_catalog("EventCatalog.Status.cs"))
world_log = parse_world_log(read_catalog("EventCatalog.WorldLog.cs"))
plots = parse_simple_add(
read_catalog("EventCatalog.Plot.cs"),
style="id_strength_category_label",
min_pos=4,
)
relationship = parse_simple_add(
read_catalog("EventCatalog.Relationship.cs"),
style="id_strength_category_label",
min_pos=4,
)
traits = parse_simple_add(
read_catalog("EventCatalog.Trait.cs"),
style="id_strength_only",
default_category="Trait",
default_label="Trait: {id} ({a})",
default_camera=True,
min_pos=2,
)
books = parse_simple_add(
read_catalog("EventCatalog.Book.cs"),
style="id_strength_category_label",
min_pos=4,
)
eras = parse_simple_add(
read_catalog("EventCatalog.Era.cs"),
style="id_strength_category_label",
min_pos=4,
)
disasters = parse_simple_add(
read_catalog("EventCatalog.Disaster.cs"),
style="disaster",
min_pos=3,
)
war_types = parse_simple_add(
read_catalog("EventCatalog.WarType.cs"),
style="war_type",
min_pos=3,
)
libraries = parse_libraries(read_catalog("EventCatalog.Libraries.cs"))
decision_cs = read_catalog("EventCatalog.Decision.cs")
decisions = merge_decisions(overlays["decisions"], decision_cs)
doc: dict[str, Any] = {
"modVersion": MOD_VERSION,
"title": "IdleSpectator event catalog",
"updated": UPDATED,
"note": NOTE,
"decisions": decisions,
"species": overlays["species"],
"character": overlays["character"],
"happiness": happiness,
"status": status,
"worldLog": world_log,
"plots": plots,
"relationship": relationship,
"traits": traits,
"books": books,
"eras": eras,
"disasters": disasters,
"warTypes": war_types,
"libraries": libraries,
}
return doc
def print_counts(doc: dict[str, Any]) -> None:
sections = [
"decisions",
"species",
"character",
"happiness",
"status",
"worldLog",
"plots",
"relationship",
"traits",
"books",
"eras",
"disasters",
"warTypes",
]
print("Counts:")
for key in sections:
val = doc.get(key)
n = len(val) if isinstance(val, list) else 0
print(f" {key}: {n}")
libs = doc.get("libraries") or {}
print(f" libraries: {len(libs)} keys ({', '.join(libs.keys())})")
for k, v in libs.items():
sig = v.get("signalIds")
extra = f", signalIds={len(sig)}" if isinstance(sig, list) else ""
print(
f" {k}: ambient={v.get('ambientStrength')} signal={v.get('signalStrength')} "
f"category={v.get('category')}{extra}"
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--out",
type=Path,
default=DEFAULT_OUT,
help=f"Output JSON path (default: {DEFAULT_OUT})",
)
ap.add_argument(
"--existing",
type=Path,
default=DEFAULT_EXISTING,
help="Existing event-catalog.json to merge decisions/species/character from",
)
args = ap.parse_args()
doc = build_document(args.existing)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Wrote {args.out}")
print_counts(doc)
if _FAILURES:
print(f"\nParse failures ({len(_FAILURES)}):", file=sys.stderr)
for f in _FAILURES:
print(f" - {f}", file=sys.stderr)
return 1
print("\nParse failures: none")
return 0
if __name__ == "__main__":
sys.exit(main())