- Add related unit tracking for activity logs and dossiers - Implement colorized names for related units in reason lines - Update harness scenarios to validate new interactions - Increment version to 0.26.1 in mod.json
380 lines
12 KiB
C#
380 lines
12 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
|
|
{
|
|
/// <summary>
|
|
/// Bright lemon for person names - must read clearly on dark dossier chrome and
|
|
/// against orange reason text (<c>#F2B861</c>).
|
|
/// </summary>
|
|
public const string NameColorHex = "#FFE566";
|
|
|
|
public static string ColorName(string name)
|
|
{
|
|
if (string.IsNullOrEmpty(name))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return "<b><color=" + NameColorHex + ">" + name + "</color></b>";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wrap known person names in gold for orange reason beats (rest inherits ReasonColor).
|
|
/// Longer names first so partial overlaps do not corrupt replacements.
|
|
/// </summary>
|
|
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<string>();
|
|
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 + "</color>", 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);
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
// Live citizen_job library + Title Case id fallback.
|
|
return ActivityAssetCatalog.JobDisplayLabel(jobId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Settlement / realm / wilds → " in X". Building/object → " at the X" (or " at a/an/the X").
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <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. 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);
|
|
}
|
|
}
|