using System; using System.Globalization; using System.Text; namespace IdleSpectator; /// /// Sole factory for interest Labels shown as the orange dossier reason. /// Part of the Events/ module with catalogs - feeds call these helpers (or Apply) /// instead of concatenating crumbs. Director never authors reasons. /// public static class EventReason { /// /// True when two labels differ only by parenthesized headcounts /// (e.g. Mass - A (12) vs B (3)Mass - A (11) vs B (4)). /// public static bool IsHeadcountOnlyChange(string previous, string next) { if (string.IsNullOrEmpty(previous) || string.IsNullOrEmpty(next)) { return false; } if (string.Equals(previous, next, StringComparison.Ordinal)) { return false; } return string.Equals(StripParenCounts(previous), StripParenCounts(next), StringComparison.Ordinal); } /// /// True when two combat tips describe the same camps but differ by tier /// (Battle↔Mass) and/or side order (A vs B ↔ B vs A), optionally with counts. /// public static bool IsCombatFramingOnlyChange(string previous, string next) { if (string.IsNullOrEmpty(previous) || string.IsNullOrEmpty(next)) { return false; } if (string.Equals(previous, next, StringComparison.Ordinal)) { return false; } if (!TryCombatCampKey(previous, out string prevKey) || !TryCombatCampKey(next, out string nextKey)) { return false; } if (string.Equals(prevKey, nextKey, StringComparison.Ordinal)) { return true; } // Pack mop-up ↔ thin vs remnant: Mass - Igguorn ↔ Battle - Igguorn vs The Godo. return SameCombatTheaterCamps(prevKey, nextKey); } /// /// Stable player-facing replay identity. Combat tiers, side order, and live /// headcounts are framing, not new story developments. /// public static string ReplayStructureKey(string label) { if (string.IsNullOrEmpty(label)) { return ""; } if (TryCombatCampKey(label, out string combatKey)) { return "combat:" + combatKey; } return label.Trim().ToLowerInvariant(); } private static bool SameCombatTheaterCamps(string prevKey, string nextKey) { if (TryPackSide(prevKey, out string pack) && TryVsSides(nextKey, out string a, out string b)) { return CampEq(pack, a) || CampEq(pack, b); } if (TryPackSide(nextKey, out pack) && TryVsSides(prevKey, out a, out b)) { return CampEq(pack, a) || CampEq(pack, b); } return false; } private static bool TryPackSide(string key, out string pack) { pack = ""; if (string.IsNullOrEmpty(key) || !key.StartsWith("pack:", StringComparison.Ordinal)) { return false; } pack = key.Substring(5); return !string.IsNullOrEmpty(pack); } private static bool TryVsSides(string key, out string a, out string b) { a = ""; b = ""; if (string.IsNullOrEmpty(key) || key.StartsWith("pack:", StringComparison.Ordinal)) { return false; } int bar = key.IndexOf('|'); if (bar <= 0 || bar >= key.Length - 1) { return false; } a = key.Substring(0, bar); b = key.Substring(bar + 1); return !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b); } private static bool CampEq(string a, string b) { return !string.IsNullOrEmpty(a) && !string.IsNullOrEmpty(b) && string.Equals(a, b, StringComparison.OrdinalIgnoreCase); } private static bool TryCombatCampKey(string label, out string key) { key = ""; if (string.IsNullOrEmpty(label)) { return false; } string t = label.Trim(); int dash = t.IndexOf(" - ", StringComparison.Ordinal); if (dash < 0) { return false; } string tier = t.Substring(0, dash).Trim(); // Include Duel so A vs B ↔ B vs A is framing-only (no Watch / tip-order churn). if (!tier.Equals("Duel", StringComparison.OrdinalIgnoreCase) && !tier.Equals("Skirmish", StringComparison.OrdinalIgnoreCase) && !tier.Equals("Battle", StringComparison.OrdinalIgnoreCase) && !tier.Equals("Mass", StringComparison.OrdinalIgnoreCase)) { return false; } string rest = StripParenCounts(t.Substring(dash + 3).Trim()) .Replace(" ()", "") .Replace("()", "") .Trim(); int vs = rest.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase); if (vs < 0) { // Same-side pack tip: Mass - Wolves () key = "pack:" + rest.ToLowerInvariant(); return !string.IsNullOrEmpty(rest); } string a = rest.Substring(0, vs).Trim().ToLowerInvariant(); string b = rest.Substring(vs + 4).Trim().ToLowerInvariant(); if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) { return false; } // Unordered camp pair so A vs B and B vs A collapse. if (string.CompareOrdinal(a, b) <= 0) { key = a + "|" + b; } else { key = b + "|" + a; } return true; } private static string StripParenCounts(string text) { var sb = new StringBuilder(text.Length); for (int i = 0; i < text.Length; i++) { char c = text[i]; if (c != '(') { sb.Append(c); continue; } int j = i + 1; bool digits = j < text.Length && char.IsDigit(text[j]); while (j < text.Length && char.IsDigit(text[j])) { j++; } if (digits && j < text.Length && text[j] == ')') { sb.Append("()"); i = j; continue; } sb.Append(c); } return sb.ToString(); } public static string Fight(Actor a, Actor b) { string name = Name(a); if (string.IsNullOrEmpty(name)) { name = "Someone"; } string foe = Name(b); if (string.IsNullOrEmpty(foe)) { return name + " is fighting"; } return name + " is fighting " + foe; } public static string Battle(Actor focus, int fighters) { // Legacy wrapper - prefer Combat(LiveEnsemble) for sided framing. var e = new LiveEnsemble { Kind = EnsembleKind.Combat, Scale = LiveEnsemble.ScaleForCount(fighters), Frame = fighters <= 2 ? EnsembleFrame.NamedPair : EnsembleFrame.CountOnly, ParticipantCount = Math.Max(1, fighters), Focus = focus }; return Combat(e); } /// /// Context-aware combat orange reason from a snapshot. /// Multi form: Battle - Humans (4) vs Wolves (3). A wiped camp collapses to a /// living-side pack label at live scale (never vs … (0)). /// Never uses raw unit names as mass-side labels. Never prints thin melee fallbacks. /// public static string Combat(LiveEnsemble ensemble) { if (ensemble == null || !ensemble.HasFocus) { return ""; } // True 1v1 only when we do not already have sticky opposing camps. if (ensemble.Scale == EnsembleScale.Pair && !LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { string a = Name(ensemble.Focus); string b = Name(ensemble.Related); if (string.IsNullOrEmpty(a)) { a = "Someone"; } if (string.IsNullOrEmpty(b)) { // Orphan duel with no foe - blank so director can keep sticky / clear. return ""; } return "Duel - " + a + " vs " + b; } if (LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { int countA = ensemble.SideA != null ? Math.Max(0, ensemble.SideA.Count) : 0; int countB = ensemble.SideB != null ? Math.Max(0, ensemble.SideB.Count) : 0; // Mop-up: a wiped camp must not print "Mass - A (93) vs B (0)". // Present the living side at live scale (pack chase / remnant). if (countA <= 0 || countB <= 0) { EnsembleSide liveSide = countA > 0 ? ensemble.SideA : ensemble.SideB; int liveN = Math.Max(countA, countB); if (liveSide != null && liveN > 0 && !string.IsNullOrEmpty(liveSide.Key)) { EnsembleScale mopScale = LiveEnsemble.ScaleForCount(liveN); string mopTier = ScaleTierLabel( mopScale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : mopScale); EnsembleFrame mopFrame = ensemble.Frame == EnsembleFrame.KingdomVsKingdom ? EnsembleFrame.KingdomVsKingdom : EnsembleFrame.SpeciesVsSpecies; string pack = FormatCombatSide(liveSide, mopFrame); if (!string.IsNullOrEmpty(pack)) { return mopTier + " - " + pack; } } } else { string tier = ScaleTierLabel( ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale); string sideA = FormatCombatSide(ensemble.SideA, ensemble.Frame); string sideB = FormatCombatSide(ensemble.SideB, ensemble.Frame); if (!string.IsNullOrEmpty(sideA) && !string.IsNullOrEmpty(sideB)) { return tier + " - " + sideA + " vs " + sideB; } } } string packTier = ScaleTierLabel( ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale); // Same-species pack with no opposing sticky camps. if (ensemble.SideA != null && !string.IsNullOrEmpty(ensemble.SideA.Key)) { string pack = FormatCombatSide(ensemble.SideA, EnsembleFrame.SpeciesVsSpecies); if (!string.IsNullOrEmpty(pack)) { return packTier + " - " + pack; } } // Never emit thin "X is fighting" from the multi path - leave blank for director reuse. if (ensemble.Scale > EnsembleScale.Pair || LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { return ""; } return Fight(ensemble.Focus, ensemble.Related); } private static bool SidesAreSameCollective(string sideA, string sideB) { if (string.IsNullOrEmpty(sideA) || string.IsNullOrEmpty(sideB)) { return false; } // Strip counts: "Wolves (6)" → "Wolves" string a = StripSideCount(sideA); string b = StripSideCount(sideB); return a.Equals(b, StringComparison.OrdinalIgnoreCase); } private static string StripSideCount(string side) { if (string.IsNullOrEmpty(side)) { return ""; } int paren = side.LastIndexOf(" (", StringComparison.Ordinal); return paren > 0 ? side.Substring(0, paren).Trim() : side.Trim(); } private static string ScaleTierLabel(EnsembleScale scale) { switch (scale) { case EnsembleScale.Pair: return "Duel"; case EnsembleScale.Skirmish: return "Skirmish"; case EnsembleScale.Mass: return "Mass"; default: return "Battle"; } } private static string FormatCombatSide(EnsembleSide side, EnsembleFrame frame) { if (side == null) { return ""; } // Mass/skirmish never print unit proper names as a side. if (frame == EnsembleFrame.NamedLeaders || frame == EnsembleFrame.NamedPair) { return ""; } string label = side.Display ?? ""; if (string.IsNullOrEmpty(label)) { label = side.Key ?? ""; } if (frame == EnsembleFrame.KingdomVsKingdom) { label = LiveEnsemble.KingdomDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key); } else if (frame == EnsembleFrame.SpeciesVsSpecies) { label = LiveEnsemble.SpeciesDisplay(string.IsNullOrEmpty(side.Key) ? label : side.Key); if (!string.IsNullOrEmpty(side.KingdomDisplay)) { string kingdom = LiveEnsemble.KingdomDisplay(side.KingdomDisplay); string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay(side.KingdomDisplay); if (!string.IsNullOrEmpty(kingdom) && !kingdom.Equals(label, StringComparison.OrdinalIgnoreCase) && !kingdomAsSpecies.Equals(label, StringComparison.OrdinalIgnoreCase) && kingdom.IndexOf(label, StringComparison.OrdinalIgnoreCase) < 0) { label = "[" + kingdom + "] " + label; } } } else { // CountOnly / unknown - refuse unit-looking labels. return ""; } if (string.IsNullOrEmpty(label)) { return ""; } label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); // Callers must not pair a zero-count side into "vs" framing (see Combat mop-up). int n = Math.Max(0, side.Count); return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")"; } /// /// War front orange reason: War - Essiona (120) vs Northreach (80). /// Kingdom sides only; never unit proper names. /// public static string WarFront(LiveEnsemble ensemble) { if (ensemble == null || !ensemble.HasFocus) { return ""; } if (!LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { return ""; } string sideA = FormatCombatSide(ensemble.SideA, EnsembleFrame.KingdomVsKingdom); string sideB = FormatCombatSide(ensemble.SideB, EnsembleFrame.KingdomVsKingdom); if (string.IsNullOrEmpty(sideA) || string.IsNullOrEmpty(sideB)) { return ""; } return "War - " + sideA + " vs " + sideB; } /// /// Plot cell orange reason: Plot - Plotters (3) vs Essiona (120). /// Side A is a collective plotter camp; Side B is the target kingdom. Never unit names. /// public static string PlotCell(LiveEnsemble ensemble) { if (ensemble == null || !ensemble.HasFocus) { return ""; } if (!LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { return ""; } // Groups only - never "Plot - Plotters (1) …" / (0). if (ensemble.SideA == null || ensemble.SideA.Count < 2) { return ""; } string sideA = FormatPlotterSide(ensemble.SideA); string sideB = FormatCombatSide(ensemble.SideB, EnsembleFrame.KingdomVsKingdom); if (string.IsNullOrEmpty(sideA) || string.IsNullOrEmpty(sideB)) { return ""; } return "Plot - " + sideA + " vs " + sideB; } private static string FormatPlotterSide(EnsembleSide side) { if (side == null) { return ""; } string label = side.Display ?? ""; if (string.IsNullOrEmpty(label) || LiveEnsemble.IsPlotterSideKey(label)) { label = "Plotters"; } // Refuse accidental unit proper names (no spaces-only short names from actors). if (label.IndexOf(' ') >= 0 && !label.Equals("Plotters", StringComparison.OrdinalIgnoreCase)) { label = "Plotters"; } label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); int n = Math.Max(0, side.Count); return label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")"; } /// Dispatch by ensemble kind (combat / war / plot; other kinds plug in later). public static string Ensemble(LiveEnsemble ensemble) { if (ensemble == null) { return ""; } switch (ensemble.Kind) { case EnsembleKind.WarFront: return WarFront(ensemble); case EnsembleKind.PlotCell: return PlotCell(ensemble); case EnsembleKind.FamilyPack: return FamilyPack(ensemble); case EnsembleKind.StatusOutbreak: return StatusOutbreak(ensemble); case EnsembleKind.Combat: return Combat(ensemble); default: return Combat(ensemble); } } public static string SeekingLover(Actor a) { return NameOrSomeone(a) + " is seeking a lover"; } public static string BecomeAlpha(Actor a) { return NameOrSomeone(a) + " becomes the family alpha"; } 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); string bn = Name(b); if (string.IsNullOrEmpty(bn)) { return an + " found a lover"; } return an + " became lovers with " + bn; } public static string LoverParted(Actor a) { return NameOrSomeone(a) + " parted from a lover"; } public static string FamilyPack(Actor a, Actor peer, string phase) { string an = NameOrSomeone(a); string bn = Name(peer); string p = (phase ?? "").Trim().ToLowerInvariant(); switch (p) { case "join": case "family_group_join": return string.IsNullOrEmpty(bn) ? an + " joins a family pack" : an + " joins a family pack with " + bn; case "leave": case "family_group_leave": return string.IsNullOrEmpty(bn) ? an + " leaves a family pack" : an + " leaves a family pack with " + bn; case "form": case "new": case "family_group_new": return string.IsNullOrEmpty(bn) ? an + " forms a family pack" : an + " forms a family pack with " + bn; default: return an + " gathers with kin"; } } /// /// Sticky family pack orange reason: Pack - Wolves (5) · gathering. /// Species-framed collective + why the camera holds; never unit proper names. /// public static string FamilyPack(LiveEnsemble ensemble) { if (ensemble == null || !ensemble.HasFocus || ensemble.SideA == null) { return ""; } if (ensemble.Kind != EnsembleKind.FamilyPack && !LiveEnsemble.IsFamilyPackSideKey(ensemble.SideA.Key)) { return ""; } string label = ensemble.SideA.Display ?? ""; if (string.IsNullOrEmpty(label) || LiveEnsemble.IsFamilyPackSideKey(label)) { label = LiveEnsemble.SpeciesDisplay(LiveEnsemble.SpeciesKeyOf(ensemble.Focus)); } if (string.IsNullOrEmpty(label)) { label = "Kin"; } // Refuse accidental unit proper names (spaces usually mean a person name). if (label.IndexOf(' ') >= 0) { string species = LiveEnsemble.SpeciesDisplay(LiveEnsemble.SpeciesKeyOf(ensemble.Focus)); label = string.IsNullOrEmpty(species) ? "Kin" : species; } label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); int n = Math.Max(0, ensemble.SideA.Count); return "Pack - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ") · gathering"; } public static string NewChild(Actor a, Actor b) { string an = NameOrSomeone(a); string bn = Name(b); if (string.IsNullOrEmpty(bn)) { return an + " has 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) { // Subject is the baby from createBabyActorFromData. return NameOrSomeone(a) + " is created as a baby"; } public static string NewFamily(Actor a) { return NameOrSomeone(a) + " starts a new family"; } public static string FamilyEnded(Actor a) { return NameOrSomeone(a) + "'s family ends"; } public static string BuildingEat(Actor a, Building food) { string an = NameOrSomeone(a); string buildingId = BuildingId(food); string buildingType = BuildingType(food); if (string.Equals(buildingType, "type_fruits", StringComparison.OrdinalIgnoreCase)) { return string.IsNullOrEmpty(buildingId) ? an + " is foraging fruit" : an + " is foraging " + HumanizeId(buildingId); } if (!string.IsNullOrEmpty(buildingId)) { return an + " is eating a " + HumanizeId(buildingId); } return an + " is eating"; } public static string BuildingDamage(Actor a, Building building) { string an = NameOrSomeone(a); string buildingId = BuildingId(building); if (!string.IsNullOrEmpty(buildingId)) { return an + " is damaging a " + HumanizeId(buildingId); } return an + " is damaging a building"; } public static string BuildingFalls(string buildingId, Actor near) { string id = HumanizeId(buildingId); if (near != null && near.isAlive()) { string n = Name(near); if (!string.IsNullOrEmpty(n)) { return string.IsNullOrEmpty(id) ? n + " is near a falling building" : n + " is near a falling " + id; } } return string.IsNullOrEmpty(id) ? "A building falls" : "A " + id + " falls"; } public static string Status(Actor a, string phrase) { string an = NameOrSomeone(a); string p = (phrase ?? "").Trim(); if (string.IsNullOrEmpty(p)) { return an + " changes status"; } return SubjectLedClause(an, p); } /// /// Stable sticky status outbreak reason, e.g. Outbreak - Cursed. /// Counts remain in the typed ensemble but do not churn the orange story sentence. /// public static string StatusOutbreak(LiveEnsemble ensemble) { if (ensemble == null || !ensemble.HasFocus || ensemble.SideA == null) { return ""; } if (ensemble.Kind != EnsembleKind.StatusOutbreak && !LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key)) { return ""; } string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key) ? ensemble.SideA.Key.Substring("status:".Length) : ""; string label = ensemble.SideA.Display ?? ""; if (string.IsNullOrEmpty(label) || LiveEnsemble.IsStatusOutbreakSideKey(label)) { label = LiveEnsemble.StatusDisplayName(statusId); } if (string.IsNullOrEmpty(label)) { label = "Afflicted"; } // Prefer noun labels ("Cursed") over accidental sentence crumbs. label = label.Trim(); if (label.StartsWith("Is ", StringComparison.OrdinalIgnoreCase)) { label = label.Substring(3).Trim(); } if (string.IsNullOrEmpty(label)) { label = "Afflicted"; } int n = Math.Max(0, ensemble.SideA.Count); return StatusClusterLabel(statusId, label, n); } internal static string StatusClusterLabel(string statusId, string display, int count) { string id = (statusId ?? "").Trim().ToLowerInvariant(); if (id == "burning" || id == "burned" || id == "on_fire") { return "Fire spreads through the crowd"; } if (id == "drowning") { return "A crowd is drowning"; } string label = (display ?? "").Trim(); if (string.IsNullOrEmpty(label)) { label = "Afflicted"; } label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); return "Outbreak - " + label; } public static string Trait(Actor a, string traitId, bool gained) { string an = NameOrSomeone(a); string id = HumanizeId(traitId); if (string.IsNullOrEmpty(id)) { id = "a trait"; } return gained ? an + " gains " + id : an + " loses " + id; } /// /// Plot labels are phase-aware: start templates stay progressive; finish verbs fire on complete. /// public static string Plot(Actor a, string template, string phase, string plotId = "") { string filled = Apply( string.IsNullOrEmpty(template) ? "{a} is plotting" : template, a, id: plotId); string p = (phase ?? "active").Trim().ToLowerInvariant(); switch (p) { case "complete": case "finished": case "finish": return RewritePlotProgressive(filled, "finishes "); case "cancel": case "cancelled": case "canceled": return RewritePlotAbandon(filled); case "leave": return NameOrSomeone(a) + " leaves a plot"; case "join": return filled; case "new": case "active": default: return RewritePlotStart(filled); } } private static string RewritePlotStart(string label) { if (string.IsNullOrEmpty(label)) { return label; } // Finish-style verbs on start → "plots to …" string[] finishVerbs = { "summons ", "casts ", "splits ", "causes ", "performs ", }; foreach (string verb in finishVerbs) { int idx = IndexOfWordPhrase(label, verb); if (idx < 0) { continue; } string infinitive = InfinitiveFromFinishVerb(verb.TrimEnd()); return label.Substring(0, idx) + "plots to " + infinitive + label.Substring(idx + verb.Length); } return label; } private static string RewritePlotProgressive(string label, string replacementPrefix) { if (string.IsNullOrEmpty(label)) { return label; } string[] progressives = { "is plotting ", "is joining ", "is breaking ", "is ending ", "is writing ", "is forging ", "is founding ", "is ascending ", }; foreach (string progressive in progressives) { int idx = IndexOfWordPhrase(label, progressive); if (idx >= 0) { // "is plotting " → "plotting " after dropping "is " return label.Substring(0, idx) + replacementPrefix + progressive.Substring(3) + label.Substring(idx + progressive.Length); } } // Already a finish verb ("summons", "casts", …) - correct for complete. return label; } private static string RewritePlotAbandon(string label) { if (string.IsNullOrEmpty(label)) { return label; } string progressive = RewritePlotProgressive(label, "abandons "); if (progressive != label) { return progressive; } string[] finishVerbs = { "summons ", "casts ", "splits ", "causes ", "performs ", }; foreach (string verb in finishVerbs) { int idx = IndexOfWordPhrase(label, verb); if (idx < 0) { continue; } string gerund = GerundFromFinishVerb(verb.TrimEnd()); return label.Substring(0, idx) + "abandons " + gerund + label.Substring(idx + verb.Length); } return label; } private static string InfinitiveFromFinishVerb(string verb) { switch (verb) { case "summons": return "summon "; case "casts": return "cast "; case "splits": return "split "; case "causes": return "cause "; case "performs": return "perform "; default: return verb + " "; } } private static string GerundFromFinishVerb(string verb) { switch (verb) { case "summons": return "summoning "; case "casts": return "casting "; case "splits": return "splitting "; case "causes": return "causing "; case "performs": return "performing "; default: return verb + " "; } } private static int IndexOfWordPhrase(string haystack, string phrase) { if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(phrase)) { return -1; } return haystack.IndexOf(phrase, StringComparison.OrdinalIgnoreCase); } public static string Boat(Actor a, string phase) { string an = NameOrSomeone(a); switch ((phase ?? "").Trim().ToLowerInvariant()) { case "unload": case "boat_unload": return an + " is unloading passengers"; case "load": case "boat_load": return an + " is loading passengers"; case "trade": case "boat_trade": return an + " is trading by boat"; default: return an + " is on a boat"; } } public static string MetaNew(Actor a, string kind) { string an = NameOrSomeone(a); string k = HumanizeId(kind); if (string.IsNullOrEmpty(k)) { k = "something new"; } return an + " founds a " + k; } public static string Library(Actor a, string kind, string assetId) { string k = (kind ?? "").Trim().ToLowerInvariant(); string id = LibraryAssetNames.DisplayName(k, assetId); if (string.IsNullOrEmpty(id)) { id = HumanizeId(assetId); } bool hasActor = a != null && a.isAlive(); string an = hasActor ? NameOrSomeone(a) : ""; if (string.IsNullOrEmpty(id)) { if (!hasActor) { return string.IsNullOrEmpty(k) ? "An event" : HumanizeId(k); } return an + " triggers " + (string.IsNullOrEmpty(k) ? "an event" : HumanizeId(k)); } switch (k) { case "spell": { // Locale / humanize often keep a leading "Cast"/"Summon"; strip so we never // emit "casts Cast Blood Rain" or "casts Summon Lightning". string spellObject = SpellCastObject(assetId, id); return hasActor ? an + " casts " + spellObject : spellObject + " is cast"; } case "item": return hasActor ? an + " " + LibraryAssetNames.ItemVerb(assetId) + " " + id : id + " is gained"; case "decision": return Decision(a, assetId); case "gene": return hasActor ? an + " gains gene " + id : "Gene " + id; case "phenotype": return hasActor ? an + " shows " + id : "Phenotype " + id; case "power": return hasActor ? an + " channels " + id : "God power: " + id; case "worldlaw": case "world_law": return "Law changed: " + id; case "biome": return "Biome shifts: " + id; case "subspecies_trait": return hasActor ? an + " evolves " + id : id + " evolves"; case "culture_trait": return hasActor ? an + "'s culture gains " + id : "Culture gains " + id; case "religion_trait": return hasActor ? an + "'s faith gains " + id : "Faith gains " + id; case "clan_trait": return hasActor ? an + "'s clan gains " + id : "Clan gains " + id; case "language_trait": return hasActor ? an + "'s language gains " + id : "Language gains " + id; default: return hasActor ? an + " · " + id : id; } } public static string Earthquake(Actor near) { if (near != null && near.isAlive()) { string n = Name(near); if (!string.IsNullOrEmpty(n)) { return n + " is caught in an earthquake"; } } return "Earthquake shakes the land"; } /// Civ construction finished - temple/statue spectacle vs ordinary house. public static string BuildingComplete(Actor near, string buildingId, bool spectacle) { string id = LibraryAssetNames.DisplayName("building", buildingId); if (string.IsNullOrEmpty(id)) { id = HumanizeId(buildingId); } id = NormalizeBuildingDisplay(buildingId, id); string what = string.IsNullOrEmpty(id) ? "a structure" : WithIndefiniteArticle(id); if (near != null && near.isAlive()) { string n = Name(near); if (!string.IsNullOrEmpty(n)) { return spectacle ? n + " completes " + what : n + " finishes building " + what; } } return spectacle ? "Wonder completed: " + (string.IsNullOrEmpty(id) ? "structure" : id) : "Building completed: " + (string.IsNullOrEmpty(id) ? "structure" : id); } /// Turn killer-POV Chronicle shorthand into a camera-readable subject-led beat. public static string SubjectLedKill(string subjectName, string chronicleLine) { string subject = (subjectName ?? "").Trim(); string line = (chronicleLine ?? "").Trim(); if (string.IsNullOrEmpty(subject)) { return line; } if (line.StartsWith("Killed ", StringComparison.OrdinalIgnoreCase)) { return subject + " kills " + line.Substring("Killed ".Length); } return string.IsNullOrEmpty(line) ? subject + " takes a life" : subject + " · " + line; } /// Turn friend-POV Chronicle shorthand into a camera-readable subject-led beat. public static string SubjectLedFriend(string subjectName, string chronicleLine) { string subject = (subjectName ?? "").Trim(); string line = (chronicleLine ?? "").Trim(); if (string.IsNullOrEmpty(subject)) { return line; } if (line.StartsWith("Befriended ", StringComparison.OrdinalIgnoreCase)) { return subject + " befriends " + line.Substring("Befriended ".Length); } return string.IsNullOrEmpty(line) ? subject + " forms a friendship" : subject + " · " + line; } /// Turn lover-POV Chronicle shorthand into a complete relationship beat. public static string SubjectLedLover(string subjectName, string chronicleLine) { const string prefix = "Became lovers with "; string subject = (subjectName ?? "").Trim(); string line = (chronicleLine ?? "").Trim(); if (string.IsNullOrEmpty(subject)) { return line; } if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { string partner = line.Substring(prefix.Length).Trim(); if (!string.IsNullOrEmpty(partner)) { return subject + " and " + partner + " become lovers"; } } return SubjectLedClause(subject, line); } /// /// Turn a POV-style clause into a complete orange story sentence. /// Chronicle and happiness sources sometimes omit the subject or capitalize /// the verb as though it were a standalone history entry. /// public static string SubjectLedClause(string subjectName, string clause) { string subject = (subjectName ?? "").Trim(); string line = (clause ?? "").Trim(); if (string.IsNullOrEmpty(subject)) { return line; } if (string.IsNullOrEmpty(line)) { return subject + " experiences a change"; } if (line.StartsWith(subject, StringComparison.OrdinalIgnoreCase)) { return line; } return subject + " " + char.ToLowerInvariant(line[0]) + (line.Length > 1 ? line.Substring(1) : ""); } private static string NormalizeBuildingDisplay(string buildingId, string display) { string raw = (buildingId ?? "").Trim().ToLowerInvariant(); string value = (display ?? "").Trim(); string[] cultureSuffixes = { "human", "dwarf", "elf", "orc" }; for (int i = 0; i < cultureSuffixes.Length; i++) { string suffix = "_" + cultureSuffixes[i]; int suffixAt = raw.LastIndexOf(suffix, StringComparison.Ordinal); if (suffixAt < 0) { continue; } string variant = raw.Substring(suffixAt + suffix.Length); bool validVariant = string.IsNullOrEmpty(variant); if (!validVariant && variant[0] == '_') { validVariant = variant.Length > 1; for (int j = 1; j < variant.Length && validVariant; j++) { validVariant = char.IsDigit(variant[j]); } } if (!validVariant) { continue; } string baseId = raw.Substring(0, suffixAt); string fallback = HumanizeId(raw); if (string.IsNullOrEmpty(value) || value.Equals(fallback, StringComparison.OrdinalIgnoreCase)) { return HumanizeId(baseId); } } return value; } private static string WithIndefiniteArticle(string noun) { string value = (noun ?? "").Trim(); if (string.IsNullOrEmpty(value)) { return "a structure"; } char first = char.ToLowerInvariant(value[0]); string article = first == 'a' || first == 'e' || first == 'i' || first == 'o' || first == 'u' ? "an " : "a "; return article + char.ToLowerInvariant(value[0]) + (value.Length > 1 ? value.Substring(1) : ""); } public static bool HarnessProbePresentationProse(out string detail) { string building = BuildingComplete(null, "tent_human", spectacle: false); bool buildingClean = building == "Building completed: Tent" && building.IndexOf("Human", StringComparison.OrdinalIgnoreCase) < 0; string variantBuilding = BuildingComplete(null, "windmill_elf_0", spectacle: false); bool variantBuildingClean = variantBuilding == "Building completed: Windmill" && variantBuilding.IndexOf("Elf", StringComparison.OrdinalIgnoreCase) < 0 && variantBuilding.IndexOf("0", StringComparison.Ordinal) < 0; string kill = SubjectLedKill("Aro", "Killed Tampoo (monkey)"); bool killClean = kill == "Aro kills Tampoo (monkey)"; string friend = SubjectLedFriend("Eceas", "Befriended Oreero"); bool friendClean = friend == "Eceas befriends Oreero"; string lover = SubjectLedLover("Starna", "Became lovers with Skenstyr"); bool loverClean = lover == "Starna and Skenstyr become lovers"; string clause = SubjectLedClause("Tampoo", "Is starving"); bool clauseClean = clause == "Tampoo is starving"; string fire = StatusClusterLabel("burning", "Burning", 3); string drowning = StatusClusterLabel("drowning", "Drowning", 8); string outbreak = StatusClusterLabel("cursed", "Cursed", 12); bool fireClean = fire == "Fire spreads through the crowd"; bool hazardClean = drowning == "A crowd is drowning" && outbreak == "Outbreak - Cursed"; string species = LiveEnsemble.SpeciesDisplay("slava"); string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay("Slava"); bool collectiveClean = string.Equals( species, kingdomAsSpecies, StringComparison.OrdinalIgnoreCase); bool ok = buildingClean && variantBuildingClean && killClean && friendClean && loverClean && clauseClean && fireClean && hazardClean && collectiveClean; detail = "building='" + building + "' variant='" + variantBuilding + "' kill='" + kill + "' friend='" + friend + "' lover='" + lover + "' clause='" + clause + "' fire='" + fire + "' drowning='" + drowning + "' outbreak='" + outbreak + "' species='" + species + "' kingdomSpecies='" + kingdomAsSpecies + "' pass=" + ok; return ok; } public static string Decision(Actor a, string decisionId) { string an = NameOrSomeone(a); string action = EventCatalog.Decision.ActionPhraseFor(decisionId); if (string.IsNullOrEmpty(action)) { return an + " makes a decision"; } return an + " decides to " + action; } /// /// Apply a sentence template with {a}/{b}/{id}. Prefer typed helpers for new copy. /// public static string Apply(string template, Actor a, Actor b = null, string id = "") { string t = string.IsNullOrEmpty(template) ? "{a}" : template; string displayId = string.IsNullOrEmpty(id) ? "" : HumanizeId(id); // Prefer humanized id in catalog templates so `{id}` never dumps snake_case. return t .Replace("{id}", displayId) .Replace("{a}", Name(a) ?? "") .Replace("{b}", Name(b) ?? ""); } public static string HumanizeId(string id) { if (string.IsNullOrEmpty(id)) { return ""; } string raw = id.Trim().Replace('-', '_'); if (raw.StartsWith("type_", StringComparison.OrdinalIgnoreCase)) { raw = raw.Substring(5); } string[] parts = raw.Split(new[] { '_' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length == 0) { return raw; } var sb = new StringBuilder(raw.Length + 4); for (int i = 0; i < parts.Length; i++) { if (i > 0) { sb.Append(' '); } string p = parts[i]; if (p.Length == 0) { continue; } sb.Append(char.ToUpperInvariant(p[0])); if (p.Length > 1) { sb.Append(p.Substring(1).ToLowerInvariant()); } } return sb.ToString(); } /// /// Mid-sentence spell object after "casts …" - never repeats Cast/Summon from the asset name. /// public static string SpellCastObject(string spellId, string displayFallback = "") { string raw = (spellId ?? "").Trim().ToLowerInvariant().Replace('-', '_'); string core = raw; if (core.StartsWith("cast_", StringComparison.Ordinal)) { core = core.Substring(5); } else if (core.StartsWith("summon_", StringComparison.Ordinal)) { core = core.Substring(7); } string fromDisplay = StripLeadingCastVerb(displayFallback); if (string.IsNullOrEmpty(fromDisplay) && !string.IsNullOrEmpty(spellId)) { fromDisplay = StripLeadingCastVerb(LibraryAssetNames.DisplayName("spell", spellId)); } string phrase = !string.IsNullOrEmpty(fromDisplay) ? fromDisplay : HumanizeId(core); if (string.IsNullOrEmpty(phrase)) { phrase = "a spell"; } return phrase.ToLowerInvariant(); } private static string StripLeadingCastVerb(string display) { if (string.IsNullOrEmpty(display)) { return ""; } string d = display.Trim(); if (d.StartsWith("Cast ", StringComparison.OrdinalIgnoreCase)) { return d.Substring(5).Trim(); } if (d.StartsWith("Summon ", StringComparison.OrdinalIgnoreCase)) { return d.Substring(7).Trim(); } return d; } private static string NameOrSomeone(Actor a) { string n = Name(a); return string.IsNullOrEmpty(n) ? "Someone" : n; } private static string Name(Actor a) => EventFeedUtil.SafeName(a); private static string BuildingId(Building building) { if (building?.asset == null) { return ""; } try { return building.asset.id ?? ""; } catch { return ""; } } private static string BuildingType(Building building) { if (building?.asset == null) { return ""; } try { return building.asset.type ?? ""; } catch { return ""; } } }