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). + +