worldbox-observer-mod/IdleSpectator/Events/EventReason.cs
DazedAnon fcf2445cd0 fix(narrative): improve caption relevance and combat continuity
- Limit saga context to relevant beats and suppress repeated facts
- Stabilize combat labels and commit telemetry after focus changes
- Preserve relationship participants and normalize event prose
- Compact narrative sidecars and extend presentation coverage
2026-07-23 14:52:27 -05:00

1310 lines
40 KiB
C#

using System;
using System.Globalization;
using System.Text;
namespace IdleSpectator;
/// <summary>
/// Sole factory for interest Labels shown as the orange dossier reason.
/// Part of the <c>Events/</c> module with catalogs - feeds call these helpers (or Apply)
/// instead of concatenating crumbs. Director never authors reasons.
/// </summary>
public static class EventReason
{
/// <summary>
/// True when two labels differ only by parenthesized headcounts
/// (e.g. <c>Mass - A (12) vs B (3)</c> → <c>Mass - A (11) vs B (4)</c>).
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
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());
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);
}
/// <summary>
/// Context-aware combat orange reason from a <see cref="LiveEnsemble"/> snapshot.
/// Multi form: <c>Battle - Humans (4) vs Wolves (3)</c>. A wiped camp collapses to a
/// living-side pack label at live scale (never <c>vs … (0)</c>).
/// Never uses raw unit names as mass-side labels. Never prints thin <c>melee</c> fallbacks.
/// </summary>
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) + ")";
}
/// <summary>
/// War front orange reason: <c>War - Essiona (120) vs Northreach (80)</c>.
/// Kingdom sides only; never unit proper names.
/// </summary>
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;
}
/// <summary>
/// Plot cell orange reason: <c>Plot - Plotters (3) vs Essiona (120)</c>.
/// Side A is a collective plotter camp; Side B is the target kingdom. Never unit names.
/// </summary>
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) + ")";
}
/// <summary>Dispatch by ensemble kind (combat / war / plot; other kinds plug in later).</summary>
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";
}
}
/// <summary>
/// Sticky family pack orange reason: <c>Pack - Wolves (5) · gathering</c>.
/// Species-framed collective + why the camera holds; never unit proper names.
/// </summary>
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;
}
/// <summary>
/// Sticky status outbreak orange reason: <c>Outbreak - Cursed (4)</c>.
/// Collective status label + carrier count; never unit proper names.
/// </summary>
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;
}
/// <summary>
/// Plot labels are phase-aware: start templates stay progressive; finish verbs fire on complete.
/// </summary>
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";
}
/// <summary>Civ construction finished - temple/statue spectacle vs ordinary house.</summary>
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);
}
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];
if (!raw.EndsWith(suffix, StringComparison.Ordinal))
{
continue;
}
string baseId = raw.Substring(0, raw.Length - suffix.Length);
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 species = LiveEnsemble.SpeciesDisplay("slava");
string kingdomAsSpecies = LiveEnsemble.SpeciesDisplay("Slava");
bool collectiveClean = string.Equals(
species, kingdomAsSpecies, StringComparison.OrdinalIgnoreCase);
bool ok = buildingClean && collectiveClean;
detail = "building='" + building + "' 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;
}
/// <summary>
/// Apply a sentence template with {a}/{b}/{id}. Prefer typed helpers for new copy.
/// </summary>
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();
}
/// <summary>
/// Mid-sentence spell object after "casts …" - never repeats Cast/Summon from the asset name.
/// </summary>
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 "";
}
}
}