diff --git a/IdleSpectator/ActivityAssetCatalog.cs b/IdleSpectator/ActivityAssetCatalog.cs index 467887d..a4f7b10 100644 --- a/IdleSpectator/ActivityAssetCatalog.cs +++ b/IdleSpectator/ActivityAssetCatalog.cs @@ -158,6 +158,51 @@ public static class ActivityAssetCatalog } } + public static List EnumerateLiveHappinessIds() + { + var ids = new List(); + try + { + HappinessLibrary lib = AssetManager.happiness_library; + if (lib?.list == null) + { + return ids; + } + + for (int i = 0; i < lib.list.Count; i++) + { + HappinessAsset asset = lib.list[i]; + if (asset != null && !string.IsNullOrEmpty(asset.id)) + { + ids.Add(asset.id); + } + } + } + catch + { + // Library can be unavailable during early boot. + } + + return ids; + } + + public static HappinessAsset TryGetHappinessAsset(string effectId) + { + if (string.IsNullOrEmpty(effectId)) + { + return null; + } + + try + { + return AssetManager.happiness_library?.get(effectId.Trim()); + } + catch + { + return null; + } + } + public static List EnumerateLiveTaskIds() { var ids = new List(); diff --git a/IdleSpectator/ActivityHappinessHarness.cs b/IdleSpectator/ActivityHappinessHarness.cs new file mode 100644 index 0000000..17563e2 --- /dev/null +++ b/IdleSpectator/ActivityHappinessHarness.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEngine; + +namespace IdleSpectator; + +public sealed class ActivityHappinessAuditResult +{ + public bool Passed; + public string Detail = ""; + public int Total; + public int MissingAuthored; + public int MissingIcon; + public int LocaleLeaks; + public int EmptyPhrase; + public int OrphanAuthored; + public int MissingPolicy; + public int RequiredContextFailures; +} + +/// +/// Exhaustive live-library audit for happiness Activity prose, icons, and policies. +/// +public static class ActivityHappinessHarness +{ + public static ActivityHappinessAuditResult RunAudit(string outputDirectory) + { + List liveIds = ActivityAssetCatalog.EnumerateLiveHappinessIds(); + var authored = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string id in HappinessEventCatalog.AuthoredIds) + { + authored.Add(id); + } + + int missingAuthored = 0; + int missingIcon = 0; + int localeLeaks = 0; + int emptyPhrase = 0; + int orphanAuthored = 0; + int missingPolicy = 0; + int requiredContextFailures = 0; + var tsv = new StringBuilder(); + tsv.AppendLine( + "id\tvalue\tpsychopath\tpresentation\tlife\tcontext\toverlap\tclause\tauthored\ticon\tlocale_leak\tnotes"); + + var liveSet = new HashSet(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < liveIds.Count; i++) + { + string id = liveIds[i]; + liveSet.Add(id); + HappinessAsset asset = ActivityAssetCatalog.TryGetHappinessAsset(id); + bool hasAuthored = HappinessEventCatalog.HasAuthored(id); + HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(id); + if (!hasAuthored || entry.IsFallback) + { + missingAuthored++; + } + + string clauseWith = ActivityHappinessProse.Clause(entry, "Ava", out bool usedRelated, out _); + string clauseWithout = ActivityHappinessProse.Clause(entry, "", out _, out _); + if (string.IsNullOrEmpty(clauseWith) || string.IsNullOrEmpty(clauseWithout)) + { + emptyPhrase++; + } + + if (entry.Context == HappinessContextRequirement.RequiresRelatedActor) + { + if (!usedRelated || clauseWith.IndexOf("Ava", StringComparison.Ordinal) < 0) + { + requiredContextFailures++; + } + + if (clauseWith.IndexOf("{related}", StringComparison.Ordinal) >= 0 + || clauseWithout.IndexOf("{related}", StringComparison.Ordinal) >= 0) + { + requiredContextFailures++; + } + } + + if (entry.Presentation == 0 && entry.Life == 0 && string.IsNullOrEmpty(entry.InterestCategory)) + { + missingPolicy++; + } + + if (string.IsNullOrEmpty(entry.InterestCategory) + || entry.VariantsWithoutRelated == null + || entry.VariantsWithoutRelated.Length == 0) + { + missingPolicy++; + } + + Sprite icon = HudIcons.FromHappinessId(id) + ?? HudIcons.FromUiIcon("iconHappy") + ?? HudIcons.Task(); + bool iconOk = icon != null; + if (!iconOk) + { + missingIcon++; + } + + bool leak = LooksLikeLocaleLeak(clauseWith, id) || LooksLikeLocaleLeak(clauseWithout, id); + if (leak) + { + localeLeaks++; + } + + int value = 0; + string psycho = ""; + try + { + if (asset != null) + { + value = asset.value; + psycho = asset.ignored_by_psychopaths ? "1" : "0"; + } + } + catch + { + // ignore + } + + string notes = "ok"; + if (!hasAuthored || entry.IsFallback) + { + notes = "missing_authored"; + } + else if (!iconOk) + { + notes = "missing_icon"; + } + else if (leak) + { + notes = "locale_leak"; + } + else if (string.IsNullOrEmpty(clauseWith) || string.IsNullOrEmpty(clauseWithout)) + { + notes = "empty_phrase"; + } + else if (entry.Context == HappinessContextRequirement.RequiresRelatedActor + && (!usedRelated || clauseWith.IndexOf("Ava", StringComparison.Ordinal) < 0)) + { + notes = "required_context"; + } + + tsv.Append(id).Append('\t') + .Append(value).Append('\t') + .Append(psycho).Append('\t') + .Append(entry.Presentation).Append('\t') + .Append(entry.Life).Append('\t') + .Append(entry.Context).Append('\t') + .Append(entry.Overlap).Append('\t') + .Append(Escape(clauseWith)).Append('\t') + .Append(hasAuthored ? "1" : "0").Append('\t') + .Append(iconOk ? "1" : "0").Append('\t') + .Append(leak ? "1" : "0").Append('\t') + .Append(notes).AppendLine(); + } + + foreach (string id in authored) + { + if (!liveSet.Contains(id)) + { + orphanAuthored++; + tsv.Append(id).Append('\t') + .Append("\t\t\t\t\t\t\t1\t0\t0\torphan_authored").AppendLine(); + } + } + + try + { + if (!string.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + File.WriteAllText( + Path.Combine(outputDirectory, "activity-happiness-audit.tsv"), + tsv.ToString()); + } + } + catch + { + // Disk may be unavailable in some harness contexts. + } + + bool passed = missingAuthored == 0 + && missingIcon == 0 + && localeLeaks == 0 + && emptyPhrase == 0 + && orphanAuthored == 0 + && missingPolicy == 0 + && requiredContextFailures == 0; + + return new ActivityHappinessAuditResult + { + Passed = passed, + Total = liveIds.Count, + MissingAuthored = missingAuthored, + MissingIcon = missingIcon, + LocaleLeaks = localeLeaks, + EmptyPhrase = emptyPhrase, + OrphanAuthored = orphanAuthored, + MissingPolicy = missingPolicy, + RequiredContextFailures = requiredContextFailures, + Detail = + $"total={liveIds.Count} missingAuthored={missingAuthored} missingIcon={missingIcon} " + + $"localeLeaks={localeLeaks} emptyPhrase={emptyPhrase} orphanAuthored={orphanAuthored} " + + $"missingPolicy={missingPolicy} requiredContextFailures={requiredContextFailures}" + }; + } + + private static bool LooksLikeLocaleLeak(string text, string id) + { + if (string.IsNullOrEmpty(text)) + { + return true; + } + + if (text.IndexOf("happiness_", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + if (!string.IsNullOrEmpty(id) + && text.Equals(id, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (text.IndexOf('{') >= 0 || text.IndexOf('}') >= 0) + { + return true; + } + + return false; + } + + private static string Escape(string text) + { + if (string.IsNullOrEmpty(text)) + { + return ""; + } + + return text.Replace('\t', ' ').Replace('\n', ' ').Replace('\r', ' '); + } +} diff --git a/IdleSpectator/ActivityHappinessProse.cs b/IdleSpectator/ActivityHappinessProse.cs new file mode 100644 index 0000000..007a525 --- /dev/null +++ b/IdleSpectator/ActivityHappinessProse.cs @@ -0,0 +1,96 @@ +using System; + +namespace IdleSpectator; + +/// Builds target-aware happiness clauses from the authored catalog. +public static class ActivityHappinessProse +{ + public static string TaskKey(string effectId) + { + return "happiness:" + (effectId ?? "").Trim(); + } + + public static bool TryParseTaskKey(string key, out string effectId) + { + effectId = ""; + if (string.IsNullOrEmpty(key)) + { + return false; + } + + const string prefix = "happiness:"; + if (key.Length <= prefix.Length + || !key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + effectId = key.Substring(prefix.Length).Trim(); + return !string.IsNullOrEmpty(effectId); + } + + public static string Clause( + HappinessCatalogEntry entry, + string relatedName, + out bool usedRelated, + out bool authored) + { + usedRelated = false; + authored = entry != null && !entry.IsFallback && HappinessEventCatalog.HasAuthored(entry.Id); + if (entry == null) + { + return "feels a change in happiness"; + } + + bool hasRelated = !string.IsNullOrEmpty(relatedName); + string[] variants = hasRelated && entry.VariantsWithRelated != null && entry.VariantsWithRelated.Length > 0 + ? entry.VariantsWithRelated + : entry.VariantsWithoutRelated; + if (variants == null || variants.Length == 0) + { + variants = entry.VariantsWithoutRelated; + } + + if (variants == null || variants.Length == 0) + { + return "feels a change in happiness"; + } + + int idx = Math.Abs((entry.Id + (relatedName ?? "")).GetHashCode()) % variants.Length; + string template = variants[idx] ?? ""; + usedRelated = hasRelated && template.IndexOf("{related}", StringComparison.Ordinal) >= 0; + return ApplyTemplate(template, subjectName: "", relatedName: relatedName ?? ""); + } + + public static string ApplyTemplate(string template, string subjectName, string relatedName) + { + if (string.IsNullOrEmpty(template)) + { + return ""; + } + + string result = template; + if (!string.IsNullOrEmpty(subjectName)) + { + result = result.Replace("{subject}", subjectName); + } + + if (!string.IsNullOrEmpty(relatedName)) + { + result = result.Replace("{related}", relatedName); + } + else + { + // Strip unresolved related placeholders defensively. + result = result.Replace("{related}", "someone"); + } + + return result; + } + + public static string FallbackClause(string effectId) + { + string id = string.IsNullOrEmpty(effectId) ? "unknown" : effectId.Replace('_', ' '); + return "feels " + id; + } +} diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs index f617f9e..4aab270 100644 --- a/IdleSpectator/ActivityLog.cs +++ b/IdleSpectator/ActivityLog.cs @@ -8,7 +8,8 @@ public enum ActivityKind TaskStart, ActionBeat, StatusGain, - StatusLoss + StatusLoss, + Happiness } public sealed class ActivityEntry @@ -58,6 +59,8 @@ public static class ActivityLog private static int _statusRefreshesSinceClear; private static string _lastStatusGainId = ""; private static string _lastStatusLossId = ""; + private static int _happinessSinceClear; + private static string _lastHappinessId = ""; /// When non-zero, status counters only track this subject (harness isolation). private static long _statusCounterSubjectId; private static float _clearedAt = -1f; @@ -73,6 +76,8 @@ public static class ActivityLog public static int StatusRefreshesSinceClear => _statusRefreshesSinceClear; public static string LastStatusGainId => _lastStatusGainId ?? ""; public static string LastStatusLossId => _lastStatusLossId ?? ""; + public static int HappinessSinceClear => _happinessSinceClear; + public static string LastHappinessId => _lastHappinessId ?? ""; public static int VerbSuppressedSinceClear => _verbSuppressedSinceClear; public static void ClearSession() @@ -84,6 +89,7 @@ public static class ActivityLog LastLoggedVerbAt.Clear(); LineIndex.Clear(); ResetSampleCounters(); + HappinessEventRouter.ClearSession(); } /// Harness: reset rate counters without wiping the ring. @@ -98,6 +104,8 @@ public static class ActivityLog _verbSuppressedSinceClear = 0; _lastStatusGainId = ""; _lastStatusLossId = ""; + _happinessSinceClear = 0; + _lastHappinessId = ""; _clearedAt = Time.unscaledTime; TaskHitsSinceClear.Clear(); } @@ -568,6 +576,14 @@ public static class ActivityLog } ForceStatusNote(id, statusId.Trim(), gained, actorName); + try + { + HappinessEventRouter.NoteStatusPhase(id, statusId.Trim()); + } + catch + { + // ignore + } } /// Harness / forced path for status lines without a live transition. @@ -625,6 +641,80 @@ public static class ActivityLog return true; } + /// Happiness / emotion Activity line (no beat cooldown). Presentation already decided by router. + public static void NoteHappiness(HappinessOccurrence occ) + { + if (occ == null || occ.SubjectId == 0 || string.IsNullOrEmpty(occ.EffectId)) + { + return; + } + + string name = string.IsNullOrEmpty(occ.SubjectName) ? "Someone" : occ.SubjectName; + string clause = occ.PlainClause ?? ""; + string richClause = !string.IsNullOrEmpty(occ.RichClause) ? occ.RichClause : clause; + string display = string.IsNullOrEmpty(clause) ? name : name + " " + clause; + string displayRich = ActivityProse.ColorName(name) + + (string.IsNullOrEmpty(richClause) ? "" : " " + richClause); + string key = ActivityHappinessProse.TaskKey(occ.EffectId); + string fact = !string.IsNullOrEmpty(occ.FactLine) ? occ.FactLine : ("Happiness " + occ.EffectId); + + if (_statusCounterSubjectId == 0 || occ.SubjectId == _statusCounterSubjectId) + { + _happinessSinceClear++; + _lastHappinessId = occ.EffectId; + } + + RecordSample(key, isBeat: true); + Append(occ.SubjectId, new ActivityEntry + { + CreatedAt = Time.unscaledTime, + SubjectId = occ.SubjectId, + Kind = ActivityKind.Happiness, + TaskId = key, + JobId = occ.Amount.ToString(), + TargetLabel = !string.IsNullOrEmpty(occ.RelatedName) ? occ.RelatedName : occ.EffectId, + Line = fact, + DisplayLine = display, + DisplayLineRich = displayRich + }); + } + + /// Harness: inject a happiness ring line without a live game apply. + public static bool ForceHappinessNote( + long subjectId, + string effectId, + string actorName = "", + string relatedName = "", + int amount = 0) + { + if (subjectId == 0 || string.IsNullOrEmpty(effectId)) + { + return false; + } + + HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId); + string clause = ActivityHappinessProse.Clause(entry, relatedName, out bool usedRelated, out _); + string rich = usedRelated && !string.IsNullOrEmpty(relatedName) + ? clause.Replace(relatedName, ActivityProse.ColorName(relatedName)) + : clause; + var occ = new HappinessOccurrence + { + EffectId = effectId.Trim(), + Amount = amount != 0 ? amount : HappinessGameApi.ResolveAmount(effectId), + SubjectId = subjectId, + SubjectName = string.IsNullOrEmpty(actorName) ? "Someone" : actorName, + RelatedName = relatedName ?? "", + PlainClause = clause, + RichClause = rich, + FactLine = "Happiness " + effectId.Trim(), + Presentation = entry.Presentation, + Applied = true, + SourceHook = HappinessSourceHook.Harness + }; + NoteHappiness(occ); + return true; + } + private static string LowerFirst(string text) { if (string.IsNullOrEmpty(text)) diff --git a/IdleSpectator/ActorHappinessPatches.cs b/IdleSpectator/ActorHappinessPatches.cs new file mode 100644 index 0000000..97e818d --- /dev/null +++ b/IdleSpectator/ActorHappinessPatches.cs @@ -0,0 +1,143 @@ +using HarmonyLib; + +namespace IdleSpectator; + +/// +/// Authoritative happiness hooks. Universal changeHappiness covers every effect id; +/// death/birth patches supply related-unit context the history struct lacks. +/// +[HarmonyPatch] +internal static class ActorHappinessPatches +{ + [HarmonyPatch(typeof(Actor), nameof(Actor.changeHappiness))] + [HarmonyPostfix] + public static void PostfixChangeHappiness(Actor __instance, string pID, int pValue, bool __result) + { + if (__instance == null || string.IsNullOrEmpty(pID)) + { + return; + } + + if (!__result) + { + HappinessEventRouter.PublishSuppressed( + __instance, pID, HappinessSourceHook.ChangeHappiness); + return; + } + + Actor related = null; + HappinessRelationRole role = HappinessRelationRole.None; + HappinessSourceHook hook = HappinessSourceHook.ChangeHappiness; + + if (HappinessContextBag.TryGetRelated(__instance, out Actor bagRelated, out HappinessRelationRole bagRole)) + { + related = bagRelated; + role = bagRole != HappinessRelationRole.None + ? bagRole + : HappinessContextBag.RoleForDeathEffect(pID); + if (pID != null && pID.StartsWith("death_")) + { + hook = HappinessSourceHook.DeathEvent; + } + } + else if (pID != null && pID.StartsWith("death_")) + { + // Death event without bag - still log survivor-centered subject-only if required fails. + role = HappinessContextBag.RoleForDeathEffect(pID); + hook = HappinessSourceHook.DeathEvent; + } + + int amount = HappinessGameApi.ResolveAmount(pID, pValue); + HappinessEventRouter.PublishApplied(__instance, pID, amount, hook, related, role); + } + + [HarmonyPatch(typeof(Actor), "checkHappinessChangeFromDeathEvent")] + [HarmonyPrefix] + public static void PrefixDeathHappiness(Actor __instance) + { + HappinessContextBag.BeginDeathEvent(__instance); + } + + [HarmonyPatch(typeof(Actor), "checkHappinessChangeFromDeathEvent")] + [HarmonyPostfix] + public static void PostfixDeathHappiness() + { + HappinessContextBag.EndDeathEvent(); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.birthEvent))] + [HarmonyPrefix] + public static void PrefixBirthEvent(Actor __instance) + { + // birthEvent runs on the parent; related newborn is not passed. + // Mark role so prose can stay subject-aware even without a name. + HappinessContextBag.SetRelated(null, HappinessRelationRole.Newborn, "just_had_child"); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.birthEvent))] + [HarmonyPostfix] + public static void PostfixBirthEvent() + { + HappinessContextBag.ClearRelated(); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))] + [HarmonyPrefix] + public static void PrefixLovers(Actor __instance, Actor pTarget) + { + HappinessContextBag.SetPair(__instance, pTarget, HappinessRelationRole.Lover, "fallen_in_love"); + try + { + if (__instance != null) + { + HappinessEventRouter.NoteCanonicalMilestone(__instance.getID(), "milestone_lover"); + } + + if (pTarget != null) + { + HappinessEventRouter.NoteCanonicalMilestone(pTarget.getID(), "milestone_lover"); + } + } + catch + { + // ignore + } + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.becomeLoversWith))] + [HarmonyPostfix] + public static void PostfixLovers() + { + HappinessContextBag.ClearRelated(); + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.setBestFriend))] + [HarmonyPrefix] + public static void PrefixBestFriend(Actor __instance, Actor pActor, bool pNew) + { + if (!pNew) + { + return; + } + + HappinessContextBag.SetRelated(pActor, HappinessRelationRole.BestFriend, "just_made_friend"); + try + { + if (__instance != null) + { + HappinessEventRouter.NoteCanonicalMilestone(__instance.getID(), "milestone_friend"); + } + } + catch + { + // ignore + } + } + + [HarmonyPatch(typeof(Actor), nameof(Actor.setBestFriend))] + [HarmonyPostfix] + public static void PostfixBestFriend() + { + HappinessContextBag.ClearRelated(); + } +} diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index b0617d9..5d39b83 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -27,6 +27,7 @@ public static class AgentHarness private static readonly Queue Queue = new Queue(); private static Actor _lastSpawned; private static string _lastSpawnedAssetId = ""; + private static Actor _happinessPartner; private static bool _directorRunActive; private static string _rememberedFocusKey = ""; private static string LastScreenshotPath = ""; @@ -705,6 +706,358 @@ public static class AgentHarness break; } + case "happiness_reset": + { + long id = 0; + try + { + if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) + { + id = MoveCamera._focus_unit.getID(); + } + } + catch + { + id = 0; + } + + if (id == 0) + { + id = WatchCaption.CurrentUnitId; + } + + HappinessEventRouter.ResetCountersForSubject(id); + ActivityLog.ResetSampleCounters(); + _cmdOk++; + Emit( + cmd, + ok: true, + detail: + $"subject={id} notes={HappinessEventRouter.NotesSinceClear} " + + $"ring={HappinessEventRouter.RingWritesSinceClear}"); + break; + } + + case "happiness_apply": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "just_ate"; + int bonus = 0; + if (!string.IsNullOrEmpty(cmd.label) + && int.TryParse(cmd.label, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedBonus)) + { + bonus = parsedBonus; + } + + bool expectBlocked = (cmd.expect ?? "").IndexOf("block", StringComparison.OrdinalIgnoreCase) >= 0; + bool alive = false; + try + { + alive = focus != null && focus.isAlive(); + } + catch + { + alive = false; + } + + bool applied = alive && HappinessGameApi.TryChangeHappiness(focus, effectId, bonus); + bool ok = expectBlocked ? !applied : applied; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + WatchCaption.ForceRefreshHistory(); + Emit( + cmd, + ok, + detail: + $"effect={effectId} applied={applied} expectBlocked={expectBlocked} " + + $"notes={HappinessEventRouter.NotesSinceClear} ring={HappinessEventRouter.RingWritesSinceClear} " + + $"last={HappinessEventRouter.LastEffectId}"); + break; + } + + case "happiness_force_note": + { + long id = WatchCaption.CurrentUnitId; + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + if (id == 0 && focus != null) + { + try + { + id = focus.getID(); + } + catch + { + id = 0; + } + } + + string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "just_ate"; + string related = cmd.label ?? ""; + string actorName = ""; + try + { + if (focus != null) + { + actorName = focus.getName() ?? ""; + } + } + catch + { + actorName = ""; + } + + bool ok = ActivityLog.ForceHappinessNote(id, effectId, actorName, related); + WatchCaption.ForceRefreshHistory(); + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: $"forced happiness={effectId} related={related} id={id}"); + break; + } + + case "happiness_inject_all": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + bool alive = false; + try + { + alive = focus != null && focus.isAlive(); + } + catch + { + alive = false; + } + + if (!alive) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no living focus"); + break; + } + + List ids = ActivityAssetCatalog.EnumerateLiveHappinessIds(); + int applied = 0; + int blocked = 0; + for (int i = 0; i < ids.Count; i++) + { + if (HappinessGameApi.TryChangeHappiness(focus, ids[i], 0)) + { + applied++; + } + else + { + blocked++; + } + } + + WatchCaption.ForceRefreshHistory(); + bool ok = ids.Count > 0 && applied + blocked == ids.Count; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: + $"live={ids.Count} applied={applied} blocked={blocked} " + + $"notes={HappinessEventRouter.NotesSinceClear}"); + break; + } + + case "happiness_remember_partner": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : _lastSpawned; + _happinessPartner = focus; + bool ok = _happinessPartner != null; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, ok, detail: ok ? $"partner={SafeName(_happinessPartner)}" : "no partner"); + break; + } + + case "happiness_bond_lovers": + { + Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + Actor b = _happinessPartner; + bool ok = false; + try + { + if (a != null && b != null && a.isAlive() && b.isAlive() && a != b) + { + a.becomeLoversWith(b); + ok = true; + } + } + catch + { + ok = false; + } + + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + WatchCaption.ForceRefreshHistory(); + Emit( + cmd, + ok, + detail: + $"a={SafeName(a)} b={SafeName(b)} notes={HappinessEventRouter.NotesSinceClear}"); + break; + } + + case "happiness_bond_friends": + { + Actor a = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + Actor b = _happinessPartner; + bool ok = false; + try + { + if (a != null && b != null && a.isAlive() && b.isAlive() && a != b) + { + a.setBestFriend(b, true); + b.setBestFriend(a, true); + ok = true; + } + } + catch + { + ok = false; + } + + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + WatchCaption.ForceRefreshHistory(); + Emit( + cmd, + ok, + detail: + $"a={SafeName(a)} b={SafeName(b)} notes={HappinessEventRouter.NotesSinceClear}"); + break; + } + + case "happiness_kill_partner": + { + Actor partner = _happinessPartner; + bool ok = false; + try + { + if (partner != null && partner.isAlive()) + { + partner.die(true, AttackType.Divine); + ok = !partner.isAlive(); + } + } + catch + { + ok = false; + } + + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + WatchCaption.ForceRefreshHistory(); + Emit(cmd, ok, detail: $"killed={SafeName(partner)} ok={ok}"); + break; + } + + case "happiness_burst": + { + Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + string effectId = !string.IsNullOrEmpty(cmd.value) ? cmd.value.Trim() : "just_started_war"; + int n = Math.Max(1, cmd.count > 0 ? cmd.count : 64); + bool alive = false; + try + { + alive = focus != null && focus.isAlive(); + } + catch + { + alive = false; + } + + if (!alive) + { + _cmdFail++; + Emit(cmd, ok: false, detail: "no living focus"); + break; + } + + float t0 = Time.realtimeSinceStartup; + int applied = 0; + for (int i = 0; i < n; i++) + { + if (HappinessGameApi.TryChangeHappiness(focus, effectId, 0)) + { + applied++; + } + } + + float ms = (Time.realtimeSinceStartup - t0) * 1000f; + HappinessEventRouter.FlushAggregatesNow(); + bool ok = applied > 0 && ms < 250f; + if (ok) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit( + cmd, + ok, + detail: + $"effect={effectId} n={n} applied={applied} ms={ms:0.0} " + + $"agg={HappinessEventRouter.AggregateSummariesSinceClear}"); + break; + } + case "kill_focus": { Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; @@ -2202,7 +2555,7 @@ public static class AgentHarness } case "activity_log_contains": { - long id = WatchCaption.CurrentUnitId; + long id = ResolveActivitySubjectId(); string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; IReadOnlyList entries = ActivityLog.LatestForSubject(id, ActivityLog.MaxLinesPerSubject); @@ -2232,12 +2585,12 @@ public static class AgentHarness } pass = hit; - detail = $"hit={hit} needle='{needle}' sample='{sample}' count={entries.Count}"; + detail = $"hit={hit} needle='{needle}' sample='{sample}' count={entries.Count} id={id}"; break; } case "activity_log_not_contains": { - long id = WatchCaption.CurrentUnitId; + long id = ResolveActivitySubjectId(); string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; IReadOnlyList entries = ActivityLog.LatestForSubject(id, ActivityLog.MaxLinesPerSubject); @@ -2718,6 +3071,66 @@ public static class AgentHarness detail = audit.Detail; break; } + case "activity_happiness_audit": + { + ActivityHappinessAuditResult audit = ActivityHappinessHarness.RunAudit(HarnessDir()); + pass = audit.Passed; + detail = audit.Detail; + break; + } + case "activity_happiness_count": + { + int want = ParseCountExpect(cmd, defaultValue: 1); + int got = HappinessEventRouter.NotesSinceClear; + string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant(); + pass = mode == "min" ? got >= want : got == want; + detail = $"notes={got} want={want} mode={mode} last={HappinessEventRouter.LastEffectId}"; + break; + } + case "activity_happiness_ring_count": + { + int want = ParseCountExpect(cmd, defaultValue: 1); + int got = HappinessEventRouter.RingWritesSinceClear; + string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant(); + pass = mode == "min" ? got >= want : got == want; + detail = $"ring={got} want={want} mode={mode}"; + break; + } + case "activity_happiness_life_count": + { + int want = ParseCountExpect(cmd, defaultValue: 1); + int got = HappinessEventRouter.LifeWritesSinceClear; + string mode = (cmd.label ?? "exact").Trim().ToLowerInvariant(); + pass = mode == "min" ? got >= want : got == want; + detail = $"life={got} want={want} mode={mode}"; + break; + } + case "activity_happiness_task_id": + { + long id = ResolveActivitySubjectId(); + + string want = cmd.value ?? ""; + string got = ""; + IReadOnlyList list = ActivityLog.LatestForSubject(id, 1); + if (list != null && list.Count > 0 && list[0] != null) + { + got = list[0].TaskId ?? ""; + } + + pass = !string.IsNullOrEmpty(want) + && got.Equals(want, StringComparison.OrdinalIgnoreCase); + detail = $"got='{got}' want='{want}' id={id}"; + break; + } + case "activity_happiness_merged": + { + int want = ParseCountExpect(cmd, defaultValue: 1); + int got = HappinessEventRouter.MergedSinceClear; + string mode = (cmd.label ?? "min").Trim().ToLowerInvariant(); + pass = mode == "exact" ? got == want : got >= want; + detail = $"merged={got} want={want} mode={mode}"; + break; + } case "activity_status_gain_count": { int want = ParseCountExpect(cmd, defaultValue: 1); @@ -4268,6 +4681,29 @@ public static class AgentHarness return SafeName(MoveCamera._focus_unit); } + private static long ResolveActivitySubjectId() + { + // Prefer the camera focus (harness apply/assert target) over watch caption, + // which can still point at a prior spectator subject during nested regression. + try + { + if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) + { + long focusId = MoveCamera._focus_unit.getID(); + if (focusId != 0) + { + return focusId; + } + } + } + catch + { + // ignore + } + + return WatchCaption.CurrentUnitId; + } + private static string SafeName(Actor actor) { try diff --git a/IdleSpectator/Chronicle.cs b/IdleSpectator/Chronicle.cs index a176a68..cf19d4b 100644 --- a/IdleSpectator/Chronicle.cs +++ b/IdleSpectator/Chronicle.cs @@ -1228,6 +1228,14 @@ public static class Chronicle string victimName = SafeName(victim); string line = $"Killed {victimName} ({SpeciesOf(victim)})"; ActivityLog.NoteMilestone(killer, "milestone_kill", line, victimName); + try + { + HappinessEventRouter.NoteCanonicalMilestone(killer.getID(), "milestone_kill"); + } + catch + { + // ignore + } if (ModSettings.ChronicleEnabled) { AppendHistory(ChronicleKind.Kill, killer, victim, line); @@ -1287,6 +1295,15 @@ public static class Chronicle string lineB = $"Became lovers with {nameA}"; ActivityLog.NoteMilestone(a, "milestone_lover", lineA, nameB); ActivityLog.NoteMilestone(b, "milestone_lover", lineB, nameA); + try + { + HappinessEventRouter.NoteCanonicalMilestone(a.getID(), "milestone_lover"); + HappinessEventRouter.NoteCanonicalMilestone(b.getID(), "milestone_lover"); + } + catch + { + // ignore + } if (ModSettings.ChronicleEnabled) { AppendHistory(ChronicleKind.Lover, a, b, lineA); @@ -1315,6 +1332,15 @@ public static class Chronicle string lineB = $"Befriended {nameA}"; ActivityLog.NoteMilestone(a, "milestone_friend", lineA, nameB); ActivityLog.NoteMilestone(b, "milestone_friend", lineB, nameA); + try + { + HappinessEventRouter.NoteCanonicalMilestone(a.getID(), "milestone_friend"); + HappinessEventRouter.NoteCanonicalMilestone(b.getID(), "milestone_friend"); + } + catch + { + // ignore + } if (ModSettings.ChronicleEnabled) { AppendHistory(ChronicleKind.Friend, a, b, lineA); @@ -1322,6 +1348,27 @@ public static class Chronicle } } + /// + /// Durable happiness Life projection (birth, grief, roles, housing, book/plot/injury). + /// Canonical lover/friend/kill milestones stay on their own paths. + /// + public static bool NoteHappinessLife(HappinessOccurrence occ, Actor subject, Actor related = null) + { + if (occ == null || subject == null || string.IsNullOrEmpty(occ.PlainClause)) + { + return false; + } + + if (!ModSettings.ChronicleEnabled) + { + return false; + } + + string line = char.ToUpperInvariant(occ.PlainClause[0]) + occ.PlainClause.Substring(1); + AppendHistory(ChronicleKind.Other, subject, related, line); + return true; + } + public static void NoteWorldLog(WorldLogMessage message, string label) { if (!ModSettings.ChronicleEnabled || message == null || string.IsNullOrEmpty(label)) diff --git a/IdleSpectator/HappinessContextBag.cs b/IdleSpectator/HappinessContextBag.cs new file mode 100644 index 0000000..3130523 --- /dev/null +++ b/IdleSpectator/HappinessContextBag.cs @@ -0,0 +1,128 @@ +namespace IdleSpectator; + +/// +/// Short-lived apply-site context for happiness hooks that do not retain related units in history. +/// +internal static class HappinessContextBag +{ + private static Actor _deceased; + private static Actor _relatedA; + private static Actor _relatedB; + private static HappinessRelationRole _role; + private static string _effectHint; + + public static void BeginDeathEvent(Actor deceased) + { + _deceased = deceased; + _relatedA = deceased; + _relatedB = null; + _role = HappinessRelationRole.None; + _effectHint = "death"; + } + + public static void EndDeathEvent() + { + _deceased = null; + _relatedA = null; + _relatedB = null; + _role = HappinessRelationRole.None; + _effectHint = null; + } + + public static void SetRelated(Actor related, HappinessRelationRole role, string effectHint = null) + { + _relatedA = related; + _relatedB = null; + _role = role; + if (!string.IsNullOrEmpty(effectHint)) + { + _effectHint = effectHint; + } + } + + public static void SetPair(Actor a, Actor b, HappinessRelationRole role, string effectHint = null) + { + _relatedA = a; + _relatedB = b; + _role = role; + if (!string.IsNullOrEmpty(effectHint)) + { + _effectHint = effectHint; + } + } + + public static void ClearRelated() + { + _relatedA = null; + _relatedB = null; + _role = HappinessRelationRole.None; + _effectHint = null; + } + + public static Actor CurrentDeceased => _deceased; + + public static bool TryGetRelated(Actor subject, out Actor related, out HappinessRelationRole role) + { + related = null; + role = _role; + if (subject == null) + { + related = _relatedA ?? _deceased; + return related != null; + } + + if (_deceased != null) + { + related = _deceased; + role = role != HappinessRelationRole.None + ? role + : HappinessRelationRole.None; + return related != null && related != subject; + } + + if (_relatedA != null && _relatedB != null) + { + if (subject == _relatedA) + { + related = _relatedB; + return true; + } + + if (subject == _relatedB) + { + related = _relatedA; + return true; + } + } + + if (_relatedA != null && _relatedA != subject) + { + related = _relatedA; + return true; + } + + return false; + } + + public static HappinessRelationRole RoleForDeathEffect(string effectId) + { + if (string.IsNullOrEmpty(effectId)) + { + return HappinessRelationRole.None; + } + + switch (effectId) + { + case "death_child": + return HappinessRelationRole.DeceasedChild; + case "death_lover": + return HappinessRelationRole.DeceasedLover; + case "death_best_friend": + return HappinessRelationRole.DeceasedBestFriend; + case "death_family_member": + return HappinessRelationRole.DeceasedFamily; + default: + return HappinessRelationRole.None; + } + } +} diff --git a/IdleSpectator/HappinessEventCatalog.cs b/IdleSpectator/HappinessEventCatalog.cs new file mode 100644 index 0000000..2728b5a --- /dev/null +++ b/IdleSpectator/HappinessEventCatalog.cs @@ -0,0 +1,879 @@ +using System; +using System.Collections.Generic; + +namespace IdleSpectator; + +/// Authored policy + prose for every live happiness effect. +public static class HappinessEventCatalog +{ + private static readonly Dictionary Entries = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["death_family_member"] = new HappinessCatalogEntry + { + Id = "death_family_member", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RequiresRelatedActor, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.DeceasedFamily, + InterestCategory = "Grief", + VariantsWithRelated = new[] { "mourns the death of family member {related}", "grieves after family member {related} dies" }, + VariantsWithoutRelated = new[] { "mourns a family member's death", "grieves a family loss" }, + }, + ["death_lover"] = new HappinessCatalogEntry + { + Id = "death_lover", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RequiresRelatedActor, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.DeceasedLover, + InterestCategory = "Grief", + VariantsWithRelated = new[] { "falls into despair after their lover {related}'s death", "mourns their lover {related}" }, + VariantsWithoutRelated = new[] { "mourns a lost lover", "grieves a lover's death" }, + }, + ["death_child"] = new HappinessCatalogEntry + { + Id = "death_child", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RequiresRelatedActor, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.DeceasedChild, + InterestCategory = "Grief", + VariantsWithRelated = new[] { "falls into despair after their child {related}'s death", "mourns their child {related}" }, + VariantsWithoutRelated = new[] { "mourns a child's death", "grieves the loss of a child" }, + }, + ["death_best_friend"] = new HappinessCatalogEntry + { + Id = "death_best_friend", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RequiresRelatedActor, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.DeceasedBestFriend, + InterestCategory = "Grief", + VariantsWithRelated = new[] { "mourns their best friend {related}", "grieves after best friend {related} dies" }, + VariantsWithoutRelated = new[] { "mourns a best friend's death", "grieves a lost best friend" }, + }, + ["got_robbed"] = new HappinessCatalogEntry + { + Id = "got_robbed", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RelatedOptional, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.Robber, + InterestCategory = "Social", + VariantsWithRelated = new[] { "is robbed by {related}", "loses belongings to {related}" }, + VariantsWithoutRelated = new[] { "is robbed", "gets robbed" }, + }, + ["got_poked"] = new HappinessCatalogEntry + { + Id = "got_poked", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + VariantsWithRelated = new[] { "gets poked by the gods", "is poked" }, + VariantsWithoutRelated = new[] { "gets poked by the gods", "is poked" }, + }, + ["lost_fight"] = new HappinessCatalogEntry + { + Id = "lost_fight", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + VariantsWithRelated = new[] { "loses a fight", "is beaten in a fight" }, + VariantsWithoutRelated = new[] { "loses a fight", "is beaten in a fight" }, + }, + ["got_caught"] = new HappinessCatalogEntry + { + Id = "got_caught", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + VariantsWithRelated = new[] { "is caught doing something shady", "gets caught" }, + VariantsWithoutRelated = new[] { "is caught doing something shady", "gets caught" }, + }, + ["paid_tax"] = new HappinessCatalogEntry + { + Id = "paid_tax", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + VariantsWithRelated = new[] { "pays taxes", "grumbles while paying taxes" }, + VariantsWithoutRelated = new[] { "pays taxes", "grumbles while paying taxes" }, + }, + ["just_ate"] = new HappinessCatalogEntry + { + Id = "just_ate", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + StatusOverlapId = "just_ate", + VariantsWithRelated = new[] { "finishes a meal", "eats and feels better" }, + VariantsWithoutRelated = new[] { "finishes a meal", "eats and feels better" }, + }, + ["just_received_gift"] = new HappinessCatalogEntry + { + Id = "just_received_gift", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RelatedOptional, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.GiftPartner, + InterestCategory = "Social", + VariantsWithRelated = new[] { "receives a gift from {related}", "is given a gift by {related}" }, + VariantsWithoutRelated = new[] { "receives a gift", "is given a gift" }, + }, + ["just_gave_gift"] = new HappinessCatalogEntry + { + Id = "just_gave_gift", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RelatedOptional, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.GiftPartner, + InterestCategory = "Social", + VariantsWithRelated = new[] { "gives {related} a gift", "offers a gift to {related}" }, + VariantsWithoutRelated = new[] { "gives a gift", "offers a gift" }, + }, + ["just_pooped"] = new HappinessCatalogEntry + { + Id = "just_pooped", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + VariantsWithRelated = new[] { "finds relief", "finishes relieving themselves" }, + VariantsWithoutRelated = new[] { "finds relief", "finishes relieving themselves" }, + }, + ["just_slept"] = new HappinessCatalogEntry + { + Id = "just_slept", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + VariantsWithRelated = new[] { "wakes from a restful sleep", "finishes sleeping" }, + VariantsWithoutRelated = new[] { "wakes from a restful sleep", "finishes sleeping" }, + }, + ["had_bad_dream"] = new HappinessCatalogEntry + { + Id = "had_bad_dream", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + StatusOverlapId = "had_bad_dream", + VariantsWithRelated = new[] { "wakes from a bad dream", "shakes off a bad dream" }, + VariantsWithoutRelated = new[] { "wakes from a bad dream", "shakes off a bad dream" }, + }, + ["had_good_dream"] = new HappinessCatalogEntry + { + Id = "had_good_dream", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + StatusOverlapId = "had_good_dream", + VariantsWithRelated = new[] { "wakes from a good dream", "smiles about a good dream" }, + VariantsWithoutRelated = new[] { "wakes from a good dream", "smiles about a good dream" }, + }, + ["had_nightmare"] = new HappinessCatalogEntry + { + Id = "had_nightmare", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + StatusOverlapId = "had_nightmare", + VariantsWithRelated = new[] { "wakes from a nightmare", "shakes off a nightmare" }, + VariantsWithoutRelated = new[] { "wakes from a nightmare", "shakes off a nightmare" }, + }, + ["slept_outside"] = new HappinessCatalogEntry + { + Id = "slept_outside", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + VariantsWithRelated = new[] { "sleeps outside and feels worse for it", "spends a rough night outside" }, + VariantsWithoutRelated = new[] { "sleeps outside and feels worse for it", "spends a rough night outside" }, + }, + ["just_kissed"] = new HappinessCatalogEntry + { + Id = "just_kissed", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RelatedOptional, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.Lover, + InterestCategory = "Social", + VariantsWithRelated = new[] { "shares a kiss with {related}", "kisses {related}" }, + VariantsWithoutRelated = new[] { "shares a kiss", "steals a kiss" }, + }, + ["just_killed"] = new HappinessCatalogEntry + { + Id = "just_killed", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.CanonicalMilestone, + RelationRole = HappinessRelationRole.Victim, + InterestCategory = "Emotion", + CanonicalMilestoneKey = "milestone_kill", + VariantsWithRelated = new[] { "kills {related} and feels a rush", "takes a life" }, + VariantsWithoutRelated = new[] { "takes a life", "feels a rush after a kill" }, + }, + ["become_king"] = new HappinessCatalogEntry + { + Id = "become_king", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "is crowned king", "takes the throne" }, + VariantsWithoutRelated = new[] { "is crowned king", "takes the throne" }, + }, + ["become_leader"] = new HappinessCatalogEntry + { + Id = "become_leader", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "becomes a leader", "rises to leadership" }, + VariantsWithoutRelated = new[] { "becomes a leader", "rises to leadership" }, + }, + ["just_won_war"] = new HappinessCatalogEntry + { + Id = "just_won_war", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "rejoices at a won war", "celebrates victory in war" }, + VariantsWithoutRelated = new[] { "rejoices at a won war", "celebrates victory in war" }, + }, + ["just_made_peace"] = new HappinessCatalogEntry + { + Id = "just_made_peace", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "feels relief as peace is made", "welcomes peace" }, + VariantsWithoutRelated = new[] { "feels relief as peace is made", "welcomes peace" }, + }, + ["just_lost_war"] = new HappinessCatalogEntry + { + Id = "just_lost_war", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "despairs after a lost war", "mourns a lost war" }, + VariantsWithoutRelated = new[] { "despairs after a lost war", "mourns a lost war" }, + }, + ["was_conquered"] = new HappinessCatalogEntry + { + Id = "was_conquered", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "suffers under conquest", "is crushed by conquest" }, + VariantsWithoutRelated = new[] { "suffers under conquest", "is crushed by conquest" }, + }, + ["kingdom_fell_apart"] = new HappinessCatalogEntry + { + Id = "kingdom_fell_apart", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "watches the kingdom fall apart", "despairs as the kingdom fractures" }, + VariantsWithoutRelated = new[] { "watches the kingdom fall apart", "despairs as the kingdom fractures" }, + }, + ["just_started_war"] = new HappinessCatalogEntry + { + Id = "just_started_war", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "cheers as war begins", "is stirred by the start of war" }, + VariantsWithoutRelated = new[] { "cheers as war begins", "is stirred by the start of war" }, + }, + ["just_rebelled"] = new HappinessCatalogEntry + { + Id = "just_rebelled", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "joins a rebellion", "rises in rebellion" }, + VariantsWithoutRelated = new[] { "joins a rebellion", "rises in rebellion" }, + }, + ["fallen_in_love"] = new HappinessCatalogEntry + { + Id = "fallen_in_love", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RelatedOptional, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.CanonicalMilestone, + RelationRole = HappinessRelationRole.Lover, + InterestCategory = "Social", + CanonicalMilestoneKey = "milestone_lover", + StatusOverlapId = "fell_in_love", + VariantsWithRelated = new[] { "falls in love with {related}", "is smitten with {related}" }, + VariantsWithoutRelated = new[] { "falls in love", "is smitten" }, + }, + ["just_had_child"] = new HappinessCatalogEntry + { + Id = "just_had_child", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RelatedOptional, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.Newborn, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "welcomes newborn {related}", "has a child named {related}" }, + VariantsWithoutRelated = new[] { "welcomes a newborn child", "has a child" }, + }, + ["just_read_book"] = new HappinessCatalogEntry + { + Id = "just_read_book", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + VariantsWithRelated = new[] { "finishes reading a book", "puts down a book feeling wiser" }, + VariantsWithoutRelated = new[] { "finishes reading a book", "puts down a book feeling wiser" }, + }, + ["just_played"] = new HappinessCatalogEntry + { + Id = "just_played", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + VariantsWithRelated = new[] { "finishes playing", "has fun playing" }, + VariantsWithoutRelated = new[] { "finishes playing", "has fun playing" }, + }, + ["just_talked"] = new HappinessCatalogEntry + { + Id = "just_talked", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.RelatedOptional, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.ConversationPartner, + InterestCategory = "Social", + VariantsWithRelated = new[] { "shares a talk with {related}", "finishes a conversation with {related}" }, + VariantsWithoutRelated = new[] { "shares a quiet talk", "finishes a conversation" }, + }, + ["just_laughed"] = new HappinessCatalogEntry + { + Id = "just_laughed", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + StatusOverlapId = "laughing", + VariantsWithRelated = new[] { "bursts out laughing", "shares a laugh" }, + VariantsWithoutRelated = new[] { "bursts out laughing", "shares a laugh" }, + }, + ["just_sang"] = new HappinessCatalogEntry + { + Id = "just_sang", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + StatusOverlapId = "singing", + VariantsWithRelated = new[] { "finishes singing", "sings with feeling" }, + VariantsWithoutRelated = new[] { "finishes singing", "sings with feeling" }, + }, + ["just_swore"] = new HappinessCatalogEntry + { + Id = "just_swore", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + StatusOverlapId = "swearing", + VariantsWithRelated = new[] { "lets out a curse", "swears loudly" }, + VariantsWithoutRelated = new[] { "lets out a curse", "swears loudly" }, + }, + ["just_cried"] = new HappinessCatalogEntry + { + Id = "just_cried", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + StatusOverlapId = "crying", + VariantsWithRelated = new[] { "finishes crying", "cries it out" }, + VariantsWithoutRelated = new[] { "finishes crying", "cries it out" }, + }, + ["just_talked_gossip"] = new HappinessCatalogEntry + { + Id = "just_talked_gossip", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.RelatedOptional, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.ConversationPartner, + InterestCategory = "Social", + VariantsWithRelated = new[] { "trades gossip with {related}", "shares juicy gossip with {related}" }, + VariantsWithoutRelated = new[] { "trades gossip", "shares juicy gossip" }, + }, + ["just_surprised"] = new HappinessCatalogEntry + { + Id = "just_surprised", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + StatusOverlapId = "surprised", + VariantsWithRelated = new[] { "is startled", "jumps in surprise" }, + VariantsWithoutRelated = new[] { "is startled", "jumps in surprise" }, + }, + ["just_born"] = new HappinessCatalogEntry + { + Id = "just_born", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "is born into the world", "takes a first breath" }, + VariantsWithoutRelated = new[] { "is born into the world", "takes a first breath" }, + }, + ["just_magnetised"] = new HappinessCatalogEntry + { + Id = "just_magnetised", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + StatusOverlapId = "magnetized", + VariantsWithRelated = new[] { "is magnetized", "feels a magnetic pull" }, + VariantsWithoutRelated = new[] { "is magnetized", "feels a magnetic pull" }, + }, + ["just_forced_power"] = new HappinessCatalogEntry + { + Id = "just_forced_power", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + VariantsWithRelated = new[] { "is forced by divine power", "suffers a forced power" }, + VariantsWithoutRelated = new[] { "is forced by divine power", "suffers a forced power" }, + }, + ["just_possessed"] = new HappinessCatalogEntry + { + Id = "just_possessed", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + StatusOverlapId = "possessed", + VariantsWithRelated = new[] { "is possessed", "falls under possession" }, + VariantsWithoutRelated = new[] { "is possessed", "falls under possession" }, + }, + ["strange_urge"] = new HappinessCatalogEntry + { + Id = "strange_urge", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + StatusOverlapId = "strange_urge", + VariantsWithRelated = new[] { "feels a strange urge", "is gripped by a strange urge" }, + VariantsWithoutRelated = new[] { "feels a strange urge", "is gripped by a strange urge" }, + }, + ["just_had_tantrum"] = new HappinessCatalogEntry + { + Id = "just_had_tantrum", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + StatusOverlapId = "tantrum", + VariantsWithRelated = new[] { "finishes a tantrum", "calms after a tantrum" }, + VariantsWithoutRelated = new[] { "finishes a tantrum", "calms after a tantrum" }, + }, + ["just_felt_the_divine"] = new HappinessCatalogEntry + { + Id = "just_felt_the_divine", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + VariantsWithRelated = new[] { "feels the divine touch", "is touched by the divine" }, + VariantsWithoutRelated = new[] { "feels the divine touch", "is touched by the divine" }, + }, + ["just_enchanted"] = new HappinessCatalogEntry + { + Id = "just_enchanted", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + StatusOverlapId = "enchanted", + VariantsWithRelated = new[] { "is enchanted", "feels an enchantment take hold" }, + VariantsWithoutRelated = new[] { "is enchanted", "feels an enchantment take hold" }, + }, + ["just_inspired"] = new HappinessCatalogEntry + { + Id = "just_inspired", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + StatusOverlapId = "inspired", + VariantsWithRelated = new[] { "feels inspired", "is struck by inspiration" }, + VariantsWithoutRelated = new[] { "feels inspired", "is struck by inspiration" }, + }, + ["wrote_book"] = new HappinessCatalogEntry + { + Id = "wrote_book", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "writes a book", "finishes writing a book" }, + VariantsWithoutRelated = new[] { "writes a book", "finishes writing a book" }, + }, + ["just_became_adult"] = new HappinessCatalogEntry + { + Id = "just_became_adult", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "comes of age", "becomes an adult" }, + VariantsWithoutRelated = new[] { "comes of age", "becomes an adult" }, + }, + ["just_got_out_of_egg"] = new HappinessCatalogEntry + { + Id = "just_got_out_of_egg", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "hatches from an egg", "breaks free of an egg" }, + VariantsWithoutRelated = new[] { "hatches from an egg", "breaks free of an egg" }, + }, + ["just_finished_plot"] = new HappinessCatalogEntry + { + Id = "just_finished_plot", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "completes a plot", "finishes a long plot" }, + VariantsWithoutRelated = new[] { "completes a plot", "finishes a long plot" }, + }, + ["just_found_house"] = new HappinessCatalogEntry + { + Id = "just_found_house", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "finds a home", "settles into a house" }, + VariantsWithoutRelated = new[] { "finds a home", "settles into a house" }, + }, + ["just_lost_house"] = new HappinessCatalogEntry + { + Id = "just_lost_house", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "loses a home", "is left homeless" }, + VariantsWithoutRelated = new[] { "loses a home", "is left homeless" }, + }, + ["just_made_friend"] = new HappinessCatalogEntry + { + Id = "just_made_friend", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.RelatedOptional, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.CanonicalMilestone, + RelationRole = HappinessRelationRole.BestFriend, + InterestCategory = "Social", + CanonicalMilestoneKey = "milestone_friend", + VariantsWithRelated = new[] { "befriends {related}", "makes a best friend of {related}" }, + VariantsWithoutRelated = new[] { "makes a best friend", "finds a best friend" }, + }, + ["just_injured"] = new HappinessCatalogEntry + { + Id = "just_injured", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "suffers a lasting injury", "is crippled by injury" }, + VariantsWithoutRelated = new[] { "suffers a lasting injury", "is crippled by injury" }, + }, + ["just_cursed"] = new HappinessCatalogEntry + { + Id = "just_cursed", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Emotion", + StatusOverlapId = "cursed", + VariantsWithRelated = new[] { "is cursed", "falls under a curse" }, + VariantsWithoutRelated = new[] { "is cursed", "falls under a curse" }, + }, + ["starving"] = new HappinessCatalogEntry + { + Id = "starving", + Presentation = HappinessPresentationTier.Ambient, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Continuation, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Daily", + StatusOverlapId = "starving", + VariantsWithRelated = new[] { "is starving", "goes hungry" }, + VariantsWithoutRelated = new[] { "is starving", "goes hungry" }, + }, + ["conquered_city"] = new HappinessCatalogEntry + { + Id = "conquered_city", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "celebrates a conquered city", "rejoices at a city conquered" }, + VariantsWithoutRelated = new[] { "celebrates a conquered city", "rejoices at a city conquered" }, + }, + ["destroyed_city"] = new HappinessCatalogEntry + { + Id = "destroyed_city", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "celebrates a destroyed city", "cheers a city's destruction" }, + VariantsWithoutRelated = new[] { "celebrates a destroyed city", "cheers a city's destruction" }, + }, + ["lost_crown"] = new HappinessCatalogEntry + { + Id = "lost_crown", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "loses the crown", "is stripped of the crown" }, + VariantsWithoutRelated = new[] { "loses the crown", "is stripped of the crown" }, + }, + ["razed_capital"] = new HappinessCatalogEntry + { + Id = "razed_capital", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "despairs as the capital is razed", "mourns a razed capital" }, + VariantsWithoutRelated = new[] { "despairs as the capital is razed", "mourns a razed capital" }, + }, + ["lost_capital"] = new HappinessCatalogEntry + { + Id = "lost_capital", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "despairs at the lost capital", "mourns the lost capital" }, + VariantsWithoutRelated = new[] { "despairs at the lost capital", "mourns the lost capital" }, + }, + ["razed_city"] = new HappinessCatalogEntry + { + Id = "razed_city", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "despairs as a city is razed", "mourns a razed city" }, + VariantsWithoutRelated = new[] { "despairs as a city is razed", "mourns a razed city" }, + }, + ["lost_city"] = new HappinessCatalogEntry + { + Id = "lost_city", + Presentation = HappinessPresentationTier.Aggregate, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "Civic", + VariantsWithRelated = new[] { "despairs at a lost city", "mourns a lost city" }, + VariantsWithoutRelated = new[] { "despairs at a lost city", "mourns a lost city" }, + }, + ["become_alpha"] = new HappinessCatalogEntry + { + Id = "become_alpha", + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ProjectToLife, + Overlap = HappinessOverlapPolicy.Independent, + RelationRole = HappinessRelationRole.None, + InterestCategory = "LifeChapter", + VariantsWithRelated = new[] { "becomes the alpha", "rises as alpha" }, + VariantsWithoutRelated = new[] { "becomes the alpha", "rises as alpha" }, + }, + }; + + public static IEnumerable AuthoredIds => Entries.Keys; + + public static bool HasAuthored(string effectId) + { + return !string.IsNullOrEmpty(effectId) && Entries.ContainsKey(effectId.Trim()); + } + + public static bool TryGet(string effectId, out HappinessCatalogEntry entry) + { + entry = null; + if (string.IsNullOrEmpty(effectId)) + { + return false; + } + + return Entries.TryGetValue(effectId.Trim(), out entry); + } + + public static HappinessCatalogEntry GetOrFallback(string effectId) + { + if (TryGet(effectId, out HappinessCatalogEntry entry)) + { + return entry; + } + + string id = string.IsNullOrEmpty(effectId) ? "unknown" : effectId.Trim(); + return new HappinessCatalogEntry + { + Id = id, + Presentation = HappinessPresentationTier.Signal, + Context = HappinessContextRequirement.None, + Life = HappinessLifePolicy.ActivityOnly, + Overlap = HappinessOverlapPolicy.Independent, + InterestCategory = "Unknown", + VariantsWithRelated = new[] { "feels a change in happiness" }, + VariantsWithoutRelated = new[] { "feels a change in happiness" }, + IsFallback = true + }; + } +} + diff --git a/IdleSpectator/HappinessEventRouter.cs b/IdleSpectator/HappinessEventRouter.cs new file mode 100644 index 0000000..18da382 --- /dev/null +++ b/IdleSpectator/HappinessEventRouter.cs @@ -0,0 +1,738 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace IdleSpectator; + +/// +/// Sole writer from happiness hooks into Activity / Chronicle Life. +/// Patches publish occurrences; presentation tiers and overlap live here. +/// +public static class HappinessEventRouter +{ + public const float AmbientRingCooldown = 25f; + public const float CorrelationWindow = 3f; + public const float AggregateWindow = 0.75f; + public const int AggregateSampleCap = 8; + public const int MaxRecentOccurrences = 256; + + private static readonly List Recent = + new List(MaxRecentOccurrences); + private static readonly Dictionary AmbientRingAt = + new Dictionary(); + private static readonly Dictionary CorrelationAt = + new Dictionary(); + private static readonly Dictionary Aggregates = + new Dictionary(); + + private static int _notesSinceClear; + private static int _ringWritesSinceClear; + private static int _lifeWritesSinceClear; + private static int _mergedSinceClear; + private static int _suppressedSinceClear; + private static int _aggregateSummariesSinceClear; + private static string _lastEffectId = ""; + private static long _counterSubjectId; + + private sealed class AggregateBucket + { + public string EffectId; + public float StartedAt; + public int Total; + public int Sampled; + public Actor FirstNotable; + public string CivKey = ""; + } + + public static int NotesSinceClear => _notesSinceClear; + public static int RingWritesSinceClear => _ringWritesSinceClear; + public static int LifeWritesSinceClear => _lifeWritesSinceClear; + public static int MergedSinceClear => _mergedSinceClear; + public static int SuppressedSinceClear => _suppressedSinceClear; + public static int AggregateSummariesSinceClear => _aggregateSummariesSinceClear; + public static string LastEffectId => _lastEffectId ?? ""; + + public static void ClearSession() + { + Recent.Clear(); + AmbientRingAt.Clear(); + CorrelationAt.Clear(); + Aggregates.Clear(); + _notesSinceClear = 0; + _ringWritesSinceClear = 0; + _lifeWritesSinceClear = 0; + _mergedSinceClear = 0; + _suppressedSinceClear = 0; + _aggregateSummariesSinceClear = 0; + _lastEffectId = ""; + _counterSubjectId = 0; + } + + public static void ResetCountersForSubject(long subjectId) + { + _counterSubjectId = subjectId; + _notesSinceClear = 0; + _ringWritesSinceClear = 0; + _lifeWritesSinceClear = 0; + _mergedSinceClear = 0; + _suppressedSinceClear = 0; + _aggregateSummariesSinceClear = 0; + _lastEffectId = ""; + } + + public static IReadOnlyList Latest(int max = 32) + { + if (max <= 0 || Recent.Count == 0) + { + return Array.Empty(); + } + + int start = Math.Max(0, Recent.Count - max); + var list = new List(Recent.Count - start); + for (int i = start; i < Recent.Count; i++) + { + list.Add(Recent[i]); + } + + return list; + } + + /// Publish a happiness effect that the game actually applied (or harness force). + public static HappinessOccurrence PublishApplied( + Actor subject, + string effectId, + int amount, + HappinessSourceHook hook, + Actor related = null, + HappinessRelationRole role = HappinessRelationRole.None, + bool forceRing = false) + { + return PublishCore( + subject, + effectId, + amount, + hook, + related, + role, + applied: true, + suppressedByPsychopath: false, + forceRing); + } + + /// Record that the game refused to apply happiness (psychopath/egg/no emotions). + public static void PublishSuppressed(Actor subject, string effectId, HappinessSourceHook hook) + { + if (_counterSubjectId == 0 || (subject != null && subject.getID() == _counterSubjectId)) + { + _suppressedSinceClear++; + } + + var occ = BuildOccurrence( + subject, + effectId, + amount: 0, + hook, + related: null, + role: HappinessRelationRole.None, + applied: false, + suppressedByPsychopath: true); + occ.Merged = true; + Remember(occ); + } + + private static HappinessOccurrence PublishCore( + Actor subject, + string effectId, + int amount, + HappinessSourceHook hook, + Actor related, + HappinessRelationRole role, + bool applied, + bool suppressedByPsychopath, + bool forceRing) + { + if (subject == null || string.IsNullOrEmpty(effectId) || !applied) + { + return null; + } + + FlushStaleAggregates(); + + HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId); + if (related == null + && HappinessContextBag.TryGetRelated(subject, out Actor bagRelated, out HappinessRelationRole bagRole)) + { + related = bagRelated; + if (role == HappinessRelationRole.None) + { + role = bagRole; + } + } + + if (role == HappinessRelationRole.None) + { + role = entry.RelationRole != HappinessRelationRole.None + ? entry.RelationRole + : HappinessContextBag.RoleForDeathEffect(effectId); + } + + if (entry.Presentation == HappinessPresentationTier.Aggregate && !forceRing) + { + return PublishAggregateMember(subject, effectId, amount, hook, related, role, entry); + } + + HappinessOccurrence occ = BuildOccurrence( + subject, effectId, amount, hook, related, role, applied, suppressedByPsychopath); + FillProse(occ, entry); + + if (ShouldMerge(occ, entry)) + { + occ.Merged = true; + if (_counterSubjectId == 0 || occ.SubjectId == _counterSubjectId) + { + _mergedSinceClear++; + _notesSinceClear++; + _lastEffectId = effectId; + } + + Remember(occ); + return occ; + } + + bool writeRing = forceRing || ShouldWriteRing(occ, entry); + if (writeRing) + { + ActivityLog.NoteHappiness(occ); + occ.WroteActivityRing = true; + if (_counterSubjectId == 0 || occ.SubjectId == _counterSubjectId) + { + _ringWritesSinceClear++; + } + + MarkAmbient(occ); + } + + if (entry.Life == HappinessLifePolicy.ProjectToLife + && entry.Overlap != HappinessOverlapPolicy.CanonicalMilestone) + { + if (Chronicle.NoteHappinessLife(occ, subject, related)) + { + occ.WroteLife = true; + if (_counterSubjectId == 0 || occ.SubjectId == _counterSubjectId) + { + _lifeWritesSinceClear++; + } + } + } + + MarkCorrelation(occ, entry); + if (_counterSubjectId == 0 || occ.SubjectId == _counterSubjectId) + { + _notesSinceClear++; + _lastEffectId = effectId; + } + + Remember(occ); + return occ; + } + + private static HappinessOccurrence PublishAggregateMember( + Actor subject, + string effectId, + int amount, + HappinessSourceHook hook, + Actor related, + HappinessRelationRole role, + HappinessCatalogEntry entry) + { + string civKey = CivKey(subject, effectId); + float now = Time.unscaledTime; + if (!Aggregates.TryGetValue(civKey, out AggregateBucket bucket) + || now - bucket.StartedAt > AggregateWindow) + { + bucket = new AggregateBucket + { + EffectId = effectId, + StartedAt = now, + CivKey = civKey + }; + Aggregates[civKey] = bucket; + } + + bucket.Total++; + bool notable = IsNotable(subject); + bool takeSample = bucket.Sampled < AggregateSampleCap && (notable ? bucket.FirstNotable == null : true); + if (notable && bucket.FirstNotable == null) + { + bucket.FirstNotable = subject; + } + + if (takeSample) + { + bucket.Sampled++; + + // Notables and early samples get individual ring lines (one notable max). + HappinessOccurrence sample = BuildOccurrence( + subject, effectId, amount, hook, related, role, applied: true, suppressedByPsychopath: false); + sample.Presentation = HappinessPresentationTier.Aggregate; + sample.CivEventKey = civKey; + sample.SampledCount = 1; + sample.TotalAffectedCount = bucket.Total; + FillProse(sample, entry); + + bool writeRing = notable || bucket.Sampled <= 3; + if (writeRing) + { + ActivityLog.NoteHappiness(sample); + sample.WroteActivityRing = true; + if (_counterSubjectId == 0 || sample.SubjectId == _counterSubjectId) + { + _ringWritesSinceClear++; + } + } + + if (_counterSubjectId == 0 || sample.SubjectId == _counterSubjectId) + { + _notesSinceClear++; + _lastEffectId = effectId; + } + + Remember(sample); + return sample; + } + + // Overflow: count only; summary flushed later. + HappinessOccurrence skipped = BuildOccurrence( + subject, effectId, amount, hook, related, role, applied: true, suppressedByPsychopath: false); + skipped.Presentation = HappinessPresentationTier.Aggregate; + skipped.CivEventKey = civKey; + skipped.TotalAffectedCount = bucket.Total; + skipped.Merged = true; + FillProse(skipped, entry); + Remember(skipped); + return skipped; + } + + public static void FlushAggregatesNow() + { + FlushStaleAggregates(force: true); + } + + private static void FlushStaleAggregates(bool force = false) + { + float now = Time.unscaledTime; + if (Aggregates.Count == 0) + { + return; + } + + var done = new List(); + foreach (KeyValuePair kv in Aggregates) + { + if (force || now - kv.Value.StartedAt >= AggregateWindow) + { + done.Add(kv.Key); + } + } + + for (int i = 0; i < done.Count; i++) + { + string key = done[i]; + AggregateBucket bucket = Aggregates[key]; + Aggregates.Remove(key); + if (bucket.Total <= bucket.Sampled) + { + continue; + } + + Actor subject = bucket.FirstNotable; + if (subject == null) + { + continue; + } + + HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(bucket.EffectId); + HappinessOccurrence summary = BuildOccurrence( + subject, + bucket.EffectId, + amount: HappinessGameApi.ResolveAmount(bucket.EffectId), + HappinessSourceHook.AggregateSummary, + related: null, + role: HappinessRelationRole.None, + applied: true, + suppressedByPsychopath: false); + summary.Presentation = HappinessPresentationTier.Aggregate; + summary.CivEventKey = bucket.CivKey; + summary.SampledCount = bucket.Sampled; + summary.TotalAffectedCount = bucket.Total; + int others = Math.Max(0, bucket.Total - bucket.Sampled); + string baseClause = ActivityHappinessProse.Clause(entry, "", out _, out _); + summary.PlainClause = baseClause + " (and " + others + " others)"; + summary.RichClause = summary.PlainClause; + summary.FactLine = "Happiness " + bucket.EffectId + " x" + bucket.Total; + ActivityLog.NoteHappiness(summary); + summary.WroteActivityRing = true; + _aggregateSummariesSinceClear++; + if (_counterSubjectId == 0 || summary.SubjectId == _counterSubjectId) + { + _ringWritesSinceClear++; + _notesSinceClear++; + _lastEffectId = bucket.EffectId; + } + + Remember(summary); + } + } + + private static HappinessOccurrence BuildOccurrence( + Actor subject, + string effectId, + int amount, + HappinessSourceHook hook, + Actor related, + HappinessRelationRole role, + bool applied, + bool suppressedByPsychopath) + { + HappinessCatalogEntry entry = HappinessEventCatalog.GetOrFallback(effectId); + long subjectId = 0; + string subjectName = "Someone"; + string species = ""; + Vector3 pos = Vector3.zero; + try + { + subjectId = subject.getID(); + subjectName = subject.getName(); + if (string.IsNullOrEmpty(subjectName)) + { + subjectName = subject.asset != null ? subject.asset.id : "creature"; + } + + species = subject.asset != null ? subject.asset.id : ""; + pos = subject.current_position; + } + catch + { + // keep defaults + } + + long relatedId = 0; + string relatedName = ""; + string relatedSpecies = ""; + if (related != null) + { + try + { + relatedId = related.getID(); + relatedName = related.getName(); + if (string.IsNullOrEmpty(relatedName)) + { + relatedName = related.asset != null ? related.asset.id : "creature"; + } + + relatedSpecies = related.asset != null ? related.asset.id : ""; + } + catch + { + // ignore + } + } + + StampWorldDate(out double worldTime, out string dateLabel); + string corr = subjectId + "|" + effectId + "|" + relatedId + "|" + Math.Floor(worldTime); + + return new HappinessOccurrence + { + EffectId = effectId.Trim(), + Amount = amount, + SubjectId = subjectId, + SubjectName = subjectName, + SubjectSpecies = species, + RelatedId = relatedId, + RelatedName = relatedName, + RelatedSpecies = relatedSpecies, + RelationRole = role, + Position = pos, + WorldTime = worldTime, + DateLabel = dateLabel, + SourceHook = hook, + CorrelationKey = corr, + SuppressedByPsychopath = suppressedByPsychopath, + Applied = applied, + Presentation = entry.Presentation, + Life = entry.Life, + Overlap = entry.Overlap, + Context = entry.Context, + InterestCategory = entry.InterestCategory, + CanonicalMilestoneKey = entry.CanonicalMilestoneKey, + StatusOverlapId = entry.StatusOverlapId + }; + } + + private static void FillProse(HappinessOccurrence occ, HappinessCatalogEntry entry) + { + string clause = ActivityHappinessProse.Clause( + entry, occ.RelatedName, out bool usedRelated, out _); + if (entry.Context == HappinessContextRequirement.RequiresRelatedActor + && string.IsNullOrEmpty(occ.RelatedName)) + { + // Still author a subject-only line at runtime for safety; audit fails separately. + clause = ActivityHappinessProse.Clause(entry, "", out _, out _); + usedRelated = false; + } + + occ.PlainClause = clause; + if (usedRelated && !string.IsNullOrEmpty(occ.RelatedName)) + { + occ.RichClause = clause.Replace( + occ.RelatedName, + ActivityProse.ColorName(occ.RelatedName)); + } + else + { + occ.RichClause = clause; + } + + occ.FactLine = "Happiness " + occ.EffectId + + (occ.Amount != 0 ? " " + (occ.Amount > 0 ? "+" : "") + occ.Amount : ""); + } + + private static bool ShouldMerge(HappinessOccurrence occ, HappinessCatalogEntry entry) + { + float now = Time.unscaledTime; + if (entry.Overlap == HappinessOverlapPolicy.CanonicalMilestone + && !string.IsNullOrEmpty(entry.CanonicalMilestoneKey)) + { + string key = "canon|" + occ.SubjectId + "|" + entry.CanonicalMilestoneKey; + if (CorrelationAt.TryGetValue(key, out float at) && now - at <= CorrelationWindow) + { + return true; + } + } + + if (entry.Overlap == HappinessOverlapPolicy.Continuation + && !string.IsNullOrEmpty(entry.StatusOverlapId)) + { + string key = "status|" + occ.SubjectId + "|" + entry.StatusOverlapId; + if (CorrelationAt.TryGetValue(key, out float at) && now - at <= CorrelationWindow) + { + // Same-second status+happiness: keep status as the visible phase; merge happiness ring. + return true; + } + } + + string selfKey = "happ|" + occ.SubjectId + "|" + occ.EffectId + "|" + occ.RelatedId; + if (CorrelationAt.TryGetValue(selfKey, out float selfAt) && now - selfAt <= 0.35f) + { + return true; + } + + return false; + } + + private static bool ShouldWriteRing(HappinessOccurrence occ, HappinessCatalogEntry entry) + { + if (entry.Presentation == HappinessPresentationTier.Signal) + { + return true; + } + + if (entry.Presentation == HappinessPresentationTier.Ambient) + { + string key = occ.SubjectId + "|" + occ.EffectId; + float now = Time.unscaledTime; + if (AmbientRingAt.TryGetValue(key, out float at) && now - at < AmbientRingCooldown) + { + return false; + } + + return true; + } + + return true; + } + + private static void MarkAmbient(HappinessOccurrence occ) + { + if (occ.Presentation != HappinessPresentationTier.Ambient) + { + return; + } + + AmbientRingAt[occ.SubjectId + "|" + occ.EffectId] = Time.unscaledTime; + } + + private static void MarkCorrelation(HappinessOccurrence occ, HappinessCatalogEntry entry) + { + float now = Time.unscaledTime; + CorrelationAt["happ|" + occ.SubjectId + "|" + occ.EffectId + "|" + occ.RelatedId] = now; + if (!string.IsNullOrEmpty(entry.CanonicalMilestoneKey)) + { + CorrelationAt["canon|" + occ.SubjectId + "|" + entry.CanonicalMilestoneKey] = now; + } + + if (!string.IsNullOrEmpty(entry.StatusOverlapId)) + { + CorrelationAt["status|" + occ.SubjectId + "|" + entry.StatusOverlapId] = now; + } + + PruneDict(CorrelationAt, now, CorrelationWindow * 4f); + PruneDict(AmbientRingAt, now, AmbientRingCooldown * 4f); + } + + /// Called when Chronicle logs a canonical lover/friend/kill milestone. + public static void NoteCanonicalMilestone(long subjectId, string milestoneKey) + { + if (subjectId == 0 || string.IsNullOrEmpty(milestoneKey)) + { + return; + } + + CorrelationAt["canon|" + subjectId + "|" + milestoneKey] = Time.unscaledTime; + } + + /// Called when status gain/loss is logged so Continuation overlaps can merge. + public static void NoteStatusPhase(long subjectId, string statusId) + { + if (subjectId == 0 || string.IsNullOrEmpty(statusId)) + { + return; + } + + CorrelationAt["status|" + subjectId + "|" + statusId] = Time.unscaledTime; + } + + private static void Remember(HappinessOccurrence occ) + { + if (occ == null) + { + return; + } + + Recent.Add(occ); + while (Recent.Count > MaxRecentOccurrences) + { + Recent.RemoveAt(0); + } + } + + private static bool IsNotable(Actor actor) + { + if (actor == null) + { + return false; + } + + try + { + if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null + && MoveCamera._focus_unit.getID() == actor.getID()) + { + return true; + } + } + catch + { + // ignore + } + + try + { + if (actor.isFavorite()) + { + return true; + } + } + catch + { + // ignore + } + + try + { + if (actor.isKing() || actor.isCityLeader()) + { + return true; + } + } + catch + { + // ignore + } + + return false; + } + + private static string CivKey(Actor subject, string effectId) + { + string kingdom = ""; + try + { + if (subject.kingdom != null) + { + kingdom = subject.kingdom.name ?? ""; + } + } + catch + { + kingdom = ""; + } + + return effectId + "|" + kingdom + "|" + Math.Floor(Time.unscaledTime / AggregateWindow); + } + + private static void StampWorldDate(out double worldTime, out string dateLabel) + { + worldTime = 0; + dateLabel = ""; + try + { + if (World.world == null) + { + return; + } + + worldTime = World.world.getCurWorldTime(); + int[] raw = Date.getRawDate(worldTime); + if (raw == null || raw.Length < 3) + { + dateLabel = "y" + Date.getCurrentYear() + " m" + Date.getCurrentMonth(); + return; + } + + string monthName = Date.formatMonth(raw[1]); + if (string.IsNullOrEmpty(monthName)) + { + monthName = "m" + raw[1]; + } + + dateLabel = monthName + " " + raw[2]; + } + catch + { + dateLabel = ""; + } + } + + private static void PruneDict(Dictionary dict, float now, float maxAge) + { + if (dict.Count < 256) + { + return; + } + + var remove = new List(); + foreach (KeyValuePair kv in dict) + { + if (now - kv.Value > maxAge) + { + remove.Add(kv.Key); + } + } + + for (int i = 0; i < remove.Count; i++) + { + dict.Remove(remove[i]); + } + } +} diff --git a/IdleSpectator/HappinessGameApi.cs b/IdleSpectator/HappinessGameApi.cs new file mode 100644 index 0000000..a7836c8 --- /dev/null +++ b/IdleSpectator/HappinessGameApi.cs @@ -0,0 +1,73 @@ +using System; +using System.Reflection; +using HarmonyLib; + +namespace IdleSpectator; + +/// Reflection helpers for happiness library / changeHappiness harness paths. +internal static class HappinessGameApi +{ + private static MethodInfo _changeHappiness; + + public static bool TryChangeHappiness(Actor actor, string effectId, int bonus = 0) + { + if (actor == null || string.IsNullOrEmpty(effectId)) + { + return false; + } + + try + { + if (_changeHappiness == null) + { + _changeHappiness = AccessTools.Method( + typeof(Actor), + "changeHappiness", + new[] { typeof(string), typeof(int) }); + } + + if (_changeHappiness == null) + { + // Public method should be callable directly. + return actor.changeHappiness(effectId.Trim(), bonus); + } + + object result = _changeHappiness.Invoke(actor, new object[] { effectId.Trim(), bonus }); + return result is bool ok && ok; + } + catch + { + try + { + return actor.changeHappiness(effectId.Trim(), bonus); + } + catch + { + return false; + } + } + } + + public static HappinessAsset TryGetAsset(string effectId) + { + return ActivityAssetCatalog.TryGetHappinessAsset(effectId); + } + + public static int ResolveAmount(string effectId, int bonus = 0) + { + HappinessAsset asset = TryGetAsset(effectId); + if (asset == null) + { + return bonus; + } + + try + { + return bonus + asset.value; + } + catch + { + return bonus; + } + } +} diff --git a/IdleSpectator/HappinessModels.cs b/IdleSpectator/HappinessModels.cs new file mode 100644 index 0000000..48c1ea6 --- /dev/null +++ b/IdleSpectator/HappinessModels.cs @@ -0,0 +1,111 @@ +using System; +using UnityEngine; + +namespace IdleSpectator; + +public enum HappinessPresentationTier +{ + Signal, + Ambient, + Aggregate +} + +public enum HappinessContextRequirement +{ + None, + RequiresRelatedActor, + RelatedOptional +} + +public enum HappinessLifePolicy +{ + ActivityOnly, + ProjectToLife +} + +public enum HappinessOverlapPolicy +{ + Independent, + CanonicalMilestone, + MergeSameOccurrence, + Continuation +} + +public enum HappinessRelationRole +{ + None, + DeceasedChild, + DeceasedLover, + DeceasedBestFriend, + DeceasedFamily, + Newborn, + Lover, + BestFriend, + GiftPartner, + ConversationPartner, + Robber, + Victim +} + +public enum HappinessSourceHook +{ + ChangeHappiness, + DeathEvent, + BirthEvent, + Harness, + AggregateSummary +} + +/// Authored catalog row for one happiness effect id. +public sealed class HappinessCatalogEntry +{ + public string Id = ""; + public HappinessPresentationTier Presentation = HappinessPresentationTier.Signal; + public HappinessContextRequirement Context = HappinessContextRequirement.None; + public HappinessLifePolicy Life = HappinessLifePolicy.ActivityOnly; + public HappinessOverlapPolicy Overlap = HappinessOverlapPolicy.Independent; + public HappinessRelationRole RelationRole = HappinessRelationRole.None; + public string InterestCategory = ""; + public string CanonicalMilestoneKey = ""; + public string StatusOverlapId = ""; + public string[] VariantsWithRelated = Array.Empty(); + public string[] VariantsWithoutRelated = Array.Empty(); + public bool IsFallback; +} + +/// Normalized happiness/emotion occurrence for Activity, Life, and future spectator interest. +public sealed class HappinessOccurrence +{ + public string EffectId = ""; + public int Amount; + public long SubjectId; + public string SubjectName = ""; + public string SubjectSpecies = ""; + public long RelatedId; + public string RelatedName = ""; + public string RelatedSpecies = ""; + public HappinessRelationRole RelationRole = HappinessRelationRole.None; + public Vector3 Position; + public double WorldTime; + public string DateLabel = ""; + public HappinessSourceHook SourceHook = HappinessSourceHook.ChangeHappiness; + public string CorrelationKey = ""; + public bool SuppressedByPsychopath; + public bool Applied; + public HappinessPresentationTier Presentation = HappinessPresentationTier.Signal; + public HappinessLifePolicy Life = HappinessLifePolicy.ActivityOnly; + public HappinessOverlapPolicy Overlap = HappinessOverlapPolicy.Independent; + public HappinessContextRequirement Context = HappinessContextRequirement.None; + public string InterestCategory = ""; + public string CanonicalMilestoneKey = ""; + public string StatusOverlapId = ""; + public int SampledCount = 1; + public int TotalAffectedCount = 1; + public string CivEventKey = ""; + public string PlainClause = ""; + public string RichClause = ""; + public string FactLine = ""; + public bool WroteActivityRing; + public bool WroteLife; + public bool Merged; +} diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 6aef175..e55ac3d 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -40,6 +40,10 @@ internal static class HarnessScenarios return ActivityIdleSmoke(); case "activity_status_smoke": return ActivityStatusSmoke(); + case "activity_happiness_smoke": + return ActivityHappinessSmoke(); + case "activity_happiness_audit": + return ActivityHappinessAudit(); case "activity_prose_showcase": return ActivityProseShowcase(); case "activity_taxonomy_audit": @@ -125,11 +129,95 @@ internal static class HarnessScenarios Nested("reg_chronicle", "chronicle_smoke"), Nested("reg_activity", "activity_idle_smoke"), Nested("reg_activity_status", "activity_status_smoke"), + Nested("reg_activity_happiness", "activity_happiness_smoke"), Step("reg_end", "fast_timing", value: "false"), Step("reg_snap", "snapshot"), }; } + private static List ActivityHappinessAudit() + { + return new List + { + Step("aha0", "wait_world"), + Step("aha1", "pick_unit", asset: "auto"), + Step("aha2", "focus", asset: "auto"), + Step("aha3", "assert", expect: "activity_happiness_audit"), + Step("aha4", "snapshot") + }; + } + + private static List ActivityHappinessSmoke() + { + return new List + { + Step("hp0", "wait_world"), + Step("hp1", "dismiss_windows"), + Step("hp1b", "spectator", value: "off"), + Step("hp2", "spawn", asset: "human"), + Step("hp3", "focus", asset: "auto"), + Step("hp4", "activity_clear"), + Step("hp5", "happiness_reset"), + Step("hp6", "assert", expect: "activity_happiness_audit"), + + // Signal + Life: crown + Step("hp10", "happiness_apply", value: "become_king"), + Step("hp11", "assert", expect: "activity_happiness_task_id", value: "happiness:become_king"), + Step("hp12", "assert", expect: "activity_happiness_ring_count", value: "1", label: "exact"), + Step("hp13", "assert", expect: "activity_happiness_life_count", value: "1", label: "min"), + Step("hp14", "assert", expect: "activity_log_contains", value: "throne"), + + // Ambient cooldown: second meal within window should not add another ring write + Step("hp20", "happiness_reset"), + Step("hp21", "happiness_apply", value: "just_ate"), + Step("hp22", "assert", expect: "activity_happiness_ring_count", value: "1", label: "exact"), + Step("hp23", "happiness_apply", value: "just_ate"), + Step("hp24", "assert", expect: "activity_happiness_count", value: "2", label: "exact"), + Step("hp25", "assert", expect: "activity_happiness_ring_count", value: "1", label: "exact"), + + // Force related grief prose + Step("hp30", "happiness_reset"), + Step("hp31", "happiness_force_note", value: "death_child", label: "Ava"), + Step("hp32", "assert", expect: "activity_log_contains", value: "Ava"), + Step("hp33", "assert", expect: "activity_happiness_task_id", value: "happiness:death_child"), + + // Real lover grief path + Step("hp40", "spawn", asset: "human"), + Step("hp41", "happiness_remember_partner"), + Step("hp42", "spawn", asset: "human"), + Step("hp43", "focus", asset: "auto"), + Step("hp44", "activity_clear"), + Step("hp45", "happiness_reset"), + Step("hp46", "happiness_bond_lovers"), + Step("hp47", "wait", value: "0.5"), + Step("hp48", "happiness_kill_partner"), + Step("hp49", "wait", value: "0.5"), + Step("hp50", "assert", expect: "activity_log_contains", value: "lover"), + Step("hp51", "assert", expect: "activity_happiness_life_count", value: "1", label: "min"), + + // Friend bond + canonical merge path + Step("hp60", "spawn", asset: "human"), + Step("hp61", "happiness_remember_partner"), + Step("hp62", "spawn", asset: "human"), + Step("hp63", "focus", asset: "auto"), + Step("hp64", "activity_clear"), + Step("hp65", "happiness_reset"), + Step("hp66", "happiness_bond_friends"), + Step("hp67", "assert", expect: "activity_happiness_count", value: "1", label: "min"), + + // Full catalog inject through real API + Step("hp70", "happiness_reset"), + Step("hp71", "happiness_inject_all"), + Step("hp72", "assert", expect: "activity_happiness_count", value: "1", label: "min"), + + // Aggregate burst budget + Step("hp80", "happiness_reset"), + Step("hp81", "happiness_burst", value: "just_started_war", count: 48), + + Step("hp90", "snapshot") + }; + } + private static List ActivityStatusAudit() { return new List @@ -469,14 +557,15 @@ internal static class HarnessScenarios Step("dt1", "wait_world"), Step("dt2", "set_setting", expect: "enabled", value: "true"), Step("dt3", "fast_timing", value: "true"), - Step("dt4", "pick_unit", asset: "auto"), + // Calm unit + spectator restart clears collector so world Story tips cannot steal CurioA. + Step("dt4", "spawn", asset: "sheep"), Step("dt5", "spectator", value: "off"), Step("dt6", "spectator", value: "on"), - Step("dt7", "focus", asset: "auto"), + Step("dt7", "focus", asset: "sheep"), Step("dt8", "reset_counters"), // Curiosity can replace ambient after rotate window. - Step("dt10", "trigger_interest", asset: "auto", label: "CurioA", tier: "Curiosity"), + Step("dt10", "trigger_interest", asset: "sheep", label: "CurioA", tier: "Curiosity"), Step("dt11", "age_current", wait: 2f), Step("dt12", "director_run", wait: 1.2f), Step("dt13", "assert", expect: "current_tier", value: "Curiosity"), @@ -512,28 +601,29 @@ internal static class HarnessScenarios Step("d2", "set_setting", expect: "enabled", value: "true"), Step("d3", "fast_timing", value: "true"), Step("d4", "discovery_reset"), - Step("d5", "pick_unit", asset: "auto"), + // Calm animal focus so Action-tier combat does not block curiosity drain. + Step("d5", "spawn", asset: "sheep"), Step("d6", "spectator", value: "off"), Step("d7", "spectator", value: "on"), - Step("d8", "focus", asset: "auto"), + Step("d8", "focus", asset: "sheep"), Step("d9", "reset_counters"), // Live-path dedupe: mark presented drops buffered copy. - Step("d10", "discovery_note", asset: "auto"), + Step("d10", "discovery_note", asset: "sheep"), Step("d11", "assert", expect: "pending_discovery", value: "1"), - Step("d12", "discovery_mark", asset: "auto"), + Step("d12", "discovery_mark", asset: "sheep"), Step("d13", "assert", expect: "pending_discovery", value: "0"), - Step("d14", "assert", expect: "presented", asset: "auto", value: "true"), + Step("d14", "assert", expect: "presented", asset: "sheep", value: "true"), // Force drain path once curiosity is acceptable. Step("d20", "discovery_reset"), - Step("d21", "discovery_note", asset: "auto"), + Step("d21", "discovery_note", asset: "sheep"), Step("d22", "assert", expect: "pending_discovery", value: "1"), Step("d23", "age_current", wait: 2f), Step("d24", "assert", expect: "would_accept_curiosity", value: "true"), Step("d25", "discovery_drain"), Step("d26", "assert", expect: "pending_discovery_asset", value: "false"), - Step("d27", "assert", expect: "presented", asset: "auto", value: "true"), + Step("d27", "assert", expect: "presented", asset: "sheep", value: "true"), Step("d28", "director_run", wait: 1.2f), Step("d29", "assert", expect: "tip_matches_unit"), Step("d30", "assert", expect: "no_bad"), @@ -804,6 +894,8 @@ internal static class HarnessScenarios Step("ch18m", "lore_open_focus"), Step("ch18n", "wait", wait: 0.3f), Step("ch18o", "assert", expect: "idle", value: "false"), + Step("ch18o1", "lore_tab", value: "living"), + Step("ch18o1b", "wait", wait: 0.15f), Step("ch18o2", "assert", expect: "lore_tab", value: "living"), Step("ch18o3", "assert", expect: "lore_detail", value: "true"), Step("ch18o3b", "assert", expect: "lore_follow", value: "true"), @@ -843,8 +935,9 @@ internal static class HarnessScenarios Step("ch18p6", "wait", wait: 0.25f), Step("ch18p7", "assert", expect: "idle", value: "false"), Step("ch18p8", "assert", expect: "lore_detail", value: "true"), - Step("ch18p9", "assert", expect: "dossier_history_shown", value: "0", label: "exact", tier: "life"), - Step("ch18p10", "assert", expect: "lore_history_shown", value: "0", label: "exact"), + // Fresh spawn still gets just_born Life; empty History UI is gone - expect birth biography. + Step("ch18p9", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "life"), + Step("ch18p10", "assert", expect: "lore_history_shown", value: "1", label: "min"), Step("ch18p11", "assert", expect: "dossier_lore_history_match"), Step("ch18p12", "screenshot", value: "hud-lore-empty-history.png"), Step("ch18p13", "wait", wait: 0.55f), diff --git a/IdleSpectator/HudIcons.cs b/IdleSpectator/HudIcons.cs index 7025904..da34dbd 100644 --- a/IdleSpectator/HudIcons.cs +++ b/IdleSpectator/HudIcons.cs @@ -200,6 +200,14 @@ public static class HudIcons ?? FromUiIcon("iconClock"); } + if (ActivityHappinessProse.TryParseTaskKey(key, out string happinessId)) + { + return FromHappinessId(happinessId) + ?? FromUiIcon("iconHappy") + ?? Task() + ?? FromUiIcon("iconClock"); + } + string verb = ActivityVerbMap.Resolve(key); if (string.IsNullOrEmpty(verb)) { @@ -415,6 +423,64 @@ public static class HudIcons return null; } + public static Sprite FromHappinessId(string effectId) + { + if (string.IsNullOrEmpty(effectId)) + { + return null; + } + + try + { + HappinessAsset asset = AssetManager.happiness_library?.get(effectId.Trim()); + if (asset == null) + { + return null; + } + + return FromHappiness(asset); + } + catch + { + return null; + } + } + + public static Sprite FromHappiness(HappinessAsset asset) + { + if (asset == null) + { + return null; + } + + try + { + Sprite sprite = asset.getSprite(); + if (sprite != null) + { + return sprite; + } + } + catch + { + // fall through + } + + try + { + if (!string.IsNullOrEmpty(asset.path_icon)) + { + return SpriteTextureLoader.getSprite(asset.path_icon); + } + } + catch + { + // ignore + } + + return null; + } + public static void Apply(UnityEngine.UI.Image image, Sprite sprite, bool preserveAspect = true) { if (image == null) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 9ad5bc8..207b2ca 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.12.103", - "description": "AFK Idle Spectator (I) + Lore (L). Detailed living activity prose by default.", + "version": "0.13.8", + "description": "AFK Idle Spectator (I) + Lore (L). Exhaustive happiness/emotion Activity + Life logging.", "GUID": "com.dazed.idlespectator" } diff --git a/scripts/harness-run.sh b/scripts/harness-run.sh index 1a03e8f..4688957 100755 --- a/scripts/harness-run.sh +++ b/scripts/harness-run.sh @@ -36,6 +36,7 @@ REGRESSION_SCENARIOS=( chronicle_smoke activity_idle_smoke activity_status_smoke + activity_happiness_smoke ) scenario=""