worldbox-observer-mod/IdleSpectator/ActivityProse.cs
DazedAnon 0dfe3ab832 Add detailed activity prose for various actions in ActivityProse class
Expanded the ActivityProse class with new templates for actions such as laughing, singing, and playing. Enhanced the formatting logic to ensure proper sentence structure for activities, including mood and task labels. Updated AgentHarness to utilize the new prose formatting for activity sentences and adjusted test scenarios in HarnessScenarios to validate these changes. Incremented version in mod.json to reflect the update.
2026-07-14 23:48:53 -05:00

932 lines
25 KiB
C#

using System.Collections.Generic;
namespace IdleSpectator;
/// <summary>
/// Fact-preserving activity sentences with colored actor/target names.
/// </summary>
public static class ActivityProse
{
public const string NameColorHex = "#F0C14A";
/// <summary>
/// Templates use {actor} and optional {target}. Never invent outcomes.
/// </summary>
private static readonly Dictionary<string, string[]> Variants = new Dictionary<string, string[]>
{
["attack"] = new[]
{
"{actor} strikes at {target}",
"{actor} presses the attack on {target}",
"{actor} lunges toward {target}"
},
["fight"] = new[]
{
"{actor} fights {target}",
"{actor} clashes with {target}",
"{actor} trades blows with {target}"
},
["fighting"] = new[]
{
"{actor} fights {target}",
"{actor} is locked in combat with {target}",
"{actor} clashes with {target}"
},
["hunt"] = new[]
{
"{actor} hunts {target}",
"{actor} stalks {target}",
"{actor} closes on {target}"
},
["BehAttackActorHuntingTarget"] = new[]
{
"{actor} hunts {target}",
"{actor} strikes at {target}",
"{actor} closes on {target}"
},
["heal"] = new[]
{
"{actor} tends to {target}",
"{actor} heals {target}",
"{actor} binds {target}'s wounds"
},
["check_heal"] = new[]
{
"{actor} checks on wounds",
"{actor} looks over injuries",
"{actor} takes stock of pain"
},
["cure"] = new[]
{
"{actor} seeks a cure",
"{actor} works to recover",
"{actor} fights sickness"
},
["eat"] = new[]
{
"{actor} takes a meal",
"{actor} eats",
"{actor} breaks hunger"
},
["eating"] = new[]
{
"{actor} takes a meal",
"{actor} eats",
"{actor} breaks hunger"
},
["sleep"] = new[]
{
"{actor} sleeps",
"{actor} rests",
"{actor} settles in to sleep"
},
["decide_where_to_sleep"] = new[]
{
"{actor} looks for a place to sleep",
"{actor} decides where to rest",
"{actor} seeks shelter for the night"
},
["random_move"] = new[]
{
"{actor} wanders",
"{actor} roams nearby",
"{actor} drifts along"
},
["city_idle_walking"] = new[]
{
"{actor} walks the streets",
"{actor} strolls through town",
"{actor} paces the settlement"
},
["move"] = new[]
{
"{actor} is on the move",
"{actor} travels onward",
"{actor} heads out"
},
["pollinate"] = new[]
{
"{actor} dusts the blossom",
"{actor} works the flower",
"{actor} leaves pollen behind"
},
["BehPollinate"] = new[]
{
"{actor} dusts the blossom",
"{actor} works the flower",
"{actor} leaves pollen behind"
},
["farm"] = new[]
{
"{actor} tends the fields",
"{actor} works the soil",
"{actor} farms the plot"
},
["harvest"] = new[]
{
"{actor} harvests the crop",
"{actor} gathers the yield",
"{actor} cuts the ripe fields"
},
["forag"] = new[]
{
"{actor} forages",
"{actor} gathers wild food",
"{actor} searches for forage"
},
["gather"] = new[]
{
"{actor} gathers stores",
"{actor} collects goods",
"{actor} forages supplies"
},
["mine"] = new[]
{
"{actor} delves the mine",
"{actor} chips at ore",
"{actor} works the dig"
},
["build"] = new[]
{
"{actor} builds",
"{actor} raises walls",
"{actor} works the construction"
},
["woodcut"] = new[]
{
"{actor} fells timber",
"{actor} cuts wood",
"{actor} takes an axe to the trees"
},
["fire"] = new[]
{
"{actor} fights the blaze",
"{actor} douses flames",
"{actor} races the fire"
},
["BehCityActorRemoveFire"] = new[]
{
"{actor} fights the blaze",
"{actor} douses flames",
"{actor} beats back the fire"
},
["social"] = new[]
{
"{actor} talks with {target}",
"{actor} socializes with {target}",
"{actor} converses with {target}"
},
["socialize"] = new[]
{
"{actor} talks with {target}",
"{actor} socializes with {target}",
"{actor} joins company near {target}"
},
["lover"] = new[]
{
"{actor} seeks out {target}",
"{actor} is drawn to {target}",
"{actor} pursues {target}'s affection"
},
["breed"] = new[]
{
"{actor} tries to start a family with {target}",
"{actor} courts {target}",
"{actor} seeks offspring with {target}"
},
["sexual_reproduction"] = new[]
{
"{actor} tries to start a family with {target}",
"{actor} courts {target}",
"{actor} seeks offspring with {target}"
},
["unload"] = new[]
{
"{actor} unloads goods in town",
"{actor} delivers the haul",
"{actor} empties the pack at home"
},
["BehUnloadResources"] = new[]
{
"{actor} unloads goods in town",
"{actor} delivers the haul",
"{actor} empties the pack at home"
},
["job"] = new[]
{
"{actor} seeks work",
"{actor} looks for a job",
"{actor} searches for employment"
},
["find_city_job"] = new[]
{
"{actor} seeks work in town",
"{actor} looks for a city job",
"{actor} searches for employment"
},
["find_house"] = new[]
{
"{actor} looks for a house",
"{actor} seeks a home",
"{actor} searches for shelter"
},
["claim"] = new[]
{
"{actor} claims land",
"{actor} stakes a claim",
"{actor} takes claim of ground"
},
["warrior"] = new[]
{
"{actor} follows the army",
"{actor} marches with the warband",
"{actor} keeps formation"
},
["army"] = new[]
{
"{actor} follows the army",
"{actor} marches with the warband",
"{actor} keeps formation"
},
["wait"] = new[]
{
"{actor} waits",
"{actor} holds still",
"{actor} bides time"
},
["make_decision"] = new[]
{
"{actor} considers next steps",
"{actor} weighs a choice",
"{actor} pauses in thought"
},
["reflection"] = new[]
{
"{actor} reflects",
"{actor} takes a quiet moment",
"{actor} thinks things over"
},
["run_away"] = new[]
{
"{actor} flees",
"{actor} runs for safety",
"{actor} retreats"
},
["trade"] = new[]
{
"{actor} trades",
"{actor} barters goods",
"{actor} is on a trade run"
},
["fish"] = new[]
{
"{actor} fishes",
"{actor} casts for fish",
"{actor} works the waters"
},
["family_group"] = new[]
{
"{actor} joins the herd",
"{actor} seeks the family group",
"{actor} gathers with kin"
},
["generate_loot"] = new[]
{
"{actor} gathers loot",
"{actor} claims spoils",
"{actor} picks through loot"
},
["laughing"] = new[]
{
"{actor} laughs",
"{actor} bursts into laughter",
"{actor} is laughing"
},
["happy_laughing"] = new[]
{
"{actor} laughs with joy",
"{actor} is laughing happily",
"{actor} chuckles aloud"
},
["just_laughed"] = new[]
{
"{actor} just finished laughing",
"{actor} settles after a laugh",
"{actor} catches their breath from laughing"
},
["singing"] = new[]
{
"{actor} sings",
"{actor} lifts a song",
"{actor} is singing"
},
["task_unit_play"] = new[]
{
"{actor} plays",
"{actor} is at play",
"{actor} amuses themselves"
},
["just_played"] = new[]
{
"{actor} just finished playing",
"{actor} wraps up playtime",
"{actor} steps away from play"
},
["child_play_at_one_spot"] = new[]
{
"{actor} plays in place",
"{actor} keeps busy with play",
"{actor} amuses themselves on the spot"
},
["child_random_flips"] = new[]
{
"{actor} flips about",
"{actor} tumbles in play",
"{actor} shows off flips"
},
["child_random_jump"] = new[]
{
"{actor} jumps about",
"{actor} leaps in play",
"{actor} bounces around"
},
["child_follow_parent"] = new[]
{
"{actor} follows a parent",
"{actor} stays close to {target}",
"{actor} trails after kin"
},
["random_fun_move"] = new[]
{
"{actor} frolics about",
"{actor} moves for fun",
"{actor} skips along playfully"
},
["godfinger_random_fun_move"] = new[]
{
"{actor} frolics under divine whim",
"{actor} is nudged into playful motion",
"{actor} moves about for fun"
},
["try_to_read"] = new[]
{
"{actor} tries to read",
"{actor} opens a book",
"{actor} settles in to read"
},
["try_to_poop"] = new[]
{
"{actor} looks for a private moment",
"{actor} tends to nature's call",
"{actor} steps aside briefly"
},
["try_to_launch_fireworks"] = new[]
{
"{actor} tries to launch fireworks",
"{actor} readies a firework",
"{actor} prepares a celebration blast"
}
};
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)
{
plain = "";
rich = "";
string actor = string.IsNullOrEmpty(actorName) ? "Someone" : actorName;
string targetPlain = ResolveTargetPlain(targetName, targetIsActor, placeOrFallback);
bool hasTarget = !string.IsNullOrEmpty(targetPlain);
string template = PickTemplate(key, subjectId, lineIndex, hasTarget);
if (string.IsNullOrEmpty(template))
{
template = PatternTemplate(key, hasTarget);
}
if (string.IsNullOrEmpty(template))
{
template = LocalizedTemplate(rawFact, key, hasTarget);
}
if (string.IsNullOrEmpty(template))
{
template = "{actor} " + LowerFirst(HumanizeKey(key));
if (string.IsNullOrEmpty(key) && !string.IsNullOrEmpty(rawFact))
{
template = GerundSentence(rawFact);
}
}
plain = Apply(template, actor, targetPlain);
rich = Apply(
template,
ColorName(actor),
targetIsActor && !string.IsNullOrEmpty(targetName)
? ColorName(targetName)
: targetPlain);
}
/// <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,
actorName: "",
targetName: target,
targetIsActor: !string.IsNullOrEmpty(target),
placeOrFallback: target,
rawFact: rawFact,
subjectId: subjectId,
lineIndex: lineIndex,
out string plain,
out _);
return plain;
}
private static string ResolveTargetPlain(string targetName, bool targetIsActor, string placeOrFallback)
{
if (!string.IsNullOrEmpty(targetName))
{
return targetName;
}
if (!string.IsNullOrEmpty(placeOrFallback))
{
return placeOrFallback;
}
return "";
}
private static string Apply(string template, string actor, string target)
{
string t = template.Replace("{actor}", actor ?? "");
if (string.IsNullOrEmpty(target))
{
// Drop dangling " with {target}" style if target missing - templates should
// prefer no-target variants via PickTemplate, but scrub leftovers.
t = t.Replace(" with {target}", "")
.Replace(" on {target}", "")
.Replace(" toward {target}", "")
.Replace(" at {target}", "")
.Replace(" {target}", "")
.Replace("{target}", "someone");
}
else
{
t = t.Replace("{target}", target);
}
return t.Trim();
}
private static string PatternTemplate(string key, bool hasTarget)
{
if (string.IsNullOrEmpty(key))
{
return null;
}
string k = key.ToLowerInvariant();
if (k.StartsWith("try_to_"))
{
return "{actor} tries to " + HumanizeKey(k.Substring("try_to_".Length));
}
if (k.StartsWith("check_"))
{
return "{actor} checks " + HumanizeKey(k.Substring("check_".Length));
}
if (k.StartsWith("find_"))
{
return "{actor} looks for " + HumanizeKey(k.Substring("find_".Length));
}
if (k.StartsWith("decide_"))
{
return "{actor} decides " + HumanizeKey(k.Substring("decide_".Length));
}
if (k.StartsWith("build_"))
{
return "{actor} builds " + HumanizeKey(k.Substring("build_".Length));
}
if (k.StartsWith("boat_"))
{
return "{actor} " + BoatVerb(k.Substring("boat_".Length));
}
if (k.StartsWith("warrior_"))
{
return WarriorTemplate(k, hasTarget);
}
if (k.StartsWith("socialize_"))
{
return hasTarget
? "{actor} socializes with {target}"
: "{actor} socializes";
}
if (k.StartsWith("sexual_reproduction") || k.StartsWith("asexual_reproduction"))
{
return hasTarget
? "{actor} tries to start a family with {target}"
: "{actor} tries to reproduce";
}
if (k.StartsWith("child_"))
{
return "{actor} " + LowerFirst(HumanizeKey(k.Substring("child_".Length)));
}
if (k.StartsWith("ant_") || k.StartsWith("bee_") || k.StartsWith("ufo_")
|| k.StartsWith("dragon_") || k.StartsWith("worm_"))
{
return "{actor} " + LowerFirst(HumanizeKey(StripCreaturePrefix(k)));
}
if (k.StartsWith("task_unit_"))
{
return LocalizedStyleFromId(k.Substring("task_unit_".Length), hasTarget);
}
if (k.Contains("laugh"))
{
return "{actor} laughs";
}
if (k.Contains("sing"))
{
return "{actor} sings";
}
if (k.Contains("play") && !k.Contains("display"))
{
return hasTarget ? "{actor} plays with {target}" : "{actor} plays";
}
if (k.Contains("wait"))
{
return "{actor} waits";
}
if (k.Contains("sleep"))
{
return "{actor} sleeps";
}
if (k.Contains("idle") || k.Contains("random_move") || k.EndsWith("_move")
|| k.Contains("walking"))
{
return "{actor} wanders";
}
if (k.Contains("fight") || k.Contains("attack") || k.Contains("hunt"))
{
return hasTarget ? "{actor} strikes at {target}" : "{actor} fights";
}
if (k.Contains("heal") || k.Contains("cure"))
{
return hasTarget ? "{actor} tends to {target}" : "{actor} tends to wounds";
}
if (k.Contains("eat") || k.Contains("food"))
{
return "{actor} seeks a meal";
}
if (k.Contains("farm") || k.Contains("harvest") || k.Contains("forag")
|| k.Contains("gather") || k.Contains("chop") || k.Contains("mine"))
{
return "{actor} " + LowerFirst(HumanizeKey(k));
}
return null;
}
private static string WarriorTemplate(string k, bool hasTarget)
{
if (k.Contains("follow"))
{
return hasTarget ? "{actor} follows {target}" : "{actor} follows the army";
}
if (k.Contains("train"))
{
return "{actor} trains for war";
}
if (k.Contains("join"))
{
return "{actor} tries to join the army";
}
if (k.Contains("attack"))
{
return hasTarget ? "{actor} marches on {target}" : "{actor} marches to attack";
}
if (k.Contains("wait") || k.Contains("idle"))
{
return "{actor} holds formation";
}
return "{actor} " + LowerFirst(HumanizeKey(k.Replace("warrior_", "")));
}
private static string BoatVerb(string rest)
{
if (rest.Contains("fish"))
{
return "fishes from the boat";
}
if (rest.Contains("trade"))
{
return "trades by boat";
}
if (rest.Contains("dock") || rest.Contains("return"))
{
return "returns to dock";
}
if (rest.Contains("load"))
{
return "loads the boat";
}
if (rest.Contains("unload"))
{
return "unloads the boat";
}
if (rest.Contains("idle"))
{
return "idles aboard";
}
return LowerFirst(HumanizeKey(rest)) + " aboard";
}
private static string StripCreaturePrefix(string k)
{
int idx = k.IndexOf('_');
return idx > 0 && idx < k.Length - 1 ? k.Substring(idx + 1) : k;
}
private static string LocalizedStyleFromId(string rest, bool hasTarget)
{
if (rest == "play")
{
return "{actor} plays";
}
if (rest == "wait")
{
return "{actor} waits";
}
if (rest == "walk")
{
return "{actor} walks";
}
if (rest.Contains("social"))
{
return hasTarget ? "{actor} socializes with {target}" : "{actor} socializes";
}
return "{actor} " + LowerFirst(HumanizeKey(rest));
}
/// <summary>
/// Turn game localized labels like "Laughing" / "Job Seeking" into real sentences.
/// </summary>
private static string LocalizedTemplate(string rawFact, string key, bool hasTarget)
{
string loc = ExtractLocalizedBody(rawFact, key);
if (string.IsNullOrEmpty(loc))
{
return null;
}
return GerundSentence(loc);
}
private static string ExtractLocalizedBody(string rawFact, string key)
{
if (string.IsNullOrEmpty(rawFact))
{
return null;
}
string body = rawFact;
int arrow = body.IndexOf('→');
if (arrow < 0)
{
arrow = body.IndexOf("->", System.StringComparison.Ordinal);
}
if (arrow >= 0)
{
body = body.Substring(0, arrow).Trim();
}
if (body.StartsWith("Task:", System.StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Ignore if it's just the raw key.
if (!string.IsNullOrEmpty(key)
&& body.Equals(key, System.StringComparison.OrdinalIgnoreCase))
{
return null;
}
return body.Trim();
}
private static string GerundSentence(string loc)
{
if (string.IsNullOrEmpty(loc))
{
return null;
}
string body = loc.Trim();
// "Laughing" / "Job Seeking" / "Holding Still"
if (IsGerundPhrase(body))
{
return "{actor} is " + LowerFirst(body);
}
// Already a short verb phrase
return "{actor} " + LowerFirst(body);
}
private static bool IsGerundPhrase(string body)
{
if (string.IsNullOrEmpty(body))
{
return false;
}
string[] parts = body.Split(new[] { ' ' }, System.StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0 || parts.Length > 4)
{
return false;
}
// Last token ends with -ing (Laughing, Seeking, Holding...)
string last = parts[parts.Length - 1];
return last.Length > 4
&& last.EndsWith("ing", System.StringComparison.OrdinalIgnoreCase);
}
private static string HumanizeKey(string key)
{
if (string.IsNullOrEmpty(key))
{
return "";
}
string s = key.Replace("Beh", "").Replace('_', ' ').Trim();
// Collapse common noise words for readability.
s = s.Replace("task unit ", "").Replace("type ", "");
return 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);
}
private static string PickTemplate(string key, long subjectId, int lineIndex, bool hasTarget)
{
if (string.IsNullOrEmpty(key))
{
return null;
}
string[] bag = null;
if (Variants.TryGetValue(key, out bag) && bag != null && bag.Length > 0)
{
return PreferTargetAware(bag, hasTarget, subjectId, key, lineIndex);
}
string lower = key.ToLowerInvariant();
string bestKey = null;
string[] bestBag = null;
int bestLen = -1;
foreach (KeyValuePair<string, string[]> kv in Variants)
{
if (kv.Value == null || kv.Value.Length == 0)
{
continue;
}
if (lower.IndexOf(kv.Key, System.StringComparison.Ordinal) >= 0 && kv.Key.Length > bestLen)
{
bestLen = kv.Key.Length;
bestKey = kv.Key;
bestBag = kv.Value;
}
}
if (bestBag == null)
{
return null;
}
return PreferTargetAware(bestBag, hasTarget, subjectId, bestKey, lineIndex);
}
private static string PreferTargetAware(
string[] bag,
bool hasTarget,
long subjectId,
string key,
int lineIndex)
{
var suited = new List<string>(bag.Length);
for (int i = 0; i < bag.Length; i++)
{
string line = bag[i];
bool needsTarget = line.IndexOf("{target}", System.StringComparison.Ordinal) >= 0;
if (hasTarget || !needsTarget)
{
suited.Add(line);
}
}
if (suited.Count == 0)
{
suited.AddRange(bag);
}
return suited[PickIndex(subjectId, key, lineIndex, suited.Count)];
}
private static int PickIndex(long subjectId, string key, int lineIndex, int count)
{
if (count <= 1)
{
return 0;
}
unchecked
{
int h = (int)(subjectId * 397) ^ (key?.GetHashCode() ?? 0) ^ (lineIndex * 31);
h ^= (int)(UnityEngine.Time.unscaledTime * 10f);
if (h < 0)
{
h = -h;
}
return h % count;
}
}
}