433 lines
15 KiB
C#
433 lines
15 KiB
C#
using System;
|
|
|
|
namespace IdleSpectator;
|
|
|
|
/// <summary>
|
|
/// Assembles one alive activity sentence from observed <see cref="ActivityContext"/> clauses.
|
|
/// Packs observed slots (life, identity, act, target, carrying, place, combat).
|
|
/// Traits salt wording variety only - never named in the line (dossier already shows them).
|
|
/// Job / role stay on the dossier reason row - never in Activity prose.
|
|
/// </summary>
|
|
public static class ActivityClauseAssembler
|
|
{
|
|
private static readonly string[] CargoImplicationWords =
|
|
{
|
|
"carrying", "haul", "hauls", "hauling", "load", "loads", "loading",
|
|
"goods", "wheat", "berries", "spoils", "material", "cargo", "delivery",
|
|
"unload", "unloads", "unloading", "store", "stores", "storage", "delivers",
|
|
"weight", "bears", "heaves", "drags", "pulls", "draws", "packs", "carries"
|
|
};
|
|
|
|
/// <summary>
|
|
/// Build a template still containing tokens for <see cref="ActivityProse.Apply"/>.
|
|
/// Optional <paramref name="polishTemplate"/> is celebrity/family/beat flavor - still enriched.
|
|
/// </summary>
|
|
public static string Assemble(
|
|
ActivityContext ctx,
|
|
string key,
|
|
string rawFact,
|
|
bool hasTarget,
|
|
long subjectId,
|
|
int lineIndex)
|
|
{
|
|
if (ctx == null)
|
|
{
|
|
ctx = ActivityContext.Empty;
|
|
}
|
|
|
|
string action = ActivityActionComposer.Compose(
|
|
ctx,
|
|
key,
|
|
rawFact,
|
|
hasTarget,
|
|
subjectId,
|
|
lineIndex);
|
|
|
|
// Strip any leftover job tokens - job belongs on the dossier, not Activity.
|
|
action = StripToken(action, "{job_as}");
|
|
action = StripToken(action, "{job}");
|
|
|
|
string subject = BuildSubject(ctx);
|
|
string verb = ActivityVerbMap.Resolve(key);
|
|
if (IsCompanionVerb(verb))
|
|
{
|
|
action = BindSingletonCompanions(ctx, action);
|
|
}
|
|
|
|
// Traits stay on the dossier chips - they only salt variety below, never named here.
|
|
|
|
string target = BuildObservedTargetClause(ctx, action, verb);
|
|
action = EnsurePlaceClause(action, ctx, verb, key);
|
|
string carrying = BuildCarryingClause(ctx, action);
|
|
string pressure = BuildPressureClause(ctx, action + target);
|
|
string line = subject + " " + action + target + carrying + pressure;
|
|
return CollapseSpaces(line).Trim();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attach {place_at} when the action still lacks a place token.
|
|
/// Objects (bonfire, house, …) appear on site-work verbs; settlements stay settle/trade/farm/unload.
|
|
/// </summary>
|
|
private static string EnsurePlaceClause(string action, ActivityContext ctx, string verb, string key)
|
|
{
|
|
if (ctx == null
|
|
|| string.IsNullOrEmpty(ctx.PlaceLabel)
|
|
|| string.IsNullOrEmpty(action)
|
|
|| action.IndexOf("{place_at}", StringComparison.Ordinal) >= 0
|
|
|| action.IndexOf("{place}", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return action;
|
|
}
|
|
|
|
if (ctx.PlaceIsObject)
|
|
{
|
|
if (WantsObjectPlace(verb) || IsUnloadKey(key))
|
|
{
|
|
return action + "{place_at}";
|
|
}
|
|
|
|
return action;
|
|
}
|
|
|
|
if (WantsSettlementPlace(verb) || IsUnloadKey(key))
|
|
{
|
|
return action + "{place_at}";
|
|
}
|
|
|
|
return action;
|
|
}
|
|
|
|
private static bool IsUnloadKey(string key)
|
|
{
|
|
return !string.IsNullOrEmpty(key)
|
|
&& (key.Equals("BehUnloadResources", StringComparison.OrdinalIgnoreCase)
|
|
|| key.Equals("BehThrowResources", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static bool WantsSettlementPlace(string verb)
|
|
{
|
|
return verb == "settle" || verb == "trade" || verb == "farm";
|
|
}
|
|
|
|
private static bool WantsObjectPlace(string verb)
|
|
{
|
|
return verb == "work"
|
|
|| verb == "haul"
|
|
|| verb == "farm"
|
|
|| verb == "trade"
|
|
|| verb == "settle"
|
|
|| verb == "fire"
|
|
|| verb == "pollinate"
|
|
|| verb == "fish"
|
|
|| verb == "heal"
|
|
|| verb == "read";
|
|
}
|
|
|
|
private static bool IsCompanionVerb(string verb)
|
|
{
|
|
return verb == "social"
|
|
|| verb == "lover"
|
|
|| verb == "cry"
|
|
|| verb == "swear"
|
|
|| verb == "group";
|
|
}
|
|
|
|
private static string BuildObservedTargetClause(ActivityContext ctx, string action, string verb)
|
|
{
|
|
if (ctx == null
|
|
|| !IsCompanionVerb(verb)
|
|
|| !ctx.TargetIsActor
|
|
|| string.IsNullOrEmpty(ctx.TargetName)
|
|
|| ctx.InCombat
|
|
|| ctx.HasAttackTarget
|
|
|| action.IndexOf("{target}", StringComparison.Ordinal) >= 0
|
|
|| action.IndexOf("{with}", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return " with {target}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rewrite singleton companion wording to the observed name; leave plural group wording alone.
|
|
/// </summary>
|
|
private static string BindSingletonCompanions(ActivityContext ctx, string action)
|
|
{
|
|
if (ctx == null
|
|
|| !ctx.TargetIsActor
|
|
|| string.IsNullOrEmpty(ctx.TargetName)
|
|
|| string.IsNullOrEmpty(action)
|
|
|| action.IndexOf("{target}", StringComparison.Ordinal) >= 0)
|
|
{
|
|
return action;
|
|
}
|
|
|
|
string t = action;
|
|
|
|
// Longest / most specific first. Do not rewrite "among the others", "nearby family",
|
|
// or "nearby group".
|
|
t = ReplaceIgnoreCase(t, "another's", "{target}'s");
|
|
t = ReplaceIgnoreCase(t, "another presence", "{target}");
|
|
t = ReplaceIgnoreCase(t, "nearby company", "{target}");
|
|
t = ReplaceIgnoreCase(t, "offers another", "offers {target}");
|
|
t = ReplaceIgnoreCase(t, "with another", "with {target}");
|
|
t = ReplaceIgnoreCase(t, "beside another", "beside {target}");
|
|
t = ReplaceIgnoreCase(t, "against another", "against {target}");
|
|
t = ReplaceIgnoreCase(t, "around another", "around {target}");
|
|
t = ReplaceIgnoreCase(t, "facing another", "facing {target}");
|
|
t = ReplaceIgnoreCase(t, "near another", "near {target}");
|
|
t = ReplaceIgnoreCase(t, "from another", "from {target}");
|
|
t = ReplaceIgnoreCase(t, " to another", " to {target}");
|
|
t = ReplaceIgnoreCase(t, "greets another", "greets {target}");
|
|
t = ReplaceIgnoreCase(t, "claps another", "claps {target}");
|
|
t = ReplaceIgnoreCase(t, "nuzzles another", "nuzzles {target}");
|
|
t = ReplaceIgnoreCase(t, "grooms another", "grooms {target}");
|
|
t = ReplaceIgnoreCase(t, "circles another", "circles {target}");
|
|
t = ReplaceIgnoreCase(t, "mirrors another", "mirrors {target}");
|
|
t = ReplaceIgnoreCase(t, "answers another", "answers {target}");
|
|
t = ReplaceIgnoreCase(t, "touches another", "touches {target}");
|
|
t = ReplaceIgnoreCase(t, "joins another", "joins {target}");
|
|
t = ReplaceIgnoreCase(t, "faces another", "faces {target}");
|
|
t = ReplaceIgnoreCase(t, "sits beside another", "sits beside {target}");
|
|
t = ReplaceIgnoreCase(t, "rests flank to flank beside another", "rests flank to flank beside {target}");
|
|
t = ReplaceIgnoreCase(t, "winds around another", "winds around {target}");
|
|
t = ReplaceIgnoreCase(t, "clucks back and forth with another", "clucks back and forth with {target}");
|
|
t = ReplaceIgnoreCase(t, "rumbles a low greeting to another", "rumbles a low greeting to {target}");
|
|
t = ReplaceIgnoreCase(t, "croaks a greeting to another", "croaks a greeting to {target}");
|
|
t = ReplaceIgnoreCase(t, "shoulder-bumps another", "shoulder-bumps {target}");
|
|
t = ReplaceIgnoreCase(t, "exchanges a sweeping bow with another", "exchanges a sweeping bow with {target}");
|
|
t = ReplaceIgnoreCase(t, "bows stiffly to another", "bows stiffly to {target}");
|
|
t = ReplaceIgnoreCase(t, "claps a loud welcome to another", "claps a loud welcome to {target}");
|
|
t = ReplaceIgnoreCase(t, "shares low whispers with another", "shares low whispers with {target}");
|
|
t = ReplaceIgnoreCase(t, "clasps forearms with another", "clasps forearms with {target}");
|
|
t = ReplaceIgnoreCase(t, "exchanges brief greetings with another", "exchanges brief greetings with {target}");
|
|
t = ReplaceIgnoreCase(t, "trades names and short updates with another", "trades names and short updates with {target}");
|
|
t = ReplaceIgnoreCase(t, "greets another with", "greets {target} with");
|
|
t = ReplaceIgnoreCase(t, " another a ", " {target} a ");
|
|
t = ReplaceIgnoreCase(t, " another on ", " {target} on ");
|
|
t = ReplaceIgnoreCase(t, " another with ", " {target} with ");
|
|
t = ReplaceIgnoreCase(t, " another in ", " {target} in ");
|
|
t = ReplaceIgnoreCase(t, " another during ", " {target} during ");
|
|
t = ReplaceIgnoreCase(t, " another while ", " {target} while ");
|
|
t = ReplaceIgnoreCase(t, " another between ", " {target} between ");
|
|
t = ReplaceIgnoreCase(t, " another and ", " {target} and ");
|
|
|
|
return t;
|
|
}
|
|
|
|
private static string BuildCarryingClause(ActivityContext ctx, string action)
|
|
{
|
|
if (ctx == null
|
|
|| string.IsNullOrEmpty(ctx.CarryingLabel)
|
|
|| action.IndexOf("{carrying}", StringComparison.Ordinal) >= 0
|
|
|| action.IndexOf(ctx.CarryingLabel, StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
if (ActionImpliesLoad(action))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return " while carrying {carrying}";
|
|
}
|
|
|
|
private static bool ActionImpliesLoad(string action)
|
|
{
|
|
if (string.IsNullOrEmpty(action))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (int i = 0; i < CargoImplicationWords.Length; i++)
|
|
{
|
|
if (action.IndexOf(CargoImplicationWords[i], StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static string BuildSubject(ActivityContext ctx)
|
|
{
|
|
string life = ctx != null && ctx.IsChild ? "young " : "";
|
|
// Species stays on the dossier nametag (Name (species)) - never "the angle" / "the dog" here.
|
|
return life + "{actor}";
|
|
}
|
|
|
|
private static string BuildPressureClause(ActivityContext ctx, string action)
|
|
{
|
|
if (!ctx.InCombat && !ctx.HasAttackTarget)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
// An action that already names the observed target or reads as combat-aware
|
|
// does not need a second, generic combat suffix.
|
|
if (action.IndexOf("{target}", StringComparison.Ordinal) >= 0
|
|
|| action.IndexOf("fight", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("clash", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("attack", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("blow", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("hunt", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("stalk", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("strike", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("close on", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
if (ctx.TargetIsActor && !string.IsNullOrEmpty(ctx.TargetName))
|
|
{
|
|
return " while keeping {target} in sight";
|
|
}
|
|
|
|
return " while danger presses close";
|
|
}
|
|
|
|
/// <summary>Drop species tokens / "the dog" leftovers - dossier already shows species.</summary>
|
|
private static string StripSpeciesTokens(string action, ActivityContext ctx)
|
|
{
|
|
if (string.IsNullOrEmpty(action))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string t = action;
|
|
t = t.Replace(" the {species}", "");
|
|
t = t.Replace("{species}", "");
|
|
|
|
if (ctx != null)
|
|
{
|
|
string word = !string.IsNullOrEmpty(ctx.SpeciesLabel)
|
|
? ctx.SpeciesLabel.Trim()
|
|
: (ctx.SpeciesId ?? "").Replace('_', ' ').Trim();
|
|
if (!string.IsNullOrEmpty(word) && word.Length > 1)
|
|
{
|
|
t = ReplaceIgnoreCase(t, " the " + word, "");
|
|
}
|
|
}
|
|
|
|
return t.Trim();
|
|
}
|
|
|
|
public static string StripSpeciesTokensPublic(string action, ActivityContext ctx)
|
|
{
|
|
return StripSpeciesTokens(action, ctx);
|
|
}
|
|
|
|
private static string ReplaceIgnoreCase(string hay, string needle, string replacement)
|
|
{
|
|
if (string.IsNullOrEmpty(hay) || string.IsNullOrEmpty(needle))
|
|
{
|
|
return hay;
|
|
}
|
|
|
|
int idx = hay.IndexOf(needle, StringComparison.OrdinalIgnoreCase);
|
|
if (idx < 0)
|
|
{
|
|
return hay;
|
|
}
|
|
|
|
return hay.Substring(0, idx) + replacement + hay.Substring(idx + needle.Length);
|
|
}
|
|
|
|
/// <summary>Public salt so Format can mix trait into polish pick without naming the trait.</summary>
|
|
public static long TraitVarietySaltPublic(ActivityContext ctx)
|
|
{
|
|
return TraitVarietySalt(ctx);
|
|
}
|
|
|
|
private static long TraitVarietySalt(ActivityContext ctx)
|
|
{
|
|
if (ctx == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
string id = ctx.TopTraitId;
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
id = ctx.TopTraitLabel;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(id))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
return id.GetHashCode();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Peel subject prefixes from celebrity/family templates so the assembler owns identity clauses.
|
|
/// </summary>
|
|
public static string StripSubject(string template)
|
|
{
|
|
if (string.IsNullOrEmpty(template))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
string t = template.Trim();
|
|
if (t.StartsWith("young ", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
t = t.Substring("young ".Length).TrimStart();
|
|
}
|
|
|
|
if (t.StartsWith("{actor} the {species}", StringComparison.Ordinal))
|
|
{
|
|
t = t.Substring("{actor} the {species}".Length).TrimStart();
|
|
}
|
|
else if (t.StartsWith("{actor}", StringComparison.Ordinal))
|
|
{
|
|
t = t.Substring("{actor}".Length).TrimStart();
|
|
// "{actor} the dog tumbles..." → "the dog tumbles..." → "tumbles..."
|
|
if (t.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
int sp = t.IndexOf(' ', 4);
|
|
if (sp > 4)
|
|
{
|
|
string after = t.Substring(sp).TrimStart();
|
|
if (!string.IsNullOrEmpty(after) && char.IsLower(after[0]))
|
|
{
|
|
t = after;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return t.Trim();
|
|
}
|
|
|
|
private static string StripToken(string text, string token)
|
|
{
|
|
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(token))
|
|
{
|
|
return text;
|
|
}
|
|
|
|
return text.Replace(token, "");
|
|
}
|
|
|
|
private static string CollapseSpaces(string t)
|
|
{
|
|
if (string.IsNullOrEmpty(t))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
while (t.IndexOf(" ", StringComparison.Ordinal) >= 0)
|
|
{
|
|
t = t.Replace(" ", " ");
|
|
}
|
|
|
|
t = t.Replace(" ,", ",");
|
|
t = t.Replace(",,", ",");
|
|
return t;
|
|
}
|
|
}
|