namespace IdleSpectator;
///
/// Fact-preserving activity sentence formatting with colored actor/target names.
/// supplies templates from the verb map and authored species bags.
///
public static class ActivityProse
{
///
/// Vanilla tip name-value purple - reads on dark dossier chrome and orange reason text.
///
public const string NameColorHex = HudTheme.NameColorHex;
public static string ColorName(string name)
{
if (string.IsNullOrEmpty(name))
{
return "";
}
return "" + name + "";
}
///
/// Wrap known person names in purple for orange reason beats (rest inherits ReasonColor).
/// Longer names first so partial overlaps do not corrupt replacements.
///
public static string ColorizePersonNames(string plain, params string[] names)
{
if (string.IsNullOrEmpty(plain) || names == null || names.Length == 0)
{
return plain ?? "";
}
var unique = new System.Collections.Generic.List();
for (int i = 0; i < names.Length; i++)
{
string n = names[i];
if (string.IsNullOrEmpty(n))
{
continue;
}
bool seen = false;
for (int j = 0; j < unique.Count; j++)
{
if (string.Equals(unique[j], n, System.StringComparison.Ordinal))
{
seen = true;
break;
}
}
if (!seen)
{
unique.Add(n);
}
}
unique.Sort((a, b) => b.Length.CompareTo(a.Length));
string result = plain;
for (int i = 0; i < unique.Count; i++)
{
string n = unique[i];
// Skip if already wrapped from a prior pass.
if (result.IndexOf(NameColorHex + ">" + n + "", System.StringComparison.Ordinal) >= 0)
{
continue;
}
result = ReplaceNameLiteral(result, n, ColorName(n));
}
return result;
}
private static string ReplaceNameLiteral(string haystack, string name, string rich)
{
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(name))
{
return haystack;
}
int idx = haystack.IndexOf(name, System.StringComparison.Ordinal);
if (idx < 0)
{
return haystack;
}
return haystack.Substring(0, idx) + rich + haystack.Substring(idx + name.Length);
}
public static void Format(
ActivityKind kind,
string key,
string actorName,
string targetName,
bool targetIsActor,
string placeOrFallback,
string rawFact,
long subjectId,
int lineIndex,
out string plain,
out string rich)
{
ActivityContext ctx = ActivityContext.ForHarness(
actorName,
targetIsActor ? targetName : "",
place: placeOrFallback);
if (!targetIsActor && !string.IsNullOrEmpty(targetName))
{
if (string.IsNullOrEmpty(ctx.PlaceLabel))
{
ctx.PlaceLabel = targetName;
}
ctx.PlaceIsObject = true;
}
if (targetIsActor)
{
ctx.TargetName = targetName ?? "";
ctx.TargetIsActor = !string.IsNullOrEmpty(targetName);
}
Format(kind, key, ctx, rawFact, subjectId, lineIndex, out plain, out rich);
}
public static void Format(
ActivityKind kind,
string key,
ActivityContext ctx,
string rawFact,
long subjectId,
int lineIndex,
out string plain,
out string rich)
{
plain = "";
rich = "";
if (ctx == null)
{
ctx = ActivityContext.Empty;
}
EnsureDerivedFields(ctx);
EnsureChildFromKey(ctx, key);
bool hasTarget = ctx.TargetIsActor && !string.IsNullOrEmpty(ctx.TargetName);
// Trait salts variety only (dossier shows trait chips) - never names the trait in prose.
long varietyId = subjectId ^ ActivityClauseAssembler.TraitVarietySaltPublic(ctx);
string template = ActivityClauseAssembler.Assemble(
ctx,
key,
rawFact,
hasTarget,
varietyId,
lineIndex);
// Unreachable by composer contract. Preserve empty output so missing authored coverage remains auditable.
if (string.IsNullOrEmpty(template))
{
return;
}
plain = Apply(template, ctx, colored: false);
rich = Apply(template, ctx, colored: true);
_ = kind;
}
private static void EnsureDerivedFields(ActivityContext ctx)
{
if (ctx.Voice == null
|| !ctx.Voice.LiveSpeciesId.Equals(ctx.SpeciesId ?? "", System.StringComparison.Ordinal))
{
ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(ctx.SpeciesId);
if (asset != null)
{
ActivityVoiceResolver.ApplyToContext(ctx, asset);
}
else
{
ctx.Voice = ActivityVoiceResolver.Resolve(ctx.SpeciesId);
ctx.BaseSpeciesId = ctx.Voice.BaseSpeciesId;
ctx.Family = ctx.Voice.BaseFamily;
ctx.MannerTag = ActivityVoiceResolver.TagName(ctx.Voice.EffectiveActionTag);
ctx.ModifierTag = ActivityVoiceResolver.ModifierName(ctx.Voice.Modifier);
}
}
if (string.IsNullOrEmpty(ctx.RoleLabel))
{
ctx.RoleLabel = ActivityInterestTable.DeriveRoleLabel(ctx.IsKing, ctx.IsLeader, ctx.IsWarrior);
}
if (string.IsNullOrEmpty(ctx.TopTraitLabel) && !string.IsNullOrEmpty(ctx.TopTraitId))
{
ctx.TopTraitLabel = ActivityInterestTable.TraitLabelFromId(ctx.TopTraitId);
}
}
/// Mark child life-stage from task key when context did not already capture it.
private static void EnsureChildFromKey(ActivityContext ctx, string key)
{
if (ctx == null || ctx.IsChild || string.IsNullOrEmpty(key))
{
return;
}
if (key.StartsWith("child_", System.StringComparison.OrdinalIgnoreCase))
{
ctx.IsChild = true;
}
}
public static string HumanizeJobPublic(string jobId)
{
// Live citizen_job library + Title Case id fallback.
return ActivityAssetCatalog.JobDisplayLabel(jobId);
}
///
/// Settlement / realm / wilds → " in X". Building/object → " at the X" (or " at a/an/the X").
///
public static string FormatPlaceAtClause(string place, bool placeIsObject)
{
if (string.IsNullOrEmpty(place))
{
return "";
}
string noun = place.Trim().Replace('_', ' ');
if (string.IsNullOrEmpty(noun))
{
return "";
}
if (placeIsObject)
{
if (HasLeadingArticle(noun))
{
return " at " + noun;
}
return " at the " + LowerFirst(noun);
}
return " in " + noun;
}
private static bool HasLeadingArticle(string noun)
{
if (string.IsNullOrEmpty(noun))
{
return false;
}
return noun.StartsWith("a ", System.StringComparison.OrdinalIgnoreCase)
|| noun.StartsWith("an ", System.StringComparison.OrdinalIgnoreCase)
|| noun.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase);
}
/// Legacy helper used by older call sites / harness.
public static string Format(
ActivityKind kind,
string key,
string rawFact,
string target,
long subjectId,
int lineIndex)
{
Format(
kind,
key,
ActivityContext.ForHarness("", target, place: target),
rawFact,
subjectId,
lineIndex,
out string plain,
out _);
return plain;
}
private static string Apply(string template, ActivityContext ctx, bool colored)
{
if (string.IsNullOrEmpty(template))
{
return "";
}
string actorRaw = string.IsNullOrEmpty(ctx.ActorName) ? "Someone" : ctx.ActorName;
string actor = colored ? ColorName(actorRaw) : actorRaw;
string targetRaw = ctx.TargetName ?? "";
string target = "";
if (!string.IsNullOrEmpty(targetRaw))
{
target = colored && ctx.TargetIsActor ? ColorName(targetRaw) : targetRaw;
}
string species = !string.IsNullOrEmpty(ctx.SpeciesLabel)
? ctx.SpeciesLabel
: (string.IsNullOrEmpty(ctx.SpeciesId) ? "creature" : ctx.SpeciesId.Replace('_', ' '));
string place = ctx.PlaceLabel ?? "";
if (ActivityInterestTable.LooksLikeSpeciesPublic(place, ctx.SpeciesId))
{
place = "";
}
string carrying = string.IsNullOrEmpty(ctx.CarryingLabel) ? "goods" : ctx.CarryingLabel;
string withClause = string.IsNullOrEmpty(target) ? "" : (" with " + target);
// Empty place → empty clause. Objects use "at the", settlements use "in".
string placeAt = FormatPlaceAtClause(place, ctx.PlaceIsObject);
string t = template;
t = t.Replace("{actor}", actor);
t = t.Replace("{species}", species);
t = t.Replace("{with}", withClause);
t = t.Replace("{place_at}", placeAt);
t = t.Replace("{job_as}", "");
t = t.Replace("{job}", "");
t = t.Replace("{carrying}", carrying);
t = t.Replace("{place}", string.IsNullOrEmpty(place) ? "town" : place);
if (string.IsNullOrEmpty(target))
{
t = t.Replace(" with {target}", "")
.Replace(" on {target}", "")
.Replace(" toward {target}", "")
.Replace(" after {target}", "")
.Replace(" at {target}", "")
.Replace(" against {target}", "")
.Replace(" before {target}", "")
.Replace(" beside {target}", "")
.Replace(" near {target}", "")
.Replace(" around {target}", "")
.Replace(" past {target}", "")
.Replace(" behind {target}", "")
.Replace(" under {target}", "")
.Replace(" over {target}", "")
.Replace(" across {target}", "")
.Replace(" from {target}", "")
.Replace(" into {target}", "")
.Replace(" upon {target}", "")
.Replace(" {target}", "")
.Replace("{target}", "someone");
}
else
{
t = t.Replace("{target}", target);
}
// Collapse leftover double spaces from empty optional clauses.
while (t.IndexOf(" ", System.StringComparison.Ordinal) >= 0)
{
t = t.Replace(" ", " ");
}
return t.Trim();
}
private static string LowerFirst(string s)
{
if (string.IsNullOrEmpty(s))
{
return s;
}
if (s.Length == 1)
{
return s.ToLowerInvariant();
}
return char.ToLowerInvariant(s[0]) + s.Substring(1);
}
}