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); } 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 (0) - counts may be 0 while sides stay sticky. /// 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; } string tier = ScaleTierLabel( ensemble.Scale <= EnsembleScale.Pair ? EnsembleScale.Skirmish : ensemble.Scale); if (LiveEnsemble.HasOpposingCollectiveSides(ensemble)) { 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; } } // 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 tier + " - " + 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); if (!string.IsNullOrEmpty(kingdom) && !kingdom.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) : ""); // Allow 0 so sticky camps can show a side briefly empty, then climb again on rejoin. 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"; } // Prose often already includes a verb phrase ("is burning"). if (p.StartsWith("is ", StringComparison.OrdinalIgnoreCase) || p.StartsWith("are ", StringComparison.OrdinalIgnoreCase)) { return an + " " + p; } return an + " · " + p; } /// /// Sticky status outbreak orange reason: Outbreak - Cursed (4). /// Collective status label + carrier count; never unit proper names. /// 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 label = ensemble.SideA.Display ?? ""; if (string.IsNullOrEmpty(label) || LiveEnsemble.IsStatusOutbreakSideKey(label)) { string statusId = LiveEnsemble.IsStatusOutbreakSideKey(ensemble.SideA.Key) ? ensemble.SideA.Key.Substring("status:".Length) : ""; 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"; } label = char.ToUpperInvariant(label[0]) + (label.Length > 1 ? label.Substring(1) : ""); int n = Math.Max(0, ensemble.SideA.Count); return "Outbreak - " + label + " (" + n.ToString(CultureInfo.InvariantCulture) + ")"; } 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 an = NameOrSomeone(a); string id = HumanizeId(assetId); string k = HumanizeId(kind); if (string.IsNullOrEmpty(id)) { return an + " triggers " + (string.IsNullOrEmpty(k) ? "an event" : k); } switch ((kind ?? "").Trim().ToLowerInvariant()) { case "spell": return an + " casts " + id; case "item": return an + " forges " + id; case "decision": return Decision(a, assetId); case "gene": return an + " gains gene " + id; case "phenotype": return an + " shows phenotype " + id; case "power": return an + " channels " + id; case "subspecies_trait": return an + " gains " + id; case "culture_trait": return an + "'s culture gains " + id; case "religion_trait": return an + "'s faith gains " + id; case "clan_trait": return an + "'s clan gains " + id; case "language_trait": return an + "'s language gains " + id; default: return an + " · " + id; } } 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; return t .Replace("{id}", id ?? "") .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(); } 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 ""; } } }