From 1c00a7980a96aedaee532e5f0c05dde7b62cb407 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Thu, 16 Jul 2026 01:08:36 -0500 Subject: [PATCH] Enhance IdleSpectator event handling by introducing hatch event logic and refining related event reasons. Update various event catalogs to improve clarity and accuracy of event descriptions, particularly for hatching and dream-related events. Increment version in mod.json to 0.21.3 to reflect these changes. --- IdleSpectator/ActivityStatusProse.cs | 10 +- IdleSpectator/AgentHarness.cs | 33 +++++ .../Events/Catalogs/EventCatalog.Happiness.cs | 97 +++++++------- .../Catalogs/EventCatalog.Relationship.cs | 4 +- IdleSpectator/Events/EventReason.cs | 11 +- IdleSpectator/Events/Feeds/InterestFeeds.cs | 124 +++++++++++++++--- IdleSpectator/HarnessScenarios.cs | 7 + IdleSpectator/InterestDirector.cs | 29 ++-- IdleSpectator/UnitDossier.cs | 44 ++++++- IdleSpectator/mod.json | 2 +- docs/event-audit.md | 2 +- docs/event-prose-audit.md | 61 +++++++++ 12 files changed, 338 insertions(+), 86 deletions(-) create mode 100644 docs/event-prose-audit.md diff --git a/IdleSpectator/ActivityStatusProse.cs b/IdleSpectator/ActivityStatusProse.cs index d573c1c..f7a9fd0 100644 --- a/IdleSpectator/ActivityStatusProse.cs +++ b/IdleSpectator/ActivityStatusProse.cs @@ -26,17 +26,17 @@ public static class ActivityStatusProse ["dash"] = ("Dashes forward", "Ends the dash"), ["dodge"] = ("Dodges aside", "Stops dodging"), ["drowning"] = ("Starts drowning", "Gets air again"), - ["egg"] = ("Turns into an egg", "Hatches from the egg"), + ["egg"] = ("Becomes an egg", "Hatches from an egg"), ["enchanted"] = ("Becomes enchanted", "Loses the enchantment"), ["fell_in_love"] = ("Falls in love", "Lets the infatuation pass"), ["festive_spirit"] = ("Catches the festive spirit", "Settles out of the festive mood"), ["flicked"] = ("Gets flicked", "Recovers from the flick"), ["frozen"] = ("Freezes solid", "Thaws out"), - ["had_bad_dream"] = ("Wakes from a bad dream", "Shakes off the bad dream"), - ["had_good_dream"] = ("Wakes from a good dream", "Lets the good dream fade"), - ["had_nightmare"] = ("Wakes from a nightmare", "Shakes off the nightmare"), + ["had_bad_dream"] = ("Has a bad dream", "Lets a bad dream fade"), + ["had_good_dream"] = ("Has a good dream", "Lets a good dream fade"), + ["had_nightmare"] = ("Has a nightmare", "Lets a nightmare fade"), ["handsome_migrant"] = ("Turns heads as a handsome migrant", "Stops drawing migrant attention"), - ["inspired"] = ("Feels inspired", "Loses the spark of inspiration"), + ["inspired"] = ("Feels inspired", "Comes down from inspiration"), ["invincible"] = ("Becomes invincible", "Loses invincibility"), ["just_ate"] = ("Just finished eating", "Is ready to eat again"), ["laughing"] = ("Bursts out laughing", "Stops laughing"), diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index a0088ee..9b84965 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -648,6 +648,39 @@ public static class AgentHarness break; } + case "hatch_emit": + { + Actor focus = ResolveUnit( + string.IsNullOrEmpty(cmd.asset) || cmd.asset == "auto" ? null : cmd.asset); + if (focus == null) + { + focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; + } + + bool alive = false; + try + { + alive = focus != null && focus.isAlive(); + } + catch + { + alive = false; + } + + if (alive) + { + InterestFeeds.EmitHatch(focus, "harness"); + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, alive, detail: alive ? EventReason.Hatch(focus) : "no unit"); + break; + } + case "status_clear_all": { Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null; diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Happiness.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Happiness.cs index 7a416c0..e7d8a71 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Happiness.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Happiness.cs @@ -99,21 +99,22 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "Emotion", - VariantsWithRelated = new[] { "loses a fight", "is beaten in a fight" }, - VariantsWithoutRelated = new[] { "loses a fight", "is beaten in a fight" }, + VariantsWithRelated = new[] { "is forced to yield in a fight", "gives up mid-fight" }, + VariantsWithoutRelated = new[] { "is forced to yield in a fight", "gives up mid-fight" }, }, ["got_caught"] = new HappinessCatalogEntry { Id = "got_caught", - EventStrength = 48f, - Presentation = HappinessPresentationTier.Signal, + // Library-only id today - no changeHappiness("got_caught") call site in the game. + EventStrength = 20f, + Presentation = HappinessPresentationTier.Ambient, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ActivityOnly, Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "Emotion", - VariantsWithRelated = new[] { "is caught doing something shady", "gets caught" }, - VariantsWithoutRelated = new[] { "is caught doing something shady", "gets caught" }, + VariantsWithRelated = new[] { "gets caught", "is caught" }, + VariantsWithoutRelated = new[] { "gets caught", "is caught" }, }, ["paid_tax"] = new HappinessCatalogEntry { @@ -191,8 +192,8 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "Daily", - VariantsWithRelated = new[] { "wakes from a restful sleep", "finishes sleeping" }, - VariantsWithoutRelated = new[] { "wakes from a restful sleep", "finishes sleeping" }, + VariantsWithRelated = new[] { "finishes sleeping", "ends a rest" }, + VariantsWithoutRelated = new[] { "finishes sleeping", "ends a rest" }, }, ["had_bad_dream"] = new HappinessCatalogEntry { @@ -205,8 +206,8 @@ public static partial class EventCatalog RelationRole = HappinessRelationRole.None, InterestCategory = "Daily", StatusOverlapId = "had_bad_dream", - VariantsWithRelated = new[] { "wakes from a bad dream", "shakes off a bad dream" }, - VariantsWithoutRelated = new[] { "wakes from a bad dream", "shakes off a bad dream" }, + VariantsWithRelated = new[] { "has a bad dream", "is troubled by a bad dream" }, + VariantsWithoutRelated = new[] { "has a bad dream", "is troubled by a bad dream" }, }, ["had_good_dream"] = new HappinessCatalogEntry { @@ -219,8 +220,8 @@ public static partial class EventCatalog RelationRole = HappinessRelationRole.None, InterestCategory = "Daily", StatusOverlapId = "had_good_dream", - VariantsWithRelated = new[] { "wakes from a good dream", "smiles about a good dream" }, - VariantsWithoutRelated = new[] { "wakes from a good dream", "smiles about a good dream" }, + VariantsWithRelated = new[] { "has a good dream", "dreams pleasantly" }, + VariantsWithoutRelated = new[] { "has a good dream", "dreams pleasantly" }, }, ["had_nightmare"] = new HappinessCatalogEntry { @@ -233,8 +234,8 @@ public static partial class EventCatalog RelationRole = HappinessRelationRole.None, InterestCategory = "Daily", StatusOverlapId = "had_nightmare", - VariantsWithRelated = new[] { "wakes from a nightmare", "shakes off a nightmare" }, - VariantsWithoutRelated = new[] { "wakes from a nightmare", "shakes off a nightmare" }, + VariantsWithRelated = new[] { "has a nightmare", "is haunted by a nightmare" }, + VariantsWithoutRelated = new[] { "has a nightmare", "is haunted by a nightmare" }, }, ["slept_outside"] = new HappinessCatalogEntry { @@ -246,12 +247,13 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "Daily", - VariantsWithRelated = new[] { "sleeps outside and feels worse for it", "spends a rough night outside" }, - VariantsWithoutRelated = new[] { "sleeps outside and feels worse for it", "spends a rough night outside" }, + VariantsWithRelated = new[] { "has to sleep without a house", "settles down outdoors, unhappy" }, + VariantsWithoutRelated = new[] { "has to sleep without a house", "settles down outdoors, unhappy" }, }, ["just_kissed"] = new HappinessCatalogEntry { Id = "just_kissed", + // Game id is mating afterglow (BehCheckForBabiesFromSexualReproduction), not a kiss. EventStrength = 50f, Presentation = HappinessPresentationTier.Signal, Context = HappinessContextRequirement.RelatedOptional, @@ -259,12 +261,13 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.Lover, InterestCategory = "Social", - VariantsWithRelated = new[] { "shares a kiss with {related}", "kisses {related}" }, - VariantsWithoutRelated = new[] { "shares a kiss", "steals a kiss" }, + VariantsWithRelated = new[] { "mates with {related}", "shares intimacy with {related}" }, + VariantsWithoutRelated = new[] { "mates with their lover", "shares intimacy with their lover" }, }, ["just_killed"] = new HappinessCatalogEntry { Id = "just_killed", + // Only applied in newKillAction when the killer has trait bloodlust. EventStrength = 45f, Presentation = HappinessPresentationTier.Signal, Context = HappinessContextRequirement.None, @@ -273,8 +276,8 @@ public static partial class EventCatalog RelationRole = HappinessRelationRole.Victim, InterestCategory = "Emotion", CanonicalMilestoneKey = "milestone_kill", - VariantsWithRelated = new[] { "kills {related} and feels a rush", "takes a life" }, - VariantsWithoutRelated = new[] { "takes a life", "feels a rush after a kill" }, + VariantsWithRelated = new[] { "kills {related} and feels bloodlust's rush", "bloodlust flares after killing {related}" }, + VariantsWithoutRelated = new[] { "bloodlust flares after a kill", "feels bloodlust's rush after a kill" }, }, ["become_king"] = new HappinessCatalogEntry { @@ -286,8 +289,8 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "LifeChapter", - VariantsWithRelated = new[] { "is crowned king", "takes the throne" }, - VariantsWithoutRelated = new[] { "is crowned king", "takes the throne" }, + VariantsWithRelated = new[] { "takes the crown", "becomes ruler" }, + VariantsWithoutRelated = new[] { "takes the crown", "becomes ruler" }, }, ["become_leader"] = new HappinessCatalogEntry { @@ -377,8 +380,8 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "Civic", - VariantsWithRelated = new[] { "cheers as war begins", "is stirred by the start of war" }, - VariantsWithoutRelated = new[] { "cheers as war begins", "is stirred by the start of war" }, + VariantsWithRelated = new[] { "is glad war has started", "cheers a war their culture loves" }, + VariantsWithoutRelated = new[] { "is glad war has started", "cheers a war their culture loves" }, }, ["just_rebelled"] = new HappinessCatalogEntry { @@ -390,8 +393,8 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "Civic", - VariantsWithRelated = new[] { "joins a rebellion", "rises in rebellion" }, - VariantsWithoutRelated = new[] { "joins a rebellion", "rises in rebellion" }, + VariantsWithRelated = new[] { "is swept up as the city rebels", "lives through a rebellion" }, + VariantsWithoutRelated = new[] { "is swept up as the city rebels", "lives through a rebellion" }, }, ["fallen_in_love"] = new HappinessCatalogEntry { @@ -411,15 +414,16 @@ public static partial class EventCatalog ["just_had_child"] = new HappinessCatalogEntry { Id = "just_had_child", + // birthEvent is on the parent; related newborn is not wired today. EventStrength = 70f, Presentation = HappinessPresentationTier.Signal, - Context = HappinessContextRequirement.RelatedOptional, + Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ProjectToLife, Overlap = HappinessOverlapPolicy.Independent, - RelationRole = HappinessRelationRole.Newborn, + RelationRole = HappinessRelationRole.None, InterestCategory = "LifeChapter", - VariantsWithRelated = new[] { "welcomes newborn {related}", "has a child named {related}" }, - VariantsWithoutRelated = new[] { "welcomes a newborn child", "has a child" }, + VariantsWithRelated = new[] { "has a child", "feels the joy of parenthood" }, + VariantsWithoutRelated = new[] { "has a child", "feels the joy of parenthood" }, }, ["just_read_book"] = new HappinessCatalogEntry { @@ -431,8 +435,8 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "Daily", - VariantsWithRelated = new[] { "finishes reading a book", "puts down a book feeling wiser" }, - VariantsWithoutRelated = new[] { "finishes reading a book", "puts down a book feeling wiser" }, + VariantsWithRelated = new[] { "finishes reading a book", "puts down a book" }, + VariantsWithoutRelated = new[] { "finishes reading a book", "puts down a book" }, }, ["just_played"] = new HappinessCatalogEntry { @@ -457,8 +461,8 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.ConversationPartner, InterestCategory = "Social", - VariantsWithRelated = new[] { "shares a talk with {related}", "finishes a conversation with {related}" }, - VariantsWithoutRelated = new[] { "shares a quiet talk", "finishes a conversation" }, + VariantsWithRelated = new[] { "finishes talking with {related}", "ends a conversation with {related}" }, + VariantsWithoutRelated = new[] { "finishes talking", "ends a conversation" }, }, ["just_laughed"] = new HappinessCatalogEntry { @@ -526,8 +530,8 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.ConversationPartner, InterestCategory = "Social", - VariantsWithRelated = new[] { "trades gossip with {related}", "shares juicy gossip with {related}" }, - VariantsWithoutRelated = new[] { "trades gossip", "shares juicy gossip" }, + VariantsWithRelated = new[] { "gossips with {related}", "shares lover-gossip with {related}" }, + VariantsWithoutRelated = new[] { "gossips about lovers", "shares lover-gossip" }, }, ["just_surprised"] = new HappinessCatalogEntry { @@ -545,19 +549,21 @@ public static partial class EventCatalog }, ["just_born"] = new HappinessCatalogEntry { + // Actor.newCreature spawn bookkeeping - not live birth; may precede egg form. Id = "just_born", - EventStrength = 70f, + EventStrength = 48f, Presentation = HappinessPresentationTier.Signal, Context = HappinessContextRequirement.None, Life = HappinessLifePolicy.ProjectToLife, Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "LifeChapter", - VariantsWithRelated = new[] { "is born into the world", "takes a first breath" }, - VariantsWithoutRelated = new[] { "is born into the world", "takes a first breath" }, + VariantsWithRelated = new[] { "appears in the world", "is brought into existence" }, + VariantsWithoutRelated = new[] { "appears in the world", "is brought into existence" }, }, ["just_magnetised"] = new HappinessCatalogEntry { + // Game id uses British spelling; status asset is magnetized. Id = "just_magnetised", EventStrength = 45f, Presentation = HappinessPresentationTier.Signal, @@ -580,8 +586,8 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "Emotion", - VariantsWithRelated = new[] { "is forced by divine power", "suffers a forced power" }, - VariantsWithoutRelated = new[] { "is forced by divine power", "suffers a forced power" }, + VariantsWithRelated = new[] { "is flung by a god power", "is blasted aside by divine force" }, + VariantsWithoutRelated = new[] { "is flung by a god power", "is blasted aside by divine force" }, }, ["just_possessed"] = new HappinessCatalogEntry { @@ -663,8 +669,9 @@ public static partial class EventCatalog RelationRole = HappinessRelationRole.None, InterestCategory = "Emotion", StatusOverlapId = "inspired", - VariantsWithRelated = new[] { "feels inspired", "is struck by inspiration" }, - VariantsWithoutRelated = new[] { "feels inspired", "is struck by inspiration" }, + // Happiness fires on inspired status end (action_finish), not onset. + VariantsWithRelated = new[] { "comes down from inspiration", "lets inspiration settle into contentment" }, + VariantsWithoutRelated = new[] { "comes down from inspiration", "lets inspiration settle into contentment" }, }, ["wrote_book"] = new HappinessCatalogEntry { @@ -900,8 +907,8 @@ public static partial class EventCatalog Overlap = HappinessOverlapPolicy.Independent, RelationRole = HappinessRelationRole.None, InterestCategory = "LifeChapter", - VariantsWithRelated = new[] { "becomes the alpha", "rises as alpha" }, - VariantsWithoutRelated = new[] { "becomes the alpha", "rises as alpha" }, + VariantsWithRelated = new[] { "becomes the family alpha", "is named family alpha" }, + VariantsWithoutRelated = new[] { "becomes the family alpha", "is named family alpha" }, }, }; diff --git a/IdleSpectator/Events/Catalogs/EventCatalog.Relationship.cs b/IdleSpectator/Events/Catalogs/EventCatalog.Relationship.cs index 0140d04..8d2dbab 100644 --- a/IdleSpectator/Events/Catalogs/EventCatalog.Relationship.cs +++ b/IdleSpectator/Events/Catalogs/EventCatalog.Relationship.cs @@ -15,14 +15,14 @@ public static partial class EventCatalog { Add("set_lover", 72f, "Relationship", "{a} became lovers with {b}"); Add("clear_lover", 55f, "Relationship", "{a} parted from a lover"); - Add("add_child", 68f, "Relationship", "{a} and {b} have a new child"); + Add("add_child", 68f, "Relationship", "{a} gains a child, {b}"); Add("new_family", 70f, "Relationship", "{a} starts a new family"); Add("family_removed", 50f, "Relationship", "{a}'s family ends"); Add("find_lover", 45f, "Relationship", "{a} is seeking a lover"); Add("family_group_new", 60f, "Relationship", "{a} forms a family pack"); Add("family_group_join", 36f, "Relationship", "{a} joins a family pack", createsInterest: false); Add("family_group_leave", 36f, "Relationship", "{a} leaves a family pack", createsInterest: false); - Add("baby_created", 70f, "Relationship", "{a} welcomes a baby"); + Add("baby_created", 70f, "Relationship", "{a} is created as a baby"); } private static void Add( diff --git a/IdleSpectator/Events/EventReason.cs b/IdleSpectator/Events/EventReason.cs index 025dad1..f9edb07 100644 --- a/IdleSpectator/Events/EventReason.cs +++ b/IdleSpectator/Events/EventReason.cs @@ -50,6 +50,11 @@ public static class EventReason return NameOrSomeone(a) + " is seeking a lover"; } + public static string Hatch(Actor a) + { + return NameOrSomeone(a) + " hatches from an egg"; + } + public static string Lovers(Actor a, Actor b) { string an = NameOrSomeone(a); @@ -104,12 +109,14 @@ public static class EventReason return an + " has a new child"; } - return an + " and " + bn + " have a new child"; + // related is the child (Actor.addChild), not a co-parent. + return an + " gains a child, " + bn; } public static string BabyBorn(Actor a) { - return NameOrSomeone(a) + " welcomes a baby"; + // Subject is the baby from createBabyActorFromData. + return NameOrSomeone(a) + " is created as a baby"; } public static string NewFamily(Actor a) diff --git a/IdleSpectator/Events/Feeds/InterestFeeds.cs b/IdleSpectator/Events/Feeds/InterestFeeds.cs index 02aed15..b92b993 100644 --- a/IdleSpectator/Events/Feeds/InterestFeeds.cs +++ b/IdleSpectator/Events/Feeds/InterestFeeds.cs @@ -359,7 +359,7 @@ public static class InterestFeeds public static void OnStatusChange(Actor actor, string statusId, bool gained) { - if (actor == null || !actor.isAlive() || !gained || string.IsNullOrEmpty(statusId)) + if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(statusId)) { return; } @@ -369,6 +369,19 @@ public static class InterestFeeds return; } + // Egg timer end is the reliable hatch beat. Happiness just_got_out_of_egg is often + // suppressed (no emotions / still flagged egg), so status loss owns the camera reason. + if (!gained && string.Equals(statusId.Trim(), "egg", StringComparison.OrdinalIgnoreCase)) + { + EmitHatch(actor, "status_egg_loss"); + return; + } + + if (!gained) + { + return; + } + StatusInterestEntry entry = EventCatalog.Status.GetOrFallback(statusId); if (!entry.CreatesInterest) { @@ -412,6 +425,54 @@ public static class InterestFeeds } } + /// + /// Hatch camera beat. Shared key with happiness just_got_out_of_egg so both paths merge. + /// + public static void EmitHatch(Actor actor, string source) + { + if (actor == null || !actor.isAlive()) + { + return; + } + + long id = SafeId(actor); + if (id == 0) + { + return; + } + + float strength = Mathf.Max( + InterestScoring.EventStrengthForHappiness("just_got_out_of_egg"), + 88f); + var candidate = new InterestCandidate + { + Key = HatchKey(id), + LeadKind = InterestLeadKind.EventLed, + Category = "LifeChapter", + Source = string.IsNullOrEmpty(source) ? "hatch" : source, + EventStrength = strength, + VisualConfidence = 0.8f, + Position = actor.current_position, + FollowUnit = actor, + SubjectId = id, + Label = EventReason.Hatch(actor), + AssetId = SafeAsset(actor), + SpeciesId = SafeAsset(actor), + HappinessEffectId = "just_got_out_of_egg", + CreatedAt = Time.unscaledTime, + LastSeenAt = Time.unscaledTime, + ExpiresAt = Time.unscaledTime + 7f, + MinWatch = 4f, + MaxWatch = 15f, + Completion = InterestCompletionKind.FixedDwell + }; + candidate.ParticipantIds.Add(id); + InterestScoring.ScoreCheap(candidate); + EventFeedUtil.RegisterCandidate(candidate); + } + + private static string HatchKey(long subjectId) => "hatch:" + subjectId; + public static void OnChronicleMilestone(Actor subject, string milestoneKey, Actor related, string label) { if (subject == null || !subject.isAlive() || string.IsNullOrEmpty(milestoneKey)) @@ -534,27 +595,45 @@ public static class InterestFeeds Actor related = occ.RelatedId != 0 ? FindUnitById(occ.RelatedId) : null; string cat = string.IsNullOrEmpty(occ.InterestCategory) ? "Emotion" : occ.InterestCategory; - string key = "happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId - + ":" + CorrBucket(occ.CorrelationKey); bool grief = occ.EffectId != null && occ.EffectId.StartsWith("death_"); float strength = InterestScoring.EventStrengthForHappiness(occ.EffectId); - bool birthMoment = IsFreshLifeMoment(occ.EffectId); + bool hatchMoment = IsFreshLifeMoment(occ.EffectId); - // Birth/hatch: short queue TTL, but on-camera floor comes from minCameraDwell. - float ttl = birthMoment ? 7f : 35f; - float minWatch = birthMoment ? 4f : 8f; - float maxWatch = birthMoment ? 15f : 28f; - if (birthMoment) + // Hatch shares a key with status egg-loss so both paths merge into one camera beat. + string key = hatchMoment + ? HatchKey(occ.SubjectId) + : ("happiness:" + occ.EffectId + ":" + occ.SubjectId + ":" + occ.RelatedId + + ":" + CorrBucket(occ.CorrelationKey)); + + // Hatch: short queue TTL, but on-camera floor comes from minCameraDwell. + float ttl = hatchMoment ? 7f : 35f; + float minWatch = hatchMoment ? 4f : 8f; + float maxWatch = hatchMoment ? 15f : 28f; + if (hatchMoment) { strength = Mathf.Max(strength, 88f); } + string label; + if (hatchMoment) + { + label = EventReason.Hatch(subject); + } + else if (!string.IsNullOrEmpty(occ.PlainClause)) + { + label = occ.SubjectName + " " + occ.PlainClause; + } + else + { + label = occ.SubjectName + " · " + occ.EffectId; + } + var candidate = new InterestCandidate { Key = key, LeadKind = InterestLeadKind.EventLed, Category = grief ? "FamilyEmotion" : MapHappinessCategory(cat), - Source = "happiness", + Source = hatchMoment ? "happiness_hatch" : "happiness", EventStrength = strength, VisualConfidence = 0.75f, Position = occ.Position != Vector3.zero ? occ.Position : subject.current_position, @@ -562,9 +641,7 @@ public static class InterestFeeds RelatedUnit = related, SubjectId = occ.SubjectId, RelatedId = occ.RelatedId, - Label = !string.IsNullOrEmpty(occ.PlainClause) - ? (occ.SubjectName + " " + occ.PlainClause) - : (occ.SubjectName + " · " + occ.EffectId), + Label = label, AssetId = occ.SubjectSpecies, SpeciesId = occ.SubjectSpecies, HappinessEffectId = occ.EffectId, @@ -788,9 +865,9 @@ public static class InterestFeeds return false; } + // Hatch moments only - just_born is spawn bookkeeping, not a birth/hatch beat. string id = effectId.Trim(); - return string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase) - || string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) + return string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) || string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase); } @@ -915,4 +992,21 @@ public static class InterestFeeds return actor.asset != null ? actor.asset.id : "creature"; } + + private static string SafeAsset(Actor actor) + { + if (actor == null) + { + return ""; + } + + try + { + return actor.asset != null ? actor.asset.id : ""; + } + catch + { + return ""; + } + } } diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index abcb32c..b9ed5bc 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -982,6 +982,13 @@ internal static class HarnessScenarios Step("wr68", "assert", expect: "dossier_contains", value: "is seeking a lover"), Step("wr69", "assert", expect: "reason_owns_focus"), + // Hatch reason (live path is egg status-loss → EmitHatch; force here for reason text). + Step("wr69a", "interest_force_session", asset: "human", + label: "{a} hatches from an egg", tier: "Action", expect: "hatch_reason", + value: "lead=event;evt=88"), + Step("wr69b", "assert", expect: "dossier_contains", value: "hatches from an egg"), + Step("wr69c", "assert", expect: "reason_owns_focus"), + // Label-sourced clash (not inventing Clash from fighter counts). Step("wr50", "interest_expire_pending", value: ""), Step("wr51", "interest_inject", asset: "human", label: "Clash · 8 fighting", tier: "Action", expect: "mass_clash", diff --git a/IdleSpectator/InterestDirector.cs b/IdleSpectator/InterestDirector.cs index 9df27dd..3cea0bb 100644 --- a/IdleSpectator/InterestDirector.cs +++ b/IdleSpectator/InterestDirector.cs @@ -541,14 +541,15 @@ public static class InterestDirector } string id = c.HappinessEffectId; - if (!string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase) - && !string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) - && !string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase)) + bool hatch = string.Equals(id, "just_got_out_of_egg", StringComparison.OrdinalIgnoreCase) + || string.Equals(id, "just_hatched", StringComparison.OrdinalIgnoreCase); + bool spawn = string.Equals(id, "just_born", StringComparison.OrdinalIgnoreCase); + if (!hatch && !spawn) { return false; } - // Real-time freshness: if the shot waited in queue more than ~5s, skip it. + // Queued too long - miss the beat. if (now - c.CreatedAt > 5.5f) { return true; @@ -560,17 +561,21 @@ public static class InterestDirector return true; } - try + // Hatch: egg-loss / happiness may land after the unit is already past baby form. + // Only drop on age for spawn (just_born), not hatch. + if (spawn) { - // Already past the newborn beat (playing/moving as a grown unit). - if (!unit.isBaby() && unit.getAge() > 1) + try { - return true; + if (!unit.isBaby() && unit.getAge() > 1) + { + return true; + } + } + catch + { + // ignore age API mismatches } - } - catch - { - // ignore age API mismatches } return false; diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index b223599..9fb8357 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -414,7 +414,8 @@ public sealed class UnitDossier } string name = EventFeedUtil.SafeName(unit); - if (string.IsNullOrEmpty(name) || name.Length < 3) + // Length 3 catches English crumbs in reasons ("egg", "war", "duel"). + if (string.IsNullOrEmpty(name) || name.Length < 4) { continue; } @@ -568,7 +569,7 @@ public sealed class UnitDossier && t.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) < 0) { // EventReason sentences lead with the subject name - keep them. - if (LooksLikeEventSentence(t)) + if (LooksLikeEventSentence(t) || StartsWithSubjectVerbPhrase(t, d.Name)) { return false; } @@ -615,7 +616,44 @@ public sealed class UnitDossier || t.IndexOf(" foraging ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" damaging ", System.StringComparison.OrdinalIgnoreCase) >= 0 || t.IndexOf(" plotting ", System.StringComparison.OrdinalIgnoreCase) >= 0 - || t.IndexOf(" lovers ", System.StringComparison.OrdinalIgnoreCase) >= 0; + || t.IndexOf(" lovers ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" hatches ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" mates ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" appears ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" finishes ", System.StringComparison.OrdinalIgnoreCase) >= 0 + || t.IndexOf(" bloodlust ", System.StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// "Name verb …" EventReason form (covers hatches/mates/appears without an "is" helper). + /// Rejects identity crumbs like "Name (species, …)". + /// + private static bool StartsWithSubjectVerbPhrase(string text, string name) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(name)) + { + return false; + } + + if (!text.StartsWith(name, System.StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + int i = name.Length; + if (i >= text.Length || text[i] != ' ') + { + return false; + } + + int verbStart = i + 1; + if (verbStart >= text.Length) + { + return false; + } + + char c = text[verbStart]; + return char.IsLetter(c) && c != '('; } private static bool IsShownTraitName(UnitDossier d, string text) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 3072be7..fd74dc3 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.21.1", + "version": "0.21.3", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" } diff --git a/docs/event-audit.md b/docs/event-audit.md index c6d3881..d8076d5 100644 --- a/docs/event-audit.md +++ b/docs/event-audit.md @@ -35,7 +35,7 @@ Ambient / Character fill is exempt - events may take the camera immediately. | Source | Primary owner | Reason factory | Notes | |--------|---------------|----------------|-------| | WorldLog | `InterestFeeds.OnWorldLogMessage` | catalog / EventReason | Strength from `EventCatalog.WorldLog` | -| Happiness | `IngestHappiness` | `EventCatalog.Happiness` + EventReason | Birth/hatch freshness; grief sticky | +| Happiness | `IngestHappiness` | `EventCatalog.Happiness` + EventReason | Prose must match game call sites - see [event-prose-audit.md](event-prose-audit.md) | | Status | `OnStatusChange` | EventReason.Status | `drowning` CreatesInterest=true, strength ~50; notable via CharacterSignificance | | Scanner combat | WorldActivityScanner | EventReason.Fight / Battle | Completion CombatActive; dials `EventCatalog.Combat` | | War | WarEventPatches + WarInterestFeed.Tick | war type labels | | diff --git a/docs/event-prose-audit.md b/docs/event-prose-audit.md new file mode 100644 index 0000000..cd4c03a --- /dev/null +++ b/docs/event-prose-audit.md @@ -0,0 +1,61 @@ +# Event prose audit (vs game call sites) + +Authored IdleSpectator sentences must match what the game actually does when an id fires. +Source of truth: `Assembly-CSharp` apply sites (`changeHappiness`, relationship patches, status receive), not English guesses from the id string. + +Audit date: 2026-07-16. +Scope: Happiness (all authored), Relationship (all), Status/WorldLog/Plot/etc. sampled. + +## Fixed this pass + +| Id | Was | Actual | Now | +|----|-----|--------|-----| +| `just_born` | "is born into the world" / "first breath" | `Actor.newCreature` spawn; may precede egg form | "appears in the world" / "is brought into existence"; strength 48; no hatch strength floor | +| `just_kissed` | "shares a kiss" | Mating afterglow (`BehCheckForBabiesFromSexualReproduction`) | "mates with …" / "shares intimacy …" | +| `just_killed` | "takes a life" | Only with trait `bloodlust` | Explicit bloodlust wording | +| `add_child` / `EventReason.NewChild` | "{a} and {b} have a new child" | `{b}` is the child | "{a} gains a child, {b}" | +| `baby_created` / `EventReason.BabyBorn` | "{a} welcomes a baby" | `{a}` is the baby | "{a} is created as a baby" | +| `just_had_child` | Named newborn `{related}` | Related newborn not wired | "has a child" / parenthood only | +| `just_started_war` | Anyone cheers war | Culture trait `happiness_from_war` | "is glad war has started" / culture-loves-war | +| `just_talked` | Always warm | Talk can be good or bad | Neutral "finishes talking" | +| `just_inspired` | Onset of inspiration | Status `inspired` **end** | "comes down from inspiration" | +| `slept_outside` | Rough night over | Sleep start, no house | "has to sleep without a house" | +| `just_rebelled` | Joins / rises | Whole city units swept | "is swept up as the city rebels" | +| `just_forced_power` | Vague forced power | God force shove | "is flung by a god power" | +| `got_caught` | Shady catch Signal | No game caller found | Ambient, weak strength | +| `lost_fight` | Decisive loss | Yield / submit mid-fight | "is forced to yield" | +| `just_talked_gossip` | Generic juicy gossip | `gossip_lovers` culture | Lover-gossip wording | +| `become_king` | "crowned king" | Any sex ruler | "takes the crown" / "becomes ruler" | +| `become_alpha` | Fight for alpha | Oldest living family unit | "becomes the family alpha" | +| Dreams | "wakes from …" | Dream status on receive | "has a … dream" | + +## Policy notes + +- Hatch camera floor (`Max(strength, 88)`) applies only to `just_got_out_of_egg` / `just_hatched`, not `just_born`. +- True hatch prose (`just_got_out_of_egg` → "hatches from an egg") matches `Actor.eventHatchFromEgg`. +- Live parent beat is `just_had_child`, not `just_born`. + +## Still clean (sample) + +Grief deaths, `fallen_in_love`, gifts, house find/lose, `just_got_out_of_egg`, `just_became_adult`, WorldLog `king_new` template roles, Status `egg` / `pregnant` / `drowning` prose. + +## Hatch camera path (fixed) + +Hatch was Activity-only for many units: +- Status `egg` has `CreatesInterest=false` (becoming an egg is not a camera beat). +- `OnStatusChange` previously ignored **losses**, so "Hatches from an egg" never became interest. +- Happiness `just_got_out_of_egg` is often suppressed (`!hasEmotions()`). +- Even when a hatch Label existed, dossier identity filter dropped verbs without ` is ` (e.g. "Name hatches …"). + +Fix: +- Egg status **loss** → `InterestFeeds.EmitHatch` + `EventReason.Hatch`, key `hatch:{id}` shared with happiness. +- Keep `Name verb …` EventReason sentences in `UnitDossier.OwnedEventReason`. + +## Reasons + +EventLed camera candidates always get a `Label` (orange reason) when ownership + sentence filters pass. +Sources: `EventReason.*`, happiness catalog `PlainClause`, or discrete catalog templates. +Character fill / ambient may show empty reason. +Not every Activity line is a camera event (Ambient happiness, status gains with `CreatesInterest=false`, status losses except egg). + +