worldbox-observer-mod/IdleSpectator/ActivityProse.cs

276 lines
8.4 KiB
C#

namespace IdleSpectator;
/// <summary>
/// Fact-preserving activity sentence formatting with colored actor/target names.
/// <see cref="ActivityClauseAssembler"/> supplies templates from the verb map and authored species bags.
/// </summary>
public static class ActivityProse
{
public const string NameColorHex = "#F0C14A";
public static string ColorName(string name)
{
if (string.IsNullOrEmpty(name))
{
return "";
}
return "<color=" + NameColorHex + ">" + name + "</color>";
}
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) && string.IsNullOrEmpty(ctx.PlaceLabel))
{
ctx.PlaceLabel = targetName;
}
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);
}
}
/// <summary>Mark child life-stage from task key when context did not already capture it.</summary>
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)
{
return HumanizeJob(jobId);
}
/// <summary>Legacy helper used by older call sites / harness.</summary>
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 (no forced "in town"). Job never appears in Activity.
string placeAt = string.IsNullOrEmpty(place) ? "" : (" in " + place);
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 HumanizeJob(string jobId)
{
if (string.IsNullOrEmpty(jobId))
{
return "";
}
string s = jobId.Replace('_', ' ').Trim();
if (s.StartsWith("job ", System.StringComparison.OrdinalIgnoreCase))
{
s = s.Substring(4).Trim();
}
return LowerFirst(s);
}
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);
}
}