worldbox-observer-mod/IdleSpectator/ActivityClauseAssembler.cs

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;
}
}