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