733 lines
21 KiB
C#
733 lines
21 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
|
|
{
|
|
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 (0)</c> - counts may be 0 while sides stay sticky.
|
|
/// 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;
|
|
}
|
|
|
|
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) + ")";
|
|
}
|
|
|
|
/// <summary>Dispatch by ensemble kind (combat first; other kinds plug in later).</summary>
|
|
public static string Ensemble(LiveEnsemble ensemble)
|
|
{
|
|
if (ensemble == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
switch (ensemble.Kind)
|
|
{
|
|
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";
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
/// <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;
|
|
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 "";
|
|
}
|
|
}
|
|
}
|