387 lines
11 KiB
C#
387 lines
11 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 "";
|
|
}
|
|
|
|
if (action.IndexOf("fight", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("clash", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("attack", StringComparison.OrdinalIgnoreCase) >= 0
|
|
|| action.IndexOf("blow", StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
if (ctx.TargetIsActor && !string.IsNullOrEmpty(ctx.TargetName)
|
|
&& action.IndexOf("{target}", StringComparison.Ordinal) < 0)
|
|
{
|
|
return " while locked on {target}";
|
|
}
|
|
|
|
return " under combat pressure";
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|