worldbox-observer-mod/IdleSpectator/ActivityClauseAssembler.cs

393 lines
12 KiB
C#

using System;
namespace IdleSpectator;
/// <summary>
/// Assembles one alive activity sentence from observed <see cref="ActivityContext"/> clauses.
/// Packs observed slots (life, identity, role/job, act, target, carrying, place, combat).
/// Traits salt wording variety only - never named in the line (dossier already shows them).
/// </summary>
public static class ActivityClauseAssembler
{
/// <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);
bool placeInAppositive = false;
string subject = BuildSubject(ctx, action, out placeInAppositive);
if (placeInAppositive)
{
action = StripToken(action, "{place_at}");
action = StripToken(action, "{job_as}");
action = action.Replace(" at {place}", "");
action = action.Replace(" into store{place}", " into store");
action = StripToken(action, "{place}");
}
else
{
// Job already in appositive → drop trailing job_as; keep place_at on the verb.
if (HasJobOrRole(ctx))
{
action = StripToken(action, "{job_as}");
}
}
// Traits stay on the dossier chips - they only salt variety below, never named here.
string pressure = BuildPressureClause(ctx, action);
string line = subject + " " + action + pressure;
return CollapseSpaces(line).Trim();
}
private static string BuildSubject(ActivityContext ctx, string action, out bool placeInAppositive)
{
placeInAppositive = false;
string life = ctx.IsChild ? "young " : "";
// Species stays on the dossier nametag (Name (species)) - never "the angle" / "the dog" here.
// Taxonomy still drives MannerTag variety in the action phrase.
string identity = "{actor}";
string appositive = BuildAppositive(ctx, action, out placeInAppositive);
if (string.IsNullOrEmpty(appositive))
{
return life + identity;
}
return life + identity + ", " + appositive + ",";
}
private static string BuildAppositive(ActivityContext ctx, string action, out bool placeInAppositive)
{
placeInAppositive = false;
string roleOrJob = RoleOrJobNoun(ctx);
string place = SafePlace(ctx);
// Traits are dossier chrome - never spell them in the activity line appositive.
if (string.IsNullOrEmpty(roleOrJob))
{
return "";
}
// Already named in the action (rare polish) - skip duplicate appositive.
if (action.IndexOf(roleOrJob, StringComparison.OrdinalIgnoreCase) >= 0)
{
return "";
}
string head = "a " + roleOrJob;
bool actionHasPlace = action.IndexOf("{place", StringComparison.Ordinal) >= 0
|| (!string.IsNullOrEmpty(place)
&& action.IndexOf(place, StringComparison.OrdinalIgnoreCase) >= 0);
if (!string.IsNullOrEmpty(place) && !actionHasPlace)
{
placeInAppositive = true;
return head + " of " + place;
}
return head;
}
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";
}
private static bool HasJobOrRole(ActivityContext ctx)
{
return !string.IsNullOrEmpty(RoleOrJobNoun(ctx));
}
private static string StripUnusedTargetClauses(string action)
{
if (string.IsNullOrEmpty(action))
{
return "";
}
string t = action;
t = t.Replace(" with {target}", "");
t = t.Replace(" on {target}", "");
t = t.Replace(" toward {target}", "");
t = t.Replace(" after {target}", "");
t = t.Replace(" at {target}", "");
t = t.Replace(" {target}", "");
t = t.Replace("{target}", "");
return t.Trim();
}
/// <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);
}
private static string RoleOrJobNoun(ActivityContext ctx)
{
if (!string.IsNullOrEmpty(ctx.RoleLabel))
{
return ctx.RoleLabel.Trim().ToLowerInvariant();
}
return ActivityProse.HumanizeJobPublic(ctx.JobId);
}
/// <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();
}
private static string SafePlace(ActivityContext ctx)
{
string place = ctx.PlaceLabel ?? "";
if (string.IsNullOrEmpty(place))
{
return "";
}
if (ActivityInterestTable.LooksLikeSpeciesPublic(place, ctx.SpeciesId))
{
return "";
}
return place.Trim();
}
/// <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 bool LooksLikeActionPhrase(string phrase)
{
if (string.IsNullOrEmpty(phrase))
{
return false;
}
// Reject if it still looks like a full subject line.
if (phrase.StartsWith("{actor}", StringComparison.Ordinal))
{
return false;
}
// Measure substance without optional clause tokens - "{actor} plays{place_at}"
// must not count as a living action once place is empty.
string substance = phrase
.Replace("{place_at}", "")
.Replace("{job_as}", "")
.Replace("{with}", "")
.Replace("{place}", "")
.Replace("{job}", "")
.Replace("{target}", "")
.Replace("{carrying}", "")
.Replace("{species}", "")
.Trim();
while (substance.IndexOf(" ", StringComparison.Ordinal) >= 0)
{
substance = substance.Replace(" ", " ");
}
if (substance.Length < 12)
{
return false;
}
// Single-token leftovers like "plays" / "waits" are not alive enough.
if (substance.IndexOf(' ') < 0)
{
return false;
}
return true;
}
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;
}
private static string LowerFirst(string s)
{
if (string.IsNullOrEmpty(s))
{
return "";
}
if (s.Length == 1)
{
return s.ToLowerInvariant();
}
return char.ToLowerInvariant(s[0]) + s.Substring(1);
}
}