using System; using System.Text; namespace IdleSpectator; /// /// Semantic presentation kind. These values describe observed meaning, never final player-facing /// English. /// public enum NarrativePresentationKind { Unknown, BondFormed, BondBroken, SeekingLover, FriendFormed, ParentWelcomesChild, ChildBorn, FamilyFormed, FamilyEnded, FamilyPackFormed, FamilyPackJoined, FamilyPackLeft, GiftGiven, GiftReceived, SharedIntimacy, Conversation, Gossip, GrievesChild, GrievesLover, GrievesFriend, GrievesKin, AuthoredObservation, EnsembleObservation, StatusObservation, WorldObservation } public enum NarrativeRelationRole { None, Lover, FormerLover, Friend, Parent, Child, Kin, GiftPartner, ConversationPartner } /// Complete semantic input for one orange Beat. Contains no final sentence. public sealed class NarrativePresentationModel { public string EventId = ""; public string DevelopmentId = ""; public NarrativePresentationKind Kind; public NarrativeDevelopmentKind DevelopmentKind; public long PerspectiveId; public LifeSagaIdentity Perspective; public long OtherId; public LifeSagaIdentity Other; public NarrativeRelationRole RelationRole; public int Ordinal; public int Count; public string Change = ""; public string Outcome = ""; /// Catalog-authored observation clause used only by the safe typed fallback. public string AuthoredClause = ""; public string ActionId = ""; public string Phase = ""; public string EvidenceSource = ""; public float Confidence = 1f; public NarrativePresentationModel Clone() { return new NarrativePresentationModel { EventId = EventId, DevelopmentId = DevelopmentId, Kind = Kind, DevelopmentKind = DevelopmentKind, PerspectiveId = PerspectiveId, Perspective = Perspective, OtherId = OtherId, Other = Other, RelationRole = RelationRole, Ordinal = Ordinal, Count = Count, Change = Change, Outcome = Outcome, AuthoredClause = AuthoredClause, ActionId = ActionId, Phase = Phase, EvidenceSource = EvidenceSource, Confidence = Confidence }; } } public static class NarrativePresentationFactory { public static bool HarnessProbeNamedParticipantFallback(out string detail) { var occurrence = new HappinessOccurrence { EffectId = "fallen_in_love", SubjectId = 11, SubjectName = "Joon", RelatedId = 0, RelatedName = "Omyahel", RelatedSpecies = "human" }; LifeSagaIdentity other = OccurrenceOtherIdentity(occurrence, default(LifeSagaIdentity)); var model = new NarrativePresentationModel { Kind = NarrativePresentationKind.BondFormed, PerspectiveId = occurrence.SubjectId, Perspective = new LifeSagaIdentity { Id = 11, Name = "Joon" }, OtherId = other.Id, Other = other }; string beat = NarrativeRenderer.Beat(model); bool ok = other.Id == 0 && other.Name == "Omyahel" && beat == "Finds love with Omyahel"; detail = "other=" + other.Id + ":" + other.Name + " beat='" + beat + "' pass=" + ok; return ok; } public static NarrativePresentationModel Relationship(string eventId, Actor subject, Actor related) { if (subject == null) { return null; } string id = (eventId ?? "").Trim().ToLowerInvariant(); NarrativePresentationKind presentationKind; NarrativeDevelopmentKind developmentKind = NarrativeDevelopmentKind.Unknown; NarrativeRelationRole relation = NarrativeRelationRole.None; switch (id) { case "set_lover": presentationKind = NarrativePresentationKind.BondFormed; developmentKind = NarrativeDevelopmentKind.BondFormed; relation = NarrativeRelationRole.Lover; break; case "clear_lover": presentationKind = NarrativePresentationKind.BondBroken; developmentKind = NarrativeDevelopmentKind.BondBroken; relation = NarrativeRelationRole.FormerLover; break; case "find_lover": presentationKind = NarrativePresentationKind.SeekingLover; break; case "add_child": presentationKind = NarrativePresentationKind.ParentWelcomesChild; developmentKind = NarrativeDevelopmentKind.ChildBorn; relation = NarrativeRelationRole.Child; break; case "baby_created": presentationKind = NarrativePresentationKind.ChildBorn; developmentKind = NarrativeDevelopmentKind.ChildBorn; relation = NarrativeRelationRole.Parent; if (related == null) { related = FirstParent(subject); } break; case "new_family": presentationKind = NarrativePresentationKind.FamilyFormed; break; case "family_removed": presentationKind = NarrativePresentationKind.FamilyEnded; break; case "family_group_new": presentationKind = NarrativePresentationKind.FamilyPackFormed; relation = NarrativeRelationRole.Kin; break; case "family_group_join": presentationKind = NarrativePresentationKind.FamilyPackJoined; relation = NarrativeRelationRole.Kin; break; case "family_group_leave": presentationKind = NarrativePresentationKind.FamilyPackLeft; relation = NarrativeRelationRole.Kin; break; default: return null; } long perspectiveId = EventFeedUtil.SafeId(subject); LifeSagaIdentity otherSnapshot = LifeSagaMemory.Snapshot(related); NarrativeDevelopment development = developmentKind == NarrativeDevelopmentKind.Unknown ? null : MatchingDevelopment(perspectiveId, developmentKind, otherSnapshot.Id); if (otherSnapshot.Id == 0 && development != null && development.OtherId != 0) { otherSnapshot = development.Other; } int ordinal = 0; if (presentationKind == NarrativePresentationKind.ParentWelcomesChild) { ordinal = NarrativeStoryStore.CountConsequences( perspectiveId, CharacterConsequenceKind.BecameParent); } string developmentId = development?.Id ?? ""; return new NarrativePresentationModel { EventId = string.IsNullOrEmpty(developmentId) ? "observed:" + id + ":" + perspectiveId + ":" + otherSnapshot.Id : developmentId, DevelopmentId = developmentId, Kind = presentationKind, DevelopmentKind = developmentKind, PerspectiveId = perspectiveId, Perspective = LifeSagaMemory.Snapshot(subject), OtherId = otherSnapshot.Id, Other = otherSnapshot, RelationRole = relation, Ordinal = ordinal, EvidenceSource = "relationship:" + id, Confidence = 1f }; } /// /// Typed presentation for relationship-bearing happiness signals. Ambient effects deliberately /// stay on the compatibility path until their semantic catalog migration. /// public static NarrativePresentationModel Happiness( HappinessOccurrence occurrence, Actor subject, Actor related) { if (occurrence == null || subject == null) { return null; } string id = (occurrence.EffectId ?? "").Trim().ToLowerInvariant(); NarrativePresentationKind presentationKind; NarrativeDevelopmentKind developmentKind = NarrativeDevelopmentKind.Unknown; NarrativeRelationRole relation = NarrativeRelationRole.None; switch (id) { case "fallen_in_love": presentationKind = NarrativePresentationKind.BondFormed; developmentKind = NarrativeDevelopmentKind.BondFormed; relation = NarrativeRelationRole.Lover; break; case "just_made_friend": presentationKind = NarrativePresentationKind.FriendFormed; developmentKind = NarrativeDevelopmentKind.FriendFormed; relation = NarrativeRelationRole.Friend; break; case "just_had_child": presentationKind = NarrativePresentationKind.ParentWelcomesChild; developmentKind = NarrativeDevelopmentKind.ChildBorn; relation = NarrativeRelationRole.Child; break; case "just_gave_gift": presentationKind = NarrativePresentationKind.GiftGiven; relation = NarrativeRelationRole.GiftPartner; break; case "just_received_gift": presentationKind = NarrativePresentationKind.GiftReceived; relation = NarrativeRelationRole.GiftPartner; break; case "just_kissed": presentationKind = NarrativePresentationKind.SharedIntimacy; relation = NarrativeRelationRole.Lover; break; case "just_talked": presentationKind = NarrativePresentationKind.Conversation; relation = NarrativeRelationRole.ConversationPartner; break; case "just_talked_gossip": presentationKind = NarrativePresentationKind.Gossip; relation = NarrativeRelationRole.ConversationPartner; break; case "death_child": presentationKind = NarrativePresentationKind.GrievesChild; relation = NarrativeRelationRole.Child; break; case "death_lover": presentationKind = NarrativePresentationKind.GrievesLover; relation = NarrativeRelationRole.FormerLover; break; case "death_best_friend": presentationKind = NarrativePresentationKind.GrievesFriend; relation = NarrativeRelationRole.Friend; break; case "death_family_member": presentationKind = NarrativePresentationKind.GrievesKin; relation = NarrativeRelationRole.Kin; break; default: return null; } long perspectiveId = EventFeedUtil.SafeId(subject); LifeSagaIdentity otherSnapshot = OccurrenceOtherIdentity(occurrence, LifeSagaMemory.Snapshot(related)); NarrativeDevelopment development = developmentKind == NarrativeDevelopmentKind.Unknown ? null : MatchingDevelopment(perspectiveId, developmentKind, otherSnapshot.Id); if (otherSnapshot.Id == 0 && development != null && development.OtherId != 0) { otherSnapshot = development.Other; } int ordinal = presentationKind == NarrativePresentationKind.ParentWelcomesChild ? NarrativeStoryStore.CountConsequences( perspectiveId, CharacterConsequenceKind.BecameParent) : 0; string developmentId = development?.Id ?? ""; string eventId = !string.IsNullOrEmpty(developmentId) ? developmentId : "happiness:" + id + ":" + perspectiveId + ":" + otherSnapshot.Id + ":" + (occurrence.CorrelationKey ?? ""); return new NarrativePresentationModel { EventId = eventId, DevelopmentId = developmentId, Kind = presentationKind, DevelopmentKind = developmentKind, PerspectiveId = perspectiveId, Perspective = LifeSagaMemory.Snapshot(subject), OtherId = otherSnapshot.Id, Other = otherSnapshot, RelationRole = relation, Ordinal = ordinal, EvidenceSource = "happiness:" + id, Confidence = 1f }; } /// /// Safe typed envelope for catalog/ensemble events that do not create a durable character /// development. The renderer owns subject removal and final HUD hygiene. /// public static NarrativePresentationModel Candidate( string key, string source, string actionId, string authoredClause, Actor subject, Actor related) { string normalizedSource = (source ?? "").Trim().ToLowerInvariant(); NarrativePresentationKind kind; if (normalizedSource == "status") { kind = NarrativePresentationKind.StatusObservation; } else if (normalizedSource == "worldlog" || normalizedSource == "era" || normalizedSource == "worldlaw" || normalizedSource == "power" || normalizedSource == "biome" || normalizedSource == "earthquake") { kind = NarrativePresentationKind.WorldObservation; } else if ((actionId ?? "").StartsWith("live_", StringComparison.OrdinalIgnoreCase) || normalizedSource == "combat" || normalizedSource == "war" || normalizedSource == "plot") { kind = NarrativePresentationKind.EnsembleObservation; } else { kind = NarrativePresentationKind.AuthoredObservation; } return new NarrativePresentationModel { EventId = string.IsNullOrEmpty(key) ? "observed:" + normalizedSource : key, Kind = kind, PerspectiveId = EventFeedUtil.SafeId(subject), Perspective = LifeSagaMemory.Snapshot(subject), OtherId = EventFeedUtil.SafeId(related), Other = LifeSagaMemory.Snapshot(related), AuthoredClause = authoredClause ?? "", ActionId = actionId ?? "", Phase = normalizedSource, EvidenceSource = "catalog:" + normalizedSource, Confidence = 1f }; } private static NarrativeDevelopment MatchingDevelopment( long perspectiveId, NarrativeDevelopmentKind kind, long otherId) { NarrativeDevelopment development = NarrativeStoryStore.LatestFor(perspectiveId, kind, 3f); if (development == null) { return null; } if (otherId != 0 && development.OtherId != 0 && development.OtherId != otherId) { return null; } return development; } private static LifeSagaIdentity OccurrenceOtherIdentity( HappinessOccurrence occurrence, LifeSagaIdentity live) { if (live.Id != 0 || occurrence == null) { return live; } if (occurrence.RelatedId == 0 && string.IsNullOrEmpty(occurrence.RelatedName)) { return live; } return new LifeSagaIdentity { Id = occurrence.RelatedId, Name = occurrence.RelatedName ?? "", SpeciesId = occurrence.RelatedSpecies ?? "" }; } private static Actor FirstParent(Actor child) { foreach (Actor parent in ActorRelation.EnumerateParents(child, 1)) { if (parent != null) { return parent; } } return null; } } /// Single English owner for typed narrative presentation. public static class NarrativeRenderer { public static string Beat(NarrativePresentationModel model) { if (model == null || model.Confidence < 0.75f) { return ""; } string other = CleanName(model.Other.Name); switch (model.Kind) { case NarrativePresentationKind.BondFormed: return string.IsNullOrEmpty(other) ? "Finds love" : "Finds love with " + other; case NarrativePresentationKind.BondBroken: return string.IsNullOrEmpty(other) ? "Parts from a lover" : "Parts from " + other; case NarrativePresentationKind.SeekingLover: return "Seeks a lover"; case NarrativePresentationKind.FriendFormed: return string.IsNullOrEmpty(other) ? "Makes a true friend" : "Befriends " + other; case NarrativePresentationKind.ParentWelcomesChild: { string ordinal = model.Ordinal == 1 ? " first" : ""; return string.IsNullOrEmpty(other) ? "Welcomes a" + ordinal + " child" : "Welcomes" + ordinal + " child " + other; } case NarrativePresentationKind.ChildBorn: return string.IsNullOrEmpty(other) ? "Is born" : "Is born to " + other; case NarrativePresentationKind.FamilyFormed: return "Founds a family"; case NarrativePresentationKind.FamilyEnded: return "Their family line ends"; case NarrativePresentationKind.FamilyPackFormed: return string.IsNullOrEmpty(other) ? "Forms a family pack" : "Forms a family pack with " + other; case NarrativePresentationKind.FamilyPackJoined: return string.IsNullOrEmpty(other) ? "Joins a family pack" : "Joins a family pack with " + other; case NarrativePresentationKind.FamilyPackLeft: return string.IsNullOrEmpty(other) ? "Leaves a family pack" : "Leaves a family pack shared with " + other; case NarrativePresentationKind.GiftGiven: return string.IsNullOrEmpty(other) ? "Gives a gift" : "Gives " + other + " a gift"; case NarrativePresentationKind.GiftReceived: return string.IsNullOrEmpty(other) ? "Receives a gift" : "Receives a gift from " + other; case NarrativePresentationKind.SharedIntimacy: return string.IsNullOrEmpty(other) ? "Shares intimacy with their lover" : "Shares intimacy with " + other; case NarrativePresentationKind.Conversation: return string.IsNullOrEmpty(other) ? "Finishes a conversation" : "Finishes a conversation with " + other; case NarrativePresentationKind.Gossip: return string.IsNullOrEmpty(other) ? "Shares gossip" : "Shares gossip with " + other; case NarrativePresentationKind.GrievesChild: return string.IsNullOrEmpty(other) ? "Mourns a child" : "Mourns child " + other; case NarrativePresentationKind.GrievesLover: return string.IsNullOrEmpty(other) ? "Mourns a lost lover" : "Mourns lover " + other; case NarrativePresentationKind.GrievesFriend: return string.IsNullOrEmpty(other) ? "Mourns a lost friend" : "Mourns friend " + other; case NarrativePresentationKind.GrievesKin: return string.IsNullOrEmpty(other) ? "Mourns a family member" : "Mourns kin " + other; case NarrativePresentationKind.AuthoredObservation: case NarrativePresentationKind.EnsembleObservation: case NarrativePresentationKind.StatusObservation: case NarrativePresentationKind.WorldObservation: return SafeAuthoredBeat(model); default: return ""; } } /// /// Safety audit only. Semantic models prevent duplication; this detects regressions in rendered /// output and compatibility fallbacks without rewriting their prose. /// public static bool HasSemanticRedundancy(string beat, NarrativePresentationModel model = null) { if (string.IsNullOrEmpty(beat)) { return false; } string plain = StripRichText(beat).ToLowerInvariant(); string[] clauses = plain.Split(new[] { " · " }, StringSplitOptions.None); if (clauses.Length > 1) { string left = clauses[0]; for (int i = 1; i < clauses.Length; i++) { string right = clauses[i]; if (RepeatsConcept(left, right, "child") || RepeatsConcept(left, right, "lover") || RepeatsConcept(left, right, "friend") || RepeatsConcept(left, right, "parent") || RepeatsConcept(left, right, "family pack")) { return true; } } } string other = CleanName(model?.Other.Name); return !string.IsNullOrEmpty(other) && CountOccurrences(plain, other.ToLowerInvariant()) > 1; } public static bool HarnessProbeRelationshipProse(out string detail) { var parent = new LifeSagaIdentity { Id = 1, Name = "Joon", SpeciesId = "human" }; var child = new LifeSagaIdentity { Id = 2, Name = "Omyahel", SpeciesId = "human" }; var parentBeat = new NarrativePresentationModel { EventId = "event:child:1:2", DevelopmentId = "child:1:2", Kind = NarrativePresentationKind.ParentWelcomesChild, DevelopmentKind = NarrativeDevelopmentKind.ChildBorn, PerspectiveId = parent.Id, Perspective = parent, OtherId = child.Id, Other = child, RelationRole = NarrativeRelationRole.Child, Ordinal = 1 }; var childBeat = new NarrativePresentationModel { EventId = parentBeat.EventId, DevelopmentId = parentBeat.DevelopmentId, Kind = NarrativePresentationKind.ChildBorn, DevelopmentKind = NarrativeDevelopmentKind.ChildBorn, PerspectiveId = child.Id, Perspective = child, OtherId = parent.Id, Other = parent, RelationRole = NarrativeRelationRole.Parent }; var loverBeat = new NarrativePresentationModel { Kind = NarrativePresentationKind.BondFormed, PerspectiveId = parent.Id, Perspective = parent, OtherId = child.Id, Other = child, RelationRole = NarrativeRelationRole.Lover }; string parentLine = Beat(parentBeat); string childLine = Beat(childBeat); string loverLine = Beat(loverBeat); bool oldDetected = HasSemanticRedundancy( "Gains a child, Omyahel · their child", parentBeat); bool ok = parentLine == "Welcomes first child Omyahel" && childLine == "Is born to Joon" && loverLine == "Finds love with Omyahel" && !HasSemanticRedundancy(parentLine, parentBeat) && !HasSemanticRedundancy(childLine, childBeat) && !HasSemanticRedundancy(loverLine, loverBeat) && oldDetected; detail = "parent='" + parentLine + "' child='" + childLine + "' lover='" + loverLine + "' oldDetected=" + oldDetected + " pass=" + ok; return ok; } private static bool RepeatsConcept(string left, string right, string concept) { return left.IndexOf(concept, StringComparison.Ordinal) >= 0 && right.IndexOf(concept, StringComparison.Ordinal) >= 0; } private static int CountOccurrences(string value, string needle) { if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(needle)) { return 0; } int count = 0; int at = 0; while ((at = value.IndexOf(needle, at, StringComparison.Ordinal)) >= 0) { count++; at += needle.Length; } return count; } private static string CleanName(string value) => (value ?? "").Trim(); private static string SafeAuthoredBeat(NarrativePresentationModel model) { string beat = StripRichText(model?.AuthoredClause ?? "").Trim(); string subject = CleanName(model?.Perspective.Name); if (!string.IsNullOrEmpty(subject) && beat.StartsWith(subject, StringComparison.OrdinalIgnoreCase)) { beat = beat.Substring(subject.Length).TrimStart(' ', '·', '-', ':'); } if (string.IsNullOrEmpty(beat)) { beat = EventReason.HumanizeId(model?.ActionId ?? ""); } if (beat.IndexOf('{') >= 0 || beat.IndexOf('}') >= 0) { beat = beat.Replace("{a}", "").Replace("{b}", "") .Replace("{id}", EventReason.HumanizeId(model?.ActionId ?? "")) .Replace("{", "").Replace("}", "").Trim(); } if (!string.IsNullOrEmpty(beat)) { beat = char.ToUpperInvariant(beat[0]) + (beat.Length > 1 ? beat.Substring(1) : ""); } return beat; } private static string StripRichText(string value) { if (string.IsNullOrEmpty(value) || value.IndexOf('<') < 0) { return value ?? ""; } var sb = new StringBuilder(value.Length); bool tag = false; for (int i = 0; i < value.Length; i++) { char ch = value[i]; if (ch == '<') { tag = true; continue; } if (tag) { if (ch == '>') { tag = false; } continue; } sb.Append(ch); } return sb.ToString(); } } /// Bounded counters for selected-caption prose provenance and quality. public static class NarrativePresentationCoverage { public static int IntakeStructuredCount { get; private set; } public static int IntakeCompatibilityCount { get; private set; } public static int StructuredCount { get; private set; } public static int ExactDevelopmentCount { get; private set; } public static int CompatibilityCount { get; private set; } public static int RedundancyCount { get; private set; } public static string LastSource { get; private set; } = ""; public static string LastEventId { get; private set; } = ""; public static string LastDevelopmentId { get; private set; } = ""; public static string Summary => "intake=" + IntakeStructuredCount + "/" + IntakeCompatibilityCount + " structured=" + StructuredCount + " exact=" + ExactDevelopmentCount + " compat=" + CompatibilityCount + " redundant=" + RedundancyCount + " last=" + LastSource + " event=" + LastEventId + " development=" + LastDevelopmentId; public static void Clear() { IntakeStructuredCount = 0; IntakeCompatibilityCount = 0; StructuredCount = 0; ExactDevelopmentCount = 0; CompatibilityCount = 0; RedundancyCount = 0; LastSource = ""; LastEventId = ""; LastDevelopmentId = ""; } public static void NoteIntake(InterestCandidate candidate) { if (candidate?.NarrativePresentation != null) { IntakeStructuredCount++; } else { IntakeCompatibilityCount++; } } public static void Note( string source, string beat, NarrativePresentationModel presentation, bool exactDevelopment) { bool structured = presentation != null; if (structured) { StructuredCount++; if (exactDevelopment) { ExactDevelopmentCount++; } } else { CompatibilityCount++; } if (NarrativeRenderer.HasSemanticRedundancy(beat, presentation)) { RedundancyCount++; } LastSource = source ?? ""; LastEventId = presentation?.EventId ?? ""; LastDevelopmentId = presentation?.DevelopmentId ?? ""; } }