diff --git a/IdleSpectator/ActivityActionComposer.cs b/IdleSpectator/ActivityActionComposer.cs
new file mode 100644
index 0000000..67f4726
--- /dev/null
+++ b/IdleSpectator/ActivityActionComposer.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections.Generic;
+
+namespace IdleSpectator;
+
+/// Single deterministic action-selection pipeline.
+public static class ActivityActionComposer
+{
+ private static readonly Dictionary BeatTemplates =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["BehUnloadResources"] = new[]
+ {
+ "unloads {carrying} at {place}",
+ "delivers {carrying} into store{place_at}"
+ },
+ ["BehThrowResources"] = new[]
+ {
+ "throws {carrying} toward storage",
+ "tosses {carrying} from the pack"
+ },
+ ["BehSocializeTalk"] = new[]
+ {
+ "talks{with}{place_at}",
+ "shares words{with}{place_at}"
+ },
+ ["BehTryToSocialize"] = new[]
+ {
+ "tries to socialize{with}{place_at}",
+ "looks for a conversation{with}{place_at}"
+ },
+ ["BehPlantCrops"] = new[]
+ {
+ "plants crops{place_at}{job_as}",
+ "sets seed in the soil{place_at}"
+ },
+ ["BehCityActorFertilizeCrop"] = new[]
+ {
+ "fertilizes the crops{place_at}{job_as}",
+ "feeds the soil{place_at}"
+ },
+ ["BehBuildTarget"] = new[]
+ {
+ "works the build{place_at}{job_as}",
+ "raises construction{place_at}"
+ },
+ ["BehPoopOutside"] = new[]
+ {
+ "steps aside for a private moment outdoors",
+ "tends to nature's call outside"
+ },
+ ["BehPoopInside"] = new[]
+ {
+ "steps aside for a private moment indoors",
+ "tends to nature's call inside"
+ },
+ ["BehBoatFishing"] = new[]
+ {
+ "fishes from the boat{place_at}",
+ "works the waters from aboard{place_at}"
+ },
+ ["BehBoatCollectFish"] = new[]
+ {
+ "collects the catch aboard{place_at}",
+ "hauls in the boat's catch{place_at}"
+ },
+ ["BehClaimZoneForCityActorBorder"] = new[]
+ {
+ "claims border land{place_at}",
+ "stakes new ground for the settlement{place_at}"
+ }
+ };
+
+ public static string Compose(
+ ActivityContext ctx,
+ string key,
+ string rawFact,
+ bool hasTarget,
+ long subjectId,
+ int lineIndex)
+ {
+ ctx ??= ActivityContext.Empty;
+ ProseVoice voice = EnsureVoice(ctx);
+
+ if (!string.IsNullOrEmpty(key)
+ && BeatTemplates.TryGetValue(key, out string[] beatBag)
+ && voice.AssetKind != ActivityAssetKind.Vehicle)
+ {
+ return Pick(beatBag, voice, key, subjectId, lineIndex);
+ }
+
+ string verb = ActivityVerbMap.Resolve(key);
+ if (string.IsNullOrEmpty(verb))
+ {
+ string factual = ActivityProse.FactualActionPhrase(rawFact, key, hasTarget);
+ factual = ActivityClauseAssembler.StripSpeciesTokensPublic(factual, ctx);
+ if (!string.IsNullOrEmpty(factual))
+ {
+ return factual;
+ }
+
+ string patterned = ActivityProse.PatternActionPhrase(key, hasTarget);
+ if (!string.IsNullOrEmpty(patterned))
+ {
+ return patterned;
+ }
+
+ string humanized = ActivityProse.HumanizeKeyPublic(key);
+ verb = string.IsNullOrEmpty(humanized) ? "move" : humanized.ToLowerInvariant();
+ }
+
+ string[] bag = ActivityActionLexicon.GetBag(voice, verb, hasTarget);
+ return Pick(bag, voice, verb, subjectId, lineIndex);
+ }
+
+ private static ProseVoice EnsureVoice(ActivityContext ctx)
+ {
+ if (ctx.Voice != null
+ && ctx.Voice.LiveSpeciesId.Equals(ctx.SpeciesId ?? "", StringComparison.Ordinal))
+ {
+ return ctx.Voice;
+ }
+
+ ProseVoice voice = ActivityVoiceResolver.Resolve(ctx.SpeciesId);
+ ctx.Voice = voice;
+ ctx.BaseSpeciesId = voice.BaseSpeciesId;
+ ctx.ModifierTag = ActivityVoiceResolver.ModifierName(voice.Modifier);
+ ctx.Family = voice.BaseFamily;
+ ctx.MannerTag = ActivityVoiceResolver.TagName(voice.EffectiveActionTag);
+ ctx.IsCiv = voice.BaseIsCiv;
+ ctx.IsHumanoid = voice.BaseIsHumanoid;
+ ctx.IsAnimal = voice.BaseIsAnimal;
+ return voice;
+ }
+
+ private static string Pick(
+ string[] bag,
+ ProseVoice voice,
+ string verb,
+ long subjectId,
+ int lineIndex)
+ {
+ if (bag == null || bag.Length == 0)
+ {
+ return "moves through the living moment{place_at}{job_as}";
+ }
+
+ if (bag.Length == 1)
+ {
+ return bag[0];
+ }
+
+ int slot = VerbSlot(verb);
+ int bit = voice.AssetKind == ActivityAssetKind.Vehicle
+ ? 0
+ : ((voice.FlavorOrdinal >> slot) & 1);
+ int actorVariation = (int)((subjectId ^ (lineIndex * 397L)) & 1L);
+ int index = (bit ^ actorVariation) % Math.Min(2, bag.Length);
+ return bag[index];
+ }
+
+ private static int VerbSlot(string verb)
+ {
+ switch ((verb ?? "").ToLowerInvariant())
+ {
+ case "move": return 0;
+ case "wait": return 1;
+ case "play": return 2;
+ case "eat": return 3;
+ case "sleep": return 4;
+ case "hunt": return 5;
+ case "fight": return 6;
+ case "social": return 7;
+ case "laugh": return 8;
+ default: return 9;
+ }
+ }
+}
diff --git a/IdleSpectator/ActivityActionLexicon.cs b/IdleSpectator/ActivityActionLexicon.cs
new file mode 100644
index 0000000..f387228
--- /dev/null
+++ b/IdleSpectator/ActivityActionLexicon.cs
@@ -0,0 +1,370 @@
+using System;
+
+namespace IdleSpectator;
+
+/// Phrase fragments keyed by the already-resolved voice. No ActorAsset lookup.
+public static class ActivityActionLexicon
+{
+ public static string[] GetBag(ProseVoice voice, string verb, bool hasTarget)
+ {
+ voice ??= ProseVoice.Unknown();
+ string v = string.IsNullOrEmpty(verb) ? "move" : verb;
+
+ if (voice.AssetKind == ActivityAssetKind.Vehicle)
+ {
+ return VehicleBag(v);
+ }
+
+ if (voice.Modifier != ActivityModifier.None)
+ {
+ string[] modifier = ModifierBag(voice, v, hasTarget);
+ if (modifier != null) return modifier;
+ }
+
+ string[] profile = ProfileBag(voice.ProfileTag, v, hasTarget);
+ if (profile != null) return profile;
+
+ string[] body = BodyBag(voice.BaseBodyTag, v, hasTarget);
+ return body ?? GenericBag(v, hasTarget);
+ }
+
+ private static string[] ModifierBag(ProseVoice voice, string verb, bool hasTarget)
+ {
+ switch (voice.Modifier)
+ {
+ case ActivityModifier.Undead:
+ return UndeadBag(voice.BaseBodyTag, verb, hasTarget);
+ case ActivityModifier.Elemental:
+ return verb switch
+ {
+ "move" => new[] { "surges onward in a living blaze{place_at}", "flows ahead with elemental force{place_at}" },
+ "wait" => new[] { "holds in a wavering elemental pause{place_at}", "burns steadily while waiting{place_at}" },
+ "play" => new[] { "flares through a wild game{place_at}", "dances in playful elemental arcs{place_at}" },
+ "eat" => new[] { "feeds the inner flame{place_at}", "draws in fuel with bright appetite{place_at}" },
+ "sleep" => new[] { "dims into a banked rest{place_at}", "settles into a low elemental glow{place_at}" },
+ "hunt" => Targeted(hasTarget, "surges after {target}{place_at}", "hunts {target} in a trail of heat{place_at}", "searches for prey in flickering sweeps{place_at}", "hunts with elemental focus{place_at}"),
+ "fight" => Targeted(hasTarget, "crashes into {target} with elemental force{place_at}", "flares against {target}{place_at}", "fights in a storm of living heat{place_at}", "clashes with elemental fury{place_at}"),
+ "laugh" => new[] { "crackles with bright laughter{place_at}", "flares in a laughing burst{place_at}" },
+ _ => GenericBag(verb, hasTarget)
+ };
+ case ActivityModifier.Mush:
+ return verb switch
+ {
+ "move" => new[] { "shuffles onward in soft fungal steps{place_at}", "spreads along with mycelial patience{place_at}" },
+ "wait" => new[] { "rests in spore-soft quiet{place_at}", "waits while faint spores drift{place_at}" },
+ "play" => new[] { "puffs through an odd little game{place_at}", "spores about in playful motion{place_at}" },
+ "eat" => new[] { "feeds through the damp earth{place_at}", "draws a slow meal from decay{place_at}" },
+ "sleep" => new[] { "settles into a damp fungal rest{place_at}", "folds into mycelial quiet{place_at}" },
+ _ => GenericBag(verb, hasTarget)
+ };
+ case ActivityModifier.Tumor:
+ return verb switch
+ {
+ "move" => new[] { "pulses onward in malignant motion{place_at}", "lurches ahead with invasive purpose{place_at}" },
+ "wait" => new[] { "throbs in unsettling stillness{place_at}", "holds a malignant pause{place_at}" },
+ "play" => new[] { "writhes through a grotesque game{place_at}", "pulses with uncanny playfulness{place_at}" },
+ "eat" => new[] { "feeds with consuming hunger{place_at}", "absorbs a meal into the growing mass{place_at}" },
+ "fight" => Targeted(hasTarget, "lashes into {target} with malignant force{place_at}", "surges against {target}{place_at}", "fights with invasive fury{place_at}", "clashes in a malignant rush{place_at}"),
+ _ => GenericBag(verb, hasTarget)
+ };
+ case ActivityModifier.Alien:
+ return verb switch
+ {
+ "move" => new[] { "glides onward with unfamiliar ease{place_at}", "tracks ahead in precise alien motion{place_at}" },
+ "wait" => new[] { "observes in unreadable stillness{place_at}", "holds an unfamiliar watch{place_at}" },
+ "play" => new[] { "probes the moment in curious play{place_at}", "experiments with a strange little game{place_at}" },
+ "hunt" => Targeted(hasTarget, "tracks {target} with alien focus{place_at}", "closes on {target} in eerie silence{place_at}", "scans for prey{place_at}", "hunts by unfamiliar instinct{place_at}"),
+ "fight" => Targeted(hasTarget, "strikes at {target} with alien precision{place_at}", "clashes with {target} in strange motion{place_at}", "fights with unreadable purpose{place_at}", "attacks in a precise rush{place_at}"),
+ _ => GenericBag(verb, hasTarget)
+ };
+ case ActivityModifier.Construct:
+ return verb switch
+ {
+ "move" => new[] { "advances in measured constructed steps{place_at}", "shifts onward with deliberate precision{place_at}" },
+ "wait" => new[] { "idles in rigid watchfulness{place_at}", "holds a perfectly measured pause{place_at}" },
+ "play" => new[] { "tests a playful sequence of motions{place_at}", "turns precise movement into a game{place_at}" },
+ "eat" => new[] { "takes in fuel with mechanical care{place_at}", "replenishes what keeps it moving{place_at}" },
+ "sleep" => new[] { "powers down into quiet rest{place_at}", "settles into a dormant stillness{place_at}" },
+ _ => GenericBag(verb, hasTarget)
+ };
+ default:
+ return null;
+ }
+ }
+
+ private static string[] UndeadBag(ActivityBodyTag body, string verb, bool hasTarget)
+ {
+ if (verb == "move")
+ {
+ return body switch
+ {
+ ActivityBodyTag.Canine => new[] { "pads onward with lifeless purpose{place_at}", "sniffs and shambles through the living world{place_at}" },
+ ActivityBodyTag.Feline => new[] { "slinks onward in deathly quiet{place_at}", "pads ahead with hollow patience{place_at}" },
+ ActivityBodyTag.Insect => new[] { "skitters onward in deathly little steps{place_at}", "crawls ahead with hollow purpose{place_at}" },
+ ActivityBodyTag.Plant => new[] { "creeps onward in withered motion{place_at}", "drags dead growth through the living world{place_at}" },
+ ActivityBodyTag.Dragon => new[] { "lumbers onward with ancient deathly weight{place_at}", "advances like ruin given motion{place_at}" },
+ ActivityBodyTag.Bird => new[] { "flutters onward on lifeless wings{place_at}", "hops ahead in ragged deathly motion{place_at}" },
+ _ => new[] { "shambles onward through the living world{place_at}", "drifts along in restless undeath{place_at}" }
+ };
+ }
+
+ return verb switch
+ {
+ "wait" => new[] { "waits in hollow stillness{place_at}", "holds a restless undeath pause{place_at}" },
+ "play" => new[] { "lurches through a strange game{place_at}", "toys with the moment in hollow cheer{place_at}" },
+ "eat" => new[] { "feeds with deathless hunger{place_at}", "tears into a meal with hollow appetite{place_at}" },
+ "sleep" => new[] { "slumps into a hollow rest{place_at}", "goes still in restless undeath{place_at}" },
+ "hunt" => Targeted(hasTarget, "shambles after {target}{place_at}", "hunts {target} with hollow hunger{place_at}", "hunts with deathless hunger{place_at}", "searches for prey without rest{place_at}"),
+ "fight" => Targeted(hasTarget, "presses against {target} without fear{place_at}", "clashes with {target} in deathly fury{place_at}", "fights with hollow force{place_at}", "presses on without giving ground{place_at}"),
+ "laugh" => new[] { "groans in a broken laugh{place_at}", "makes a hollow sound of laughter{place_at}" },
+ "social" => Targeted(hasTarget, "lingers near {target} in uneasy silence{place_at}", "shares a hollow moment with {target}{place_at}", "seeks company in deathly quiet{place_at}", "lingers among the living{place_at}"),
+ _ => GenericBag(verb, hasTarget)
+ };
+ }
+
+ private static string[] ProfileBag(string profile, string verb, bool hasTarget)
+ {
+ if (string.IsNullOrEmpty(profile)) return null;
+ if (profile == "dragon")
+ {
+ return verb switch
+ {
+ "move" => new[] { "advances with ancient patience{place_at}", "moves as if the world should make way{place_at}" },
+ "sleep" => new[] { "coils into a guarded sleep{place_at}", "rests with one eye on the world{place_at}" },
+ "eat" => new[] { "feeds with terrifying appetite{place_at}", "takes a meal with ancient hunger{place_at}" },
+ "laugh" => new[] { "rumbles with deep laughter{place_at}", "laughs in a dangerous rolling growl{place_at}" },
+ "hunt" => Targeted(hasTarget, "descends on {target}{place_at}", "hunts {target} with ancient focus{place_at}", "circles in search of prey{place_at}", "hunts with ancient appetite{place_at}"),
+ _ => null
+ };
+ }
+
+ if (profile == "fairy")
+ {
+ return verb switch
+ {
+ "move" => new[] { "flits onward in bright little arcs{place_at}", "drifts ahead on glittering wings{place_at}" },
+ "play" => new[] { "dances through a sparkling game{place_at}", "flutters about with mischievous delight{place_at}" },
+ "laugh" => new[] { "chimes with tiny laughter{place_at}", "laughs in a bright little trill{place_at}" },
+ _ => null
+ };
+ }
+
+ if (profile == "lemon_snail")
+ {
+ return verb switch
+ {
+ "move" => new[] { "glides onward with patient citrus calm{place_at}", "eases ahead in a slow leafy trail{place_at}" },
+ "play" => new[] { "loops through a patient little game{place_at}", "sways into slow, cheerful play{place_at}" },
+ "eat" => new[] { "feeds with quiet citrus appetite{place_at}", "takes a slow, careful meal{place_at}" },
+ _ => null
+ };
+ }
+
+ if (profile == "cat")
+ {
+ return verb switch
+ {
+ "move" => new[] { "pads softly through the living world{place_at}", "slinks along with private purpose{place_at}" },
+ "play" => new[] { "bats and twists through a private game{place_at}", "springs into sudden playful leaps{place_at}" },
+ _ => null
+ };
+ }
+
+ if (profile == "buffalo")
+ {
+ return verb switch
+ {
+ "move" => new[] { "trudges onward with grounded strength{place_at}", "ambles ahead in heavy steady steps{place_at}" },
+ "play" => new[] { "frolics with heavy earnest energy{place_at}", "stomps through a clumsy game{place_at}" },
+ _ => null
+ };
+ }
+
+ if (profile == "crabzilla")
+ {
+ return verb switch
+ {
+ "move" => new[] { "strides sideways with colossal purpose{place_at}", "scuttles onward like a moving fortress{place_at}" },
+ "fight" => Targeted(hasTarget, "brings a colossal claw against {target}{place_at}", "crashes into {target} like a living siege{place_at}", "fights with city-shaking force{place_at}", "clashes like a living fortress{place_at}"),
+ _ => null
+ };
+ }
+
+ return null;
+ }
+
+ private static string[] BodyBag(ActivityBodyTag body, string verb, bool hasTarget)
+ {
+ if (verb == "move")
+ {
+ return body switch
+ {
+ ActivityBodyTag.Humanoid => new[] { "walks onward with quiet purpose{place_at}", "wanders ahead at an easy pace{place_at}" },
+ ActivityBodyTag.Canine => new[] { "trots along without hurry{place_at}", "roams ahead with curious paws{place_at}" },
+ ActivityBodyTag.Feline => new[] { "pads softly through the living world{place_at}", "slinks along at an easy pace{place_at}" },
+ ActivityBodyTag.Bird => new[] { "flutters onward through the air{place_at}", "hops and wings along nearby{place_at}" },
+ ActivityBodyTag.Insect => new[] { "scuttles along without hurry{place_at}", "skitters across the ground{place_at}" },
+ ActivityBodyTag.Arachnid => new[] { "skitters onward on many legs{place_at}", "creeps ahead with careful steps{place_at}" },
+ ActivityBodyTag.Amphibian => new[] { "hops onward through the living world{place_at}", "bounces along in damp springy steps{place_at}" },
+ ActivityBodyTag.Aquatic => new[] { "glides through the water{place_at}", "swims onward with fluid ease{place_at}" },
+ ActivityBodyTag.Reptile => new[] { "slides along without hurry{place_at}", "creeps forward with cool patience{place_at}" },
+ ActivityBodyTag.Livestock => new[] { "ambles along without hurry{place_at}", "wanders with heavy steady steps{place_at}" },
+ ActivityBodyTag.Plant => new[] { "creeps along with patient plant motion{place_at}", "eases forward in slow leafy steps{place_at}" },
+ ActivityBodyTag.Fungi => new[] { "drifts along in soft fungal steps{place_at}", "spreads onward with patient motion{place_at}" },
+ ActivityBodyTag.Protist => new[] { "oozes onward without hurry{place_at}", "pulses along through the living world{place_at}" },
+ ActivityBodyTag.Machina => new[] { "advances in measured machine steps{place_at}", "tracks onward with precise motion{place_at}" },
+ ActivityBodyTag.Mythic => new[] { "drifts along with mythic calm{place_at}", "moves with otherworldly purpose{place_at}" },
+ ActivityBodyTag.Gregalia => new[] { "gregs onward through the living world{place_at}", "wanders in restless greg motion{place_at}" },
+ ActivityBodyTag.Primate => new[] { "lopes onward with nimble curiosity{place_at}", "moves ahead in quick clever steps{place_at}" },
+ ActivityBodyTag.Rodent => new[] { "scurries onward without hurry{place_at}", "pads ahead in quick little steps{place_at}" },
+ ActivityBodyTag.Ursid => new[] { "lumbers onward with steady weight{place_at}", "pads ahead in heavy unhurried steps{place_at}" },
+ ActivityBodyTag.Seal => new[] { "slides onward with sleek ease{place_at}", "glides ahead through the wet paths{place_at}" },
+ ActivityBodyTag.Procyonid => new[] { "pads onward with busy curiosity{place_at}", "scampers ahead with clever purpose{place_at}" },
+ ActivityBodyTag.Hyaenid => new[] { "lopes onward with rough confidence{place_at}", "trots ahead in rangy strides{place_at}" },
+ ActivityBodyTag.Armadillo => new[] { "trundles onward close to the ground{place_at}", "scurries ahead in armored little steps{place_at}" },
+ ActivityBodyTag.Crawler => new[] { "writhes onward through the living world{place_at}", "crawls ahead without hurry{place_at}" },
+ ActivityBodyTag.Cold => new[] { "moves onward in a breath of cold{place_at}", "drifts ahead with wintry calm{place_at}" },
+ ActivityBodyTag.Crystal => new[] { "glides onward with crystalline precision{place_at}", "moves ahead in bright rigid steps{place_at}" },
+ ActivityBodyTag.Crab => new[] { "scuttles sideways through the living world{place_at}", "sidesteps onward with armored purpose{place_at}" },
+ _ => GenericBag(verb, hasTarget)
+ };
+ }
+
+ if (verb == "play")
+ {
+ return body switch
+ {
+ ActivityBodyTag.Canine => new[] { "tumbles about in lively play{place_at}", "bounds and wrestles through play{place_at}" },
+ ActivityBodyTag.Feline => new[] { "bats and twists through a private game{place_at}", "plays with sudden leaps{place_at}" },
+ ActivityBodyTag.Bird => new[] { "flutters through playful arcs{place_at}", "hops and wings about in play{place_at}" },
+ ActivityBodyTag.Insect => new[] { "scuttles about in tiny play{place_at}", "darts through a quick little game{place_at}" },
+ ActivityBodyTag.Arachnid => new[] { "skitters through a restless game{place_at}", "toys with the moment on many legs{place_at}" },
+ ActivityBodyTag.Amphibian => new[] { "hops about in damp play{place_at}", "bounces through a lively game{place_at}" },
+ ActivityBodyTag.Aquatic => new[] { "splashes through play in the water{place_at}", "weaves playful arcs through the wet{place_at}" },
+ ActivityBodyTag.Reptile => new[] { "slides through a low game{place_at}", "plays with slow coiled energy{place_at}" },
+ ActivityBodyTag.Livestock => new[] { "stomps about in clumsy play{place_at}", "frolics with heavy earnest energy{place_at}" },
+ ActivityBodyTag.Plant => new[] { "sways through a slow game{place_at}", "turns the moment into leafy play{place_at}" },
+ ActivityBodyTag.Primate => new[] { "clambers through a clever game{place_at}", "plays with nimble mischief{place_at}" },
+ ActivityBodyTag.Rodent => new[] { "scampers through a tiny game{place_at}", "darts about in quick play{place_at}" },
+ ActivityBodyTag.Ursid => new[] { "rolls through a heavy game{place_at}", "plays with lumbering cheer{place_at}" },
+ ActivityBodyTag.Crawler => new[] { "loops through a low little game{place_at}", "writhes about in restless play{place_at}" },
+ _ => new[] { "makes a game of the moment{place_at}", "plays with easy living energy{place_at}" }
+ };
+ }
+
+ if (verb == "wait")
+ {
+ return body switch
+ {
+ ActivityBodyTag.Insect => new[] { "holds still in a tiny pause{place_at}", "waits with antennae alert{place_at}" },
+ ActivityBodyTag.Amphibian => new[] { "crouches in a damp pause{place_at}", "waits in springy stillness{place_at}" },
+ ActivityBodyTag.Feline => new[] { "settles into a watchful crouch{place_at}", "waits with quiet patience{place_at}" },
+ ActivityBodyTag.Canine => new[] { "waits with alert ears{place_at}", "holds a restless pause{place_at}" },
+ ActivityBodyTag.Aquatic => new[] { "hovers in the water and waits{place_at}", "holds still in the wet quiet{place_at}" },
+ ActivityBodyTag.Bird => new[] { "perches and waits{place_at}", "holds a light watchful pause{place_at}" },
+ ActivityBodyTag.Plant => new[] { "holds a rooted pause{place_at}", "waits in leafy quiet{place_at}" },
+ ActivityBodyTag.Machina => new[] { "idles in a watchful hold{place_at}", "waits through a measured pause{place_at}" },
+ _ => new[] { "waits in watchful quiet{place_at}", "holds still through a living pause{place_at}" }
+ };
+ }
+
+ if (verb == "hunt")
+ {
+ return body switch
+ {
+ ActivityBodyTag.Canine => Targeted(hasTarget, "hunts {target} with keen nose{place_at}", "closes on {target} through the living world{place_at}", "hunts with keen nose{place_at}", "stalks prey through the living world{place_at}"),
+ ActivityBodyTag.Feline => Targeted(hasTarget, "hunts {target} with silent focus{place_at}", "stalks {target} with patient care{place_at}", "hunts with silent focus{place_at}", "stalks prey with quiet patience{place_at}"),
+ ActivityBodyTag.Bird => Targeted(hasTarget, "hunts {target} from above{place_at}", "circles after {target}{place_at}", "hunts from above{place_at}", "circles after prey{place_at}"),
+ ActivityBodyTag.Insect => Targeted(hasTarget, "scuttles after {target}{place_at}", "hunts {target} with tiny focus{place_at}", "hunts with tiny predatory focus{place_at}", "scuttles after prey{place_at}"),
+ ActivityBodyTag.Reptile => Targeted(hasTarget, "slides after {target}{place_at}", "hunts {target} with cold patience{place_at}", "hunts with cold patience{place_at}", "stalks prey in a low slide{place_at}"),
+ _ => Targeted(hasTarget, "hunts {target} through the living world{place_at}", "closes on {target} with careful focus{place_at}", "hunts through the living world{place_at}", "stalks prey with careful steps{place_at}")
+ };
+ }
+
+ return verb switch
+ {
+ "fight" => Targeted(hasTarget, "fights {target} in a fierce clash{place_at}", "trades blows with {target}{place_at}", "fights with fierce resolve{place_at}", "clashes without giving ground{place_at}"),
+ "eat" => body switch
+ {
+ ActivityBodyTag.Canine => new[] { "eats with eager appetite{place_at}", "tears into a meal{place_at}" },
+ ActivityBodyTag.Feline => new[] { "eats with neat focus{place_at}", "takes a careful meal{place_at}" },
+ ActivityBodyTag.Insect => new[] { "feeds with tiny industry{place_at}", "eats in quick little bites{place_at}" },
+ ActivityBodyTag.Plant => new[] { "drinks in a slow meal{place_at}", "feeds with quiet plant appetite{place_at}" },
+ ActivityBodyTag.Livestock => new[] { "grazes with steady appetite{place_at}", "settles in to feed{place_at}" },
+ _ => new[] { "settles in to take a proper meal{place_at}", "eats with focused appetite{place_at}" }
+ },
+ "sleep" => body switch
+ {
+ ActivityBodyTag.Canine => new[] { "curls into sleep{place_at}", "settles into deep rest{place_at}" },
+ ActivityBodyTag.Feline => new[] { "curls into a neat sleep{place_at}", "settles into quiet rest{place_at}" },
+ ActivityBodyTag.Bird => new[] { "tucks into sleep{place_at}", "roosts into rest{place_at}" },
+ ActivityBodyTag.Aquatic => new[] { "drifts into rest in the water{place_at}", "settles into wet quiet{place_at}" },
+ ActivityBodyTag.Plant => new[] { "settles into leafy rest{place_at}", "goes still in plant quiet{place_at}" },
+ _ => new[] { "settles into deep sleep{place_at}", "rests after the day's demands{place_at}" }
+ },
+ "social" => Targeted(hasTarget, "shares a living moment with {target}{place_at}", "talks and lingers with {target}{place_at}", "seeks company nearby{place_at}", "socializes among whoever is near{place_at}"),
+ "laugh" => new[] { "laughs aloud and lets the mood carry{place_at}", "breaks into open laughter{place_at}" },
+ "sing" => body == ActivityBodyTag.Bird
+ ? new[] { "sings out on light wings{place_at}", "fills the air with bright song{place_at}" }
+ : new[] { "sings out for whoever will hear{place_at}", "lifts a living song{place_at}" },
+ _ => GenericBag(verb, hasTarget)
+ };
+ }
+
+ private static string[] VehicleBag(string verb)
+ {
+ return verb switch
+ {
+ "move" => new[] { "cuts steadily across the water{place_at}", "sails onward with the current{place_at}" },
+ "wait" => new[] { "rides quietly at rest on the water{place_at}", "holds position on the gentle swell{place_at}" },
+ "fish" => new[] { "works the water for fish{place_at}", "casts for a catch from the deck{place_at}" },
+ "trade" => new[] { "carries trade across the water{place_at}", "makes a measured trading run{place_at}" },
+ "haul" => new[] { "takes cargo aboard{place_at}", "settles the load for passage{place_at}" },
+ _ => new[] { "works steadily upon the water{place_at}", "continues its voyage with quiet purpose{place_at}" }
+ };
+ }
+
+ private static string[] GenericBag(string verb, bool hasTarget)
+ {
+ return verb switch
+ {
+ "farm" => new[] { "tends the fields with patient care{place_at}{job_as}", "works the soil through the living day{place_at}{job_as}" },
+ "haul" => new[] { "unloads {carrying} after a long haul{place_at}{job_as}", "delivers {carrying} into storage{place_at}" },
+ "pollinate" => new[] { "works the blossom with careful industry{place_at}", "pollinates the flower in a living rhythm{place_at}" },
+ "fish" => new[] { "works the water for fish{place_at}{job_as}", "fishes with patient focus{place_at}" },
+ "heal" => Targeted(hasTarget, "tends carefully to {target}{place_at}", "works to heal {target}{place_at}", "tends wounds with careful hands{place_at}", "works patiently to recover{place_at}"),
+ "fire" => new[] { "fights the blaze with urgent care{place_at}", "battles fire before it spreads{place_at}" },
+ "flee" => new[] { "flees from danger in a living rush{place_at}", "retreats before danger closes in{place_at}" },
+ "work" => new[] { "works with focused craft{place_at}{job_as}", "labors through the task at hand{place_at}{job_as}" },
+ "trade" => new[] { "trades and bargains{place_at}{job_as}", "haggles through a careful deal{place_at}" },
+ "settle" => new[] { "settles civic business{place_at}{job_as}", "attends to belonging and place{place_at}" },
+ "warrior" => Targeted(hasTarget, "holds formation against {target}{place_at}", "drills for war near {target}{place_at}", "holds warrior discipline{place_at}", "keeps formation ready{place_at}"),
+ "read" => new[] { "reads with quiet attention{place_at}", "studies the page{place_at}{job_as}" },
+ "lover" => Targeted(hasTarget, "draws close to {target}{place_at}", "shares a tender moment with {target}{place_at}", "follows the pull of kinship{place_at}", "seeks a living bond{place_at}"),
+ "gather_life" => new[] { "gathers with kin{place_at}", "moves among the group{place_at}" },
+ "move" => new[] { "wanders without hurry through the living world{place_at}", "roams nearby with quiet purpose{place_at}" },
+ "wait" => new[] { "waits in watchful quiet{place_at}", "holds still through a living pause{place_at}" },
+ "play" => new[] { "plays with restless living energy{place_at}", "turns the moment into a game{place_at}" },
+ "eat" => new[] { "settles in to take a proper meal{place_at}", "eats with focused appetite{place_at}" },
+ "sleep" => new[] { "settles into deep sleep{place_at}", "rests after the day's demands{place_at}" },
+ "hunt" => Targeted(hasTarget, "hunts {target} through the living world{place_at}", "closes on {target} with careful focus{place_at}", "hunts through the living world{place_at}", "stalks prey with careful steps{place_at}"),
+ "fight" => Targeted(hasTarget, "fights {target} in a fierce clash{place_at}", "trades blows with {target}{place_at}", "fights with fierce resolve{place_at}", "clashes without giving ground{place_at}"),
+ "social" => Targeted(hasTarget, "shares a living moment with {target}{place_at}", "talks and lingers with {target}{place_at}", "seeks company nearby{place_at}", "socializes among whoever is near{place_at}"),
+ _ => hasTarget
+ ? new[] { "sets about " + verb.Replace('_', ' ') + " with {target}{place_at}", "turns to " + verb.Replace('_', ' ') + " near {target}{place_at}" }
+ : new[] { "sets about " + verb.Replace('_', ' ') + " through the living moment{place_at}", "turns to " + verb.Replace('_', ' ') + " with quiet purpose{place_at}" }
+ };
+ }
+
+ private static string[] Targeted(
+ bool hasTarget,
+ string withTargetA,
+ string withTargetB,
+ string noTargetA,
+ string noTargetB)
+ {
+ return hasTarget
+ ? new[] { withTargetA, withTargetB }
+ : new[] { noTargetA, noTargetB };
+ }
+}
diff --git a/IdleSpectator/ActivityAssetCatalog.cs b/IdleSpectator/ActivityAssetCatalog.cs
new file mode 100644
index 0000000..b8491b6
--- /dev/null
+++ b/IdleSpectator/ActivityAssetCatalog.cs
@@ -0,0 +1,212 @@
+using System;
+using System.Collections.Generic;
+
+namespace IdleSpectator;
+
+/// Live asset/task lookup and species-token hygiene. No voice classification.
+public static class ActivityAssetCatalog
+{
+ private static HashSet _liveSpeciesPlaceTokens;
+ private static int _liveSpeciesPlaceTokensCount = -1;
+
+ public static ActorAsset TryGetActorAsset(string speciesId)
+ {
+ if (string.IsNullOrEmpty(speciesId))
+ {
+ return null;
+ }
+
+ try
+ {
+ return AssetManager.actor_library?.get(speciesId.Trim());
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public static bool IsLiveActorAssetId(string speciesId)
+ {
+ if (string.IsNullOrEmpty(speciesId))
+ {
+ return false;
+ }
+
+ try
+ {
+ return AssetManager.actor_library != null
+ && AssetManager.actor_library.has(speciesId.Trim());
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public static string SpeciesDisplayLabel(ActorAsset asset)
+ {
+ if (asset == null)
+ {
+ return "";
+ }
+
+ try
+ {
+ string translated = asset.getTranslatedName();
+ if (!string.IsNullOrEmpty(translated))
+ {
+ string localeId = asset.getLocaleID() ?? "";
+ if (!translated.Equals(localeId, StringComparison.Ordinal)
+ && translated.IndexOf('_') < 0)
+ {
+ return translated.Trim().ToLowerInvariant();
+ }
+ }
+ }
+ catch
+ {
+ // Localization may not be ready during early harness setup.
+ }
+
+ if (!string.IsNullOrEmpty(asset.name_locale))
+ {
+ return asset.name_locale.Trim().ToLowerInvariant();
+ }
+
+ return string.IsNullOrEmpty(asset.id) ? "" : asset.id.Replace('_', ' ').Trim();
+ }
+
+ public static string SpeciesDisplayLabel(string speciesId)
+ {
+ ActorAsset asset = TryGetActorAsset(speciesId);
+ return asset != null
+ ? SpeciesDisplayLabel(asset)
+ : (string.IsNullOrEmpty(speciesId) ? "" : speciesId.Replace('_', ' ').Trim());
+ }
+
+ public static List EnumerateLiveActorAssetIds()
+ {
+ var ids = new List();
+ try
+ {
+ ActorAssetLibrary lib = AssetManager.actor_library;
+ if (lib?.list == null)
+ {
+ return ids;
+ }
+
+ for (int i = 0; i < lib.list.Count; i++)
+ {
+ ActorAsset asset = lib.list[i];
+ if (asset != null && !string.IsNullOrEmpty(asset.id))
+ {
+ ids.Add(asset.id);
+ }
+ }
+ }
+ catch
+ {
+ // Library can be unavailable during early boot.
+ }
+
+ return ids;
+ }
+
+ public static List EnumerateLiveTaskIds()
+ {
+ var ids = new List();
+ try
+ {
+ object lib = typeof(AssetManager).GetField("tasks_actor")?.GetValue(null);
+ object listObj = lib?.GetType().GetField("list")?.GetValue(lib)
+ ?? lib?.GetType().GetProperty("list")?.GetValue(lib, null);
+ System.Collections.IList list = listObj as System.Collections.IList;
+ if (list == null)
+ {
+ return ids;
+ }
+
+ for (int i = 0; i < list.Count; i++)
+ {
+ object task = list[i];
+ object idObj = task?.GetType().GetField("id")?.GetValue(task)
+ ?? task?.GetType().GetProperty("id")?.GetValue(task, null);
+ string id = idObj as string;
+ if (!string.IsNullOrEmpty(id))
+ {
+ ids.Add(id);
+ }
+ }
+ }
+ catch
+ {
+ // Library can be unavailable during early boot.
+ }
+
+ return ids;
+ }
+
+ public static bool IsLiveSpeciesPlaceToken(string label)
+ {
+ if (string.IsNullOrEmpty(label))
+ {
+ return false;
+ }
+
+ EnsureLiveSpeciesPlaceTokens();
+ return _liveSpeciesPlaceTokens != null
+ && _liveSpeciesPlaceTokens.Contains(label.Trim());
+ }
+
+ private static void EnsureLiveSpeciesPlaceTokens()
+ {
+ try
+ {
+ ActorAssetLibrary lib = AssetManager.actor_library;
+ if (lib?.list == null)
+ {
+ return;
+ }
+
+ int count = lib.list.Count;
+ if (_liveSpeciesPlaceTokens != null && _liveSpeciesPlaceTokensCount == count)
+ {
+ return;
+ }
+
+ var tokens = new HashSet(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < count; i++)
+ {
+ ActorAsset asset = lib.list[i];
+ if (asset == null)
+ {
+ continue;
+ }
+
+ Add(tokens, asset.id);
+ Add(tokens, asset.kingdom_id_wild);
+ Add(tokens, asset.name_locale);
+ if (!string.IsNullOrEmpty(asset.id))
+ {
+ Add(tokens, asset.id.Replace('_', ' '));
+ }
+ }
+
+ _liveSpeciesPlaceTokens = tokens;
+ _liveSpeciesPlaceTokensCount = count;
+ }
+ catch
+ {
+ // Keep prior cache if any.
+ }
+ }
+
+ private static void Add(HashSet tokens, string value)
+ {
+ if (!string.IsNullOrEmpty(value))
+ {
+ tokens.Add(value.Trim());
+ }
+ }
+}
diff --git a/IdleSpectator/ActivityClauseAssembler.cs b/IdleSpectator/ActivityClauseAssembler.cs
new file mode 100644
index 0000000..05b5a38
--- /dev/null
+++ b/IdleSpectator/ActivityClauseAssembler.cs
@@ -0,0 +1,387 @@
+using System;
+
+namespace IdleSpectator;
+
+///
+/// Assembles one alive activity sentence from observed 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).
+///
+public static class ActivityClauseAssembler
+{
+ ///
+ /// Build a template still containing tokens for .
+ /// Optional is celebrity/family/beat flavor - still enriched.
+ ///
+ 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();
+ }
+
+ /// Drop species tokens / "the dog" leftovers - dossier already shows species.
+ 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);
+ }
+
+ /// Public salt so Format can mix trait into polish pick without naming the trait.
+ 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();
+ }
+
+ ///
+ /// Peel subject prefixes from celebrity/family templates so the assembler owns identity clauses.
+ ///
+ 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);
+ }
+}
diff --git a/IdleSpectator/ActivityContext.cs b/IdleSpectator/ActivityContext.cs
new file mode 100644
index 0000000..0ff6a22
--- /dev/null
+++ b/IdleSpectator/ActivityContext.cs
@@ -0,0 +1,128 @@
+namespace IdleSpectator;
+
+///
+/// Snapshot of live actor facts for activity prose. Only observed fields - never invent events.
+/// Species / taxonomy / flags come from for every unit in the game.
+///
+public sealed class ActivityContext
+{
+ public string ActorName = "";
+ /// Raw actor asset id (frog, human, beetle, ...).
+ public string SpeciesId = "";
+ /// Recovered living/body source for meaningful transformed variants.
+ public string BaseSpeciesId = "";
+ /// Readable species word for prose (locale / asset id).
+ public string SpeciesLabel = "";
+ public string Family = ActivityVoiceFamilies.Default;
+ public string ModifierTag = "";
+ public ProseVoice Voice;
+ public string TaxonomicKingdom = "";
+ public string TaxonomicPhylum = "";
+ public string TaxonomicClass = "";
+ public string TaxonomicOrder = "";
+ public string TaxonomicFamily = "";
+ public string TaxonomicGenus = "";
+ public string TaxonomicSpecies = "";
+ public bool IsHumanoid;
+ public bool IsCiv;
+ /// From - animal tone when not civ humanoid.
+ public bool IsAnimal;
+ public int Age;
+ public bool IsChild;
+ public string JobId = "";
+ public string CityName = "";
+ public string KingdomName = "";
+ public bool IsKing;
+ public bool IsLeader;
+ public bool IsWarrior;
+ /// Observed role noun: king / leader / warrior when flags are true.
+ public string RoleLabel = "";
+ public string TargetName = "";
+ public bool TargetIsActor;
+ /// Building, city, kingdom, or soft place label for clauses.
+ public string PlaceLabel = "";
+ public string CarryingLabel = "";
+ public bool InCombat;
+ public bool HasAttackTarget;
+ /// Observed trait id - salts prose variety only; never spelled in the activity line.
+ public string TopTraitId = "";
+ /// Humanized trait label for salt/debug - dossier chips show the trait to the player.
+ public string TopTraitLabel = "";
+ /// Locomotion / body manner from live taxonomy/flags (insect, amphibian, flying, ...).
+ public string MannerTag = "default";
+ public string TaskKey = "";
+
+ public static ActivityContext Empty { get; } = new ActivityContext();
+
+ /// Harness / ForceNote: minimal context; species resolved from live library when present.
+ public static ActivityContext ForHarness(
+ string actorName,
+ string targetName,
+ string speciesId = "",
+ string place = "",
+ string carrying = "",
+ string jobId = "",
+ string taskKey = "",
+ string traitId = "",
+ bool isChild = false,
+ bool inCombat = false,
+ bool isKing = false,
+ bool isLeader = false,
+ bool isWarrior = false)
+ {
+ ActivityContext ctx = new ActivityContext
+ {
+ ActorName = actorName ?? "",
+ TargetName = targetName ?? "",
+ TargetIsActor = !string.IsNullOrEmpty(targetName),
+ SpeciesId = speciesId ?? "",
+ PlaceLabel = place ?? "",
+ CarryingLabel = carrying ?? "",
+ JobId = jobId ?? "",
+ TaskKey = taskKey ?? "",
+ TopTraitId = traitId ?? "",
+ IsChild = isChild,
+ InCombat = inCombat,
+ HasAttackTarget = inCombat && !string.IsNullOrEmpty(targetName),
+ IsKing = isKing,
+ IsLeader = isLeader,
+ IsWarrior = isWarrior
+ };
+
+ ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(ctx.SpeciesId);
+ if (asset != null)
+ {
+ ActivityVoiceResolver.ApplyToContext(ctx, asset);
+ ctx.SpeciesLabel = ActivityAssetCatalog.SpeciesDisplayLabel(asset);
+ }
+ else
+ {
+ ctx.SpeciesLabel = ActivityAssetCatalog.SpeciesDisplayLabel(ctx.SpeciesId);
+ 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);
+ ctx.IsCiv = ctx.Voice.BaseIsCiv;
+ ctx.IsHumanoid = ctx.Voice.BaseIsHumanoid;
+ ctx.IsAnimal = ctx.Voice.BaseIsAnimal;
+ }
+
+ if (!string.IsNullOrEmpty(taskKey)
+ && taskKey.StartsWith("child_", System.StringComparison.OrdinalIgnoreCase))
+ {
+ ctx.IsChild = true;
+ }
+
+ // Strip species-like place labels the same way live BuildContext does.
+ if (!string.IsNullOrEmpty(ctx.PlaceLabel)
+ && ActivityInterestTable.LooksLikeSpeciesPublic(ctx.PlaceLabel, ctx.SpeciesId))
+ {
+ ctx.PlaceLabel = "";
+ }
+
+ ctx.RoleLabel = ActivityInterestTable.DeriveRoleLabel(ctx.IsKing, ctx.IsLeader, ctx.IsWarrior);
+ ctx.TopTraitLabel = ActivityInterestTable.TraitLabelFromId(ctx.TopTraitId);
+ return ctx;
+ }
+}
diff --git a/IdleSpectator/ActivityInterestTable.cs b/IdleSpectator/ActivityInterestTable.cs
index 911bfc4..9ec2ad2 100644
--- a/IdleSpectator/ActivityInterestTable.cs
+++ b/IdleSpectator/ActivityInterestTable.cs
@@ -1,3 +1,4 @@
+using System;
using UnityEngine;
namespace IdleSpectator;
@@ -294,6 +295,26 @@ public static class ActivityInterestTable
{
placeLabel = "the wilds";
}
+
+ // Prefer settlement names when no building/tile label won.
+ if (string.IsNullOrEmpty(placeLabel) && string.IsNullOrEmpty(targetName))
+ {
+ try
+ {
+ if (actor.city != null && !string.IsNullOrEmpty(actor.city.name))
+ {
+ placeLabel = actor.city.name;
+ }
+ else if (actor.kingdom != null && !string.IsNullOrEmpty(actor.kingdom.name))
+ {
+ placeLabel = actor.kingdom.name;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
}
catch
{
@@ -319,6 +340,493 @@ public static class ActivityInterestTable
}
}
+ /// Build a prose context snapshot from a live actor.
+ public static ActivityContext BuildContext(Actor actor, string taskKeyHint = null)
+ {
+ ActivityContext ctx = new ActivityContext();
+ if (actor == null || !actor.isAlive())
+ {
+ return ctx;
+ }
+
+ ctx.ActorName = ActorName(actor);
+ try
+ {
+ if (actor.asset != null)
+ {
+ ActorAsset asset = actor.asset;
+ ActivityVoiceResolver.ApplyToContext(ctx, asset);
+ ctx.SpeciesLabel = ActivityAssetCatalog.SpeciesDisplayLabel(asset);
+ }
+ else
+ {
+ ctx.SpeciesId = "";
+ ctx.SpeciesLabel = "";
+ ctx.Family = ActivityVoiceFamilies.Default;
+ ctx.Voice = ProseVoice.Unknown();
+ }
+ }
+ catch
+ {
+ ctx.SpeciesId = "";
+ ctx.SpeciesLabel = "";
+ ctx.Family = ActivityVoiceFamilies.Default;
+ ctx.Voice = ProseVoice.Unknown();
+ }
+
+ if (ctx.Voice == null)
+ {
+ ctx.Voice = ActivityVoiceResolver.Resolve(ctx.SpeciesId);
+ ctx.Family = ctx.Voice.BaseFamily;
+ }
+
+ ctx.TaskKey = taskKeyHint ?? SafeTaskId(actor);
+ ctx.JobId = SafeJobId(actor);
+
+ try
+ {
+ ctx.Age = actor.getAge();
+ }
+ catch
+ {
+ ctx.Age = 0;
+ }
+
+ ctx.IsChild = false;
+ if (!string.IsNullOrEmpty(ctx.TaskKey)
+ && ctx.TaskKey.StartsWith("child_", System.StringComparison.OrdinalIgnoreCase))
+ {
+ ctx.IsChild = true;
+ }
+ else if (ctx.Age > 0 && ctx.Age < 12
+ && (ctx.IsHumanoid || ctx.Family == ActivityVoiceFamilies.Humanoid))
+ {
+ // Age bands are reliable for civ humanoids; animals often stay "young" by year count.
+ ctx.IsChild = true;
+ }
+
+ try
+ {
+ ctx.CityName = actor.city != null ? (actor.city.name ?? "") : "";
+ }
+ catch
+ {
+ ctx.CityName = "";
+ }
+
+ try
+ {
+ ctx.KingdomName = actor.kingdom != null ? (actor.kingdom.name ?? "") : "";
+ }
+ catch
+ {
+ ctx.KingdomName = "";
+ }
+
+ try
+ {
+ ctx.IsKing = actor.isKing();
+ ctx.IsLeader = actor.isCityLeader();
+ ctx.IsWarrior = actor.is_profession_warrior;
+ ctx.HasAttackTarget = actor.has_attack_target;
+ }
+ catch
+ {
+ // ignore
+ }
+
+ try
+ {
+ if (actor.hasTask() && actor.ai?.task != null)
+ {
+ ctx.InCombat = actor.ai.task.in_combat || actor.has_attack_target;
+ }
+ else
+ {
+ ctx.InCombat = actor.has_attack_target;
+ }
+ }
+ catch
+ {
+ ctx.InCombat = ctx.HasAttackTarget;
+ }
+
+ ResolveTarget(actor, out string targetName, out bool targetIsActor, out string place);
+ ctx.TargetName = targetName;
+ ctx.TargetIsActor = targetIsActor;
+ ctx.PlaceLabel = PickPlaceLabel(place, ctx.CityName, ctx.KingdomName, ctx.SpeciesId);
+
+ ctx.CarryingLabel = TryGetCarryingLabel(actor);
+ ctx.TopTraitId = TryTopInterestingTraitId(actor);
+ ctx.TopTraitLabel = TraitLabelFromId(ctx.TopTraitId);
+ ctx.RoleLabel = DeriveRoleLabel(ctx.IsKing, ctx.IsLeader, ctx.IsWarrior);
+ return ctx;
+ }
+
+ public static string DeriveRoleLabel(bool isKing, bool isLeader, bool isWarrior)
+ {
+ if (isKing)
+ {
+ return "king";
+ }
+
+ if (isLeader)
+ {
+ return "leader";
+ }
+
+ if (isWarrior)
+ {
+ return "warrior";
+ }
+
+ return "";
+ }
+
+ /// Humanize a live trait id the same way the dossier does - manner only.
+ public static string TraitLabelFromId(string traitId)
+ {
+ if (string.IsNullOrEmpty(traitId))
+ {
+ return "";
+ }
+
+ try
+ {
+ ActorTrait trait = AssetManager.traits?.get(traitId.Trim());
+ if (trait != null)
+ {
+ string locale = trait.getLocaleID();
+ if (!string.IsNullOrEmpty(locale))
+ {
+ string localized = LocalizedTextManager.getText(locale);
+ if (!string.IsNullOrEmpty(localized) && localized != locale)
+ {
+ return localized.Trim().ToLowerInvariant();
+ }
+ }
+ }
+ }
+ catch
+ {
+ // fall through to id humanize
+ }
+
+ string s = traitId.Replace('_', ' ').Trim().ToLowerInvariant();
+ if (s.StartsWith("trait ", StringComparison.Ordinal))
+ {
+ s = s.Substring(6).Trim();
+ }
+
+ return s;
+ }
+
+ ///
+ /// Real places only. Reject species ids, family/taxonomy buckets, and soft markers.
+ /// Wild kingdoms are often named after a species or type (buffalo, insect) - those are not places.
+ ///
+ public static string PickPlaceLabel(
+ string resolvedPlace,
+ string cityName,
+ string kingdomName,
+ string speciesId)
+ {
+ if (IsUsablePlace(cityName, speciesId))
+ {
+ return cityName.Trim();
+ }
+
+ if (IsConcreteResolvedPlace(resolvedPlace, speciesId))
+ {
+ return resolvedPlace.Trim();
+ }
+
+ // Kingdom only when it reads like a settlement/realm name, not a species bucket.
+ if (IsUsablePlace(kingdomName, speciesId) && LooksLikeRealmName(kingdomName))
+ {
+ return kingdomName.Trim();
+ }
+
+ if (!string.IsNullOrEmpty(resolvedPlace)
+ && resolvedPlace.Equals("the wilds", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return "the wilds";
+ }
+
+ return "";
+ }
+
+ private static bool IsUsablePlace(string name, string speciesId)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ return false;
+ }
+
+ return !LooksLikeSpeciesLabel(name, speciesId);
+ }
+
+ private static bool IsConcreteResolvedPlace(string place, string speciesId)
+ {
+ if (string.IsNullOrEmpty(place))
+ {
+ return false;
+ }
+
+ if (place.Equals("their mark", System.StringComparison.OrdinalIgnoreCase)
+ || place.Equals("the wilds", System.StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return !LooksLikeSpeciesLabel(place, speciesId);
+ }
+
+ ///
+ /// Civ kingdom/city names tend to be multi-word or proper-cased inventions.
+ /// Single taxonomic tokens like "insect" fail this check.
+ ///
+ private static bool LooksLikeRealmName(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ return false;
+ }
+
+ string n = name.Trim();
+ if (n.IndexOf(' ') >= 0 || n.IndexOf('-') >= 0 || n.IndexOf('\'') >= 0)
+ {
+ return true;
+ }
+
+ // Single token: allow only if it does not look like a plain species/type word.
+ // Realms like "Rome" pass; "insect" / "buffalo" fail via LooksLikeSpeciesLabel already,
+ // but also reject short all-lowercase taxonomy leftovers here.
+ if (n.Length <= 3)
+ {
+ return false;
+ }
+
+ bool hasUpper = false;
+ for (int i = 0; i < n.Length; i++)
+ {
+ if (char.IsUpper(n[i]))
+ {
+ hasUpper = true;
+ break;
+ }
+ }
+
+ return hasUpper;
+ }
+
+ public static bool LooksLikeSpeciesPublic(string label, string speciesId)
+ {
+ return LooksLikeSpeciesLabel(label, speciesId);
+ }
+
+ private static readonly string[] TaxonomyBuckets =
+ {
+ "insect", "insects", "animal", "animals", "beast", "beasts", "creature", "creatures",
+ "monster", "monsters", "undead", "nature", "wildlife", "civilian", "civilians",
+ "unit", "units", "boat", "boats", "humanoid", "livestock", "bird", "birds",
+ "canine", "feline", "aquatic", "default", "wild"
+ };
+
+ private static bool LooksLikeSpeciesLabel(string label, string speciesId)
+ {
+ if (string.IsNullOrEmpty(label))
+ {
+ return false;
+ }
+
+ string a = label.Trim();
+ string aLower = a.ToLowerInvariant();
+
+ for (int i = 0; i < TaxonomyBuckets.Length; i++)
+ {
+ if (aLower.Equals(TaxonomyBuckets[i], System.StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(speciesId))
+ {
+ string b = speciesId.Trim();
+ if (a.Equals(b, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ string bHuman = b.Replace('_', ' ');
+ if (a.Equals(bHuman, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ string family = ActivityVoiceResolver.Resolve(speciesId).BaseFamily;
+ if (!string.IsNullOrEmpty(family)
+ && a.Equals(family, System.StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ // Live actor library: any asset id / wild kingdom / locale name is not a place.
+ if (ActivityAssetCatalog.IsLiveSpeciesPlaceToken(a)
+ || ActivityAssetCatalog.IsLiveActorAssetId(aLower))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ public static string TryGetCarryingLabel(Actor actor)
+ {
+ if (actor == null)
+ {
+ return "";
+ }
+
+ try
+ {
+ if (!actor.isCarryingResources())
+ {
+ return "";
+ }
+ }
+ catch
+ {
+ return "";
+ }
+
+ try
+ {
+ ActorBag inv = actor.inventory;
+ string id = "";
+ try
+ {
+ id = inv.getItemIDToRender() ?? "";
+ }
+ catch
+ {
+ id = "";
+ }
+
+ if (string.IsNullOrEmpty(id))
+ {
+ try
+ {
+ id = inv.getRandomResourceID() ?? "";
+ }
+ catch
+ {
+ id = "";
+ }
+ }
+
+ if (!string.IsNullOrEmpty(id))
+ {
+ return SanitizeCarrying(HumanizeResourceId(id));
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return "what they carry";
+ }
+
+ private static string HumanizeResourceId(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ {
+ return "";
+ }
+
+ return id.Replace('_', ' ').Trim();
+ }
+
+ private static string SanitizeCarrying(string tip)
+ {
+ if (string.IsNullOrEmpty(tip))
+ {
+ return "";
+ }
+
+ string s = tip.Replace("\n", " ").Replace("\r", " ").Trim();
+ // Strip simple rich-text tags if present.
+ while (true)
+ {
+ int open = s.IndexOf('<');
+ if (open < 0)
+ {
+ break;
+ }
+
+ int close = s.IndexOf('>', open);
+ if (close < 0)
+ {
+ break;
+ }
+
+ s = s.Remove(open, close - open + 1);
+ }
+
+ s = s.Trim();
+ if (s.Length > 48)
+ {
+ s = s.Substring(0, 45).Trim() + "...";
+ }
+
+ return s;
+ }
+
+ private static string TryTopInterestingTraitId(Actor actor)
+ {
+ try
+ {
+ if (actor == null || !actor.hasTraits())
+ {
+ return "";
+ }
+
+ foreach (ActorTrait trait in actor.getTraits())
+ {
+ if (trait == null || string.IsNullOrEmpty(trait.id))
+ {
+ continue;
+ }
+
+ // Skip default species traits when detectable.
+ try
+ {
+ if (actor.asset != null
+ && trait.default_for_actor_assets != null
+ && trait.default_for_actor_assets.Contains(actor.asset))
+ {
+ continue;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return trait.id;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return "";
+ }
+
private static bool ContainsAny(string hay, params string[] needles)
{
for (int i = 0; i < needles.Length; i++)
diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs
index d99ce45..8c5f371 100644
--- a/IdleSpectator/ActivityLog.cs
+++ b/IdleSpectator/ActivityLog.cs
@@ -167,10 +167,12 @@ public static class ActivityLog
}
LastTaskId[id] = taskId;
- string jobId = ActivityInterestTable.SafeJobId(actor);
- ActivityInterestTable.ResolveTarget(
- actor, out string targetName, out bool targetIsActor, out string place);
- string actorName = ActivityInterestTable.ActorName(actor);
+ ActivityContext ctx = ActivityInterestTable.BuildContext(actor, taskId);
+ string jobId = ctx.JobId;
+ string actorName = ctx.ActorName;
+ string targetName = ctx.TargetName;
+ bool targetIsActor = ctx.TargetIsActor;
+ string place = ctx.PlaceLabel;
string loc = "";
try
{
@@ -195,10 +197,7 @@ public static class ActivityLog
ActivityProse.Format(
ActivityKind.TaskStart,
taskId,
- actorName,
- targetName,
- targetIsActor,
- place,
+ ctx,
raw,
id,
idx,
@@ -267,15 +266,31 @@ public static class ActivityLog
}
LastBeatAt[id] = now;
+ ActivityContext ctx = ActivityInterestTable.BuildContext(actor, actionKey);
string taskId = ActivityInterestTable.SafeTaskId(actor);
- string jobId = ActivityInterestTable.SafeJobId(actor);
- string actorName = ActivityInterestTable.ActorName(actor);
- ActivityInterestTable.ResolveTarget(
- actor, out string resolvedName, out bool targetIsActor, out string place);
- if (string.IsNullOrEmpty(resolvedName) && !string.IsNullOrEmpty(target))
+ string jobId = ctx.JobId;
+ if (string.IsNullOrEmpty(ctx.TargetName) && !string.IsNullOrEmpty(target))
{
- resolvedName = target;
- targetIsActor = true;
+ ctx.TargetName = target;
+ ctx.TargetIsActor = true;
+ }
+
+ if (actionKey == "BehUnloadResources" || actionKey == "BehThrowResources")
+ {
+ if (string.IsNullOrEmpty(ctx.CarryingLabel))
+ {
+ ctx.CarryingLabel = ActivityInterestTable.TryGetCarryingLabel(actor);
+ }
+
+ if (string.IsNullOrEmpty(ctx.CarryingLabel))
+ {
+ ctx.CarryingLabel = "goods";
+ }
+
+ if (string.IsNullOrEmpty(ctx.PlaceLabel))
+ {
+ ctx.PlaceLabel = !string.IsNullOrEmpty(ctx.CityName) ? ctx.CityName : "town";
+ }
}
string fact = string.IsNullOrEmpty(rawFact) ? actionKey : rawFact;
@@ -283,10 +298,7 @@ public static class ActivityLog
ActivityProse.Format(
ActivityKind.ActionBeat,
actionKey,
- actorName,
- resolvedName,
- targetIsActor,
- place,
+ ctx,
fact,
id,
idx,
@@ -300,7 +312,9 @@ public static class ActivityLog
Kind = ActivityKind.ActionBeat,
TaskId = string.IsNullOrEmpty(taskId) ? actionKey : taskId,
JobId = jobId,
- TargetLabel = !string.IsNullOrEmpty(resolvedName) ? resolvedName : (place ?? ""),
+ TargetLabel = !string.IsNullOrEmpty(ctx.TargetName)
+ ? ctx.TargetName
+ : (ctx.PlaceLabel ?? ""),
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
@@ -314,7 +328,17 @@ public static class ActivityLog
string rawLine,
bool asBeat = false,
string actorName = "",
- string targetName = "")
+ string targetName = "",
+ string speciesId = "",
+ string place = "",
+ string carrying = "",
+ string jobId = "",
+ string traitId = "",
+ bool isChild = false,
+ bool inCombat = false,
+ bool isKing = false,
+ bool isLeader = false,
+ bool isWarrior = false)
{
if (subjectId == 0 || string.IsNullOrEmpty(taskOrAction))
{
@@ -324,13 +348,122 @@ public static class ActivityLog
int idx = NextIndex(subjectId);
string fact = string.IsNullOrEmpty(rawLine) ? taskOrAction : rawLine;
ActivityKind kind = asBeat ? ActivityKind.ActionBeat : ActivityKind.TaskStart;
+
+ // Prefer live focus context when available, then apply harness overrides.
+ ActivityContext ctx = ActivityContext.Empty;
+ try
+ {
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null
+ && MoveCamera._focus_unit.getID() == subjectId)
+ {
+ ctx = ActivityInterestTable.BuildContext(MoveCamera._focus_unit, taskOrAction);
+ }
+ }
+ catch
+ {
+ ctx = ActivityContext.Empty;
+ }
+
+ if (ctx == null || string.IsNullOrEmpty(ctx.ActorName))
+ {
+ ctx = ActivityContext.ForHarness(
+ actorName,
+ targetName,
+ speciesId,
+ place,
+ carrying,
+ jobId,
+ taskOrAction,
+ traitId,
+ isChild,
+ inCombat,
+ isKing,
+ isLeader,
+ isWarrior);
+ }
+ else
+ {
+ if (!string.IsNullOrEmpty(actorName))
+ {
+ ctx.ActorName = actorName;
+ }
+
+ if (!string.IsNullOrEmpty(targetName))
+ {
+ ctx.TargetName = targetName;
+ ctx.TargetIsActor = true;
+ }
+
+ if (!string.IsNullOrEmpty(speciesId))
+ {
+ ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(speciesId);
+ if (asset != null)
+ {
+ ActivityVoiceResolver.ApplyToContext(ctx, asset);
+ ctx.SpeciesLabel = ActivityAssetCatalog.SpeciesDisplayLabel(asset);
+ }
+ else
+ {
+ ctx.SpeciesId = speciesId;
+ ctx.SpeciesLabel = ActivityAssetCatalog.SpeciesDisplayLabel(speciesId);
+ ctx.Voice = ActivityVoiceResolver.Resolve(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);
+ ctx.IsCiv = ctx.Voice.BaseIsCiv;
+ ctx.IsHumanoid = ctx.Voice.BaseIsHumanoid;
+ ctx.IsAnimal = ctx.Voice.BaseIsAnimal;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(place))
+ {
+ ctx.PlaceLabel = place;
+ }
+
+ if (!string.IsNullOrEmpty(carrying))
+ {
+ ctx.CarryingLabel = carrying;
+ }
+
+ if (!string.IsNullOrEmpty(jobId))
+ {
+ ctx.JobId = jobId;
+ }
+
+ if (!string.IsNullOrEmpty(traitId))
+ {
+ ctx.TopTraitId = traitId;
+ ctx.TopTraitLabel = ActivityInterestTable.TraitLabelFromId(traitId);
+ }
+
+ if (isChild)
+ {
+ ctx.IsChild = true;
+ }
+
+ if (inCombat)
+ {
+ ctx.InCombat = true;
+ ctx.HasAttackTarget = ctx.HasAttackTarget || ctx.TargetIsActor;
+ }
+
+ if (isKing || isLeader || isWarrior)
+ {
+ ctx.IsKing = isKing || ctx.IsKing;
+ ctx.IsLeader = isLeader || ctx.IsLeader;
+ ctx.IsWarrior = isWarrior || ctx.IsWarrior;
+ ctx.RoleLabel = ActivityInterestTable.DeriveRoleLabel(ctx.IsKing, ctx.IsLeader, ctx.IsWarrior);
+ }
+
+ ctx.TaskKey = taskOrAction;
+ }
+
ActivityProse.Format(
kind,
taskOrAction,
- actorName,
- targetName,
- !string.IsNullOrEmpty(targetName),
- "",
+ ctx,
fact,
subjectId,
idx,
@@ -342,6 +475,7 @@ public static class ActivityLog
SubjectId = subjectId,
Kind = kind,
TaskId = taskOrAction,
+ JobId = ctx.JobId ?? "",
TargetLabel = targetName ?? "",
Line = fact,
DisplayLine = display,
diff --git a/IdleSpectator/ActivityProse.cs b/IdleSpectator/ActivityProse.cs
index a45f595..792d10e 100644
--- a/IdleSpectator/ActivityProse.cs
+++ b/IdleSpectator/ActivityProse.cs
@@ -10,381 +10,383 @@ public static class ActivityProse
public const string NameColorHex = "#F0C14A";
///
- /// Templates use {actor} and optional {target}. Never invent outcomes.
+ /// Long detailed templates. Tokens: {actor}{target}{species}{place}{place_at}{job}{job_as}{carrying}{with}.
+ /// Never invent outcomes - only flavor observed tasks.
+ /// Prefer for full alive composition; Variants seed action phrases.
///
private static readonly Dictionary Variants = new Dictionary
{
["attack"] = new[]
{
- "{actor} strikes at {target}",
- "{actor} presses the attack on {target}",
- "{actor} lunges toward {target}"
+ "{actor} strikes hard at {target}{place_at}",
+ "{actor} presses the attack on {target}{place_at}",
+ "{actor} lunges toward {target} with clear intent{place_at}"
},
["fight"] = new[]
{
- "{actor} fights {target}",
- "{actor} clashes with {target}",
- "{actor} trades blows with {target}"
+ "{actor} fights {target} in a fierce clash{place_at}",
+ "{actor} clashes with {target}{place_at}{job_as}",
+ "{actor} trades blows with {target}{place_at}"
},
["fighting"] = new[]
{
- "{actor} fights {target}",
- "{actor} is locked in combat with {target}",
- "{actor} clashes with {target}"
+ "{actor} is locked in combat with {target}{place_at}",
+ "{actor} fights {target} without giving ground{place_at}",
+ "{actor} clashes with {target}{place_at}"
},
["hunt"] = new[]
{
- "{actor} hunts {target}",
- "{actor} stalks {target}",
- "{actor} closes on {target}"
+ "{actor} hunts {target} through the living world{place_at}",
+ "{actor} stalks {target} with careful steps{place_at}",
+ "{actor} closes on {target}{place_at}"
},
["BehAttackActorHuntingTarget"] = new[]
{
- "{actor} hunts {target}",
- "{actor} strikes at {target}",
- "{actor} closes on {target}"
+ "{actor} hunts {target} and strikes when the moment opens{place_at}",
+ "{actor} closes on {target} in the chase{place_at}",
+ "{actor} strikes at {target} while hunting{place_at}"
},
["heal"] = new[]
{
- "{actor} tends to {target}",
- "{actor} heals {target}",
- "{actor} binds {target}'s wounds"
+ "{actor} tends carefully to {target}{place_at}",
+ "{actor} works to heal {target}{place_at}",
+ "{actor} binds {target}'s wounds with steady hands{place_at}"
},
["check_heal"] = new[]
{
- "{actor} checks on wounds",
- "{actor} looks over injuries",
- "{actor} takes stock of pain"
+ "{actor} checks over wounds with worried care{place_at}",
+ "{actor} looks closely at injuries{place_at}",
+ "{actor} takes stock of pain before moving on{place_at}"
},
["cure"] = new[]
{
- "{actor} seeks a cure",
- "{actor} works to recover",
- "{actor} fights sickness"
+ "{actor} seeks a cure for what ails them{place_at}",
+ "{actor} works to recover from sickness{place_at}",
+ "{actor} fights sickness with stubborn will{place_at}"
},
["eat"] = new[]
{
- "{actor} takes a meal",
- "{actor} eats",
- "{actor} breaks hunger"
+ "{actor} settles in to take a proper meal{place_at}{job_as}",
+ "{actor} breaks hunger with whatever can be found{place_at}",
+ "{actor} eats with focused appetite{place_at}"
},
["eating"] = new[]
{
- "{actor} takes a meal",
- "{actor} eats",
- "{actor} breaks hunger"
+ "{actor} settles in to take a proper meal{place_at}{job_as}",
+ "{actor} breaks hunger with whatever can be found{place_at}",
+ "{actor} eats with focused appetite{place_at}"
},
["sleep"] = new[]
{
- "{actor} sleeps",
- "{actor} rests",
- "{actor} settles in to sleep"
+ "{actor} settles into sleep and lets the world go quiet{place_at}",
+ "{actor} rests deeply after the day's demands{place_at}{job_as}",
+ "{actor} curls into sleep{place_at}"
},
["decide_where_to_sleep"] = new[]
{
- "{actor} looks for a place to sleep",
- "{actor} decides where to rest",
- "{actor} seeks shelter for the night"
+ "{actor} searches for a safe place to sleep{place_at}",
+ "{actor} weighs where to rest for the night{place_at}",
+ "{actor} seeks shelter before settling down{place_at}"
},
["random_move"] = new[]
{
- "{actor} wanders",
- "{actor} roams nearby",
- "{actor} drifts along"
+ "{actor} wanders without hurry through the living world{place_at}",
+ "{actor} roams nearby, taking in the moment{place_at}",
+ "{actor} drifts along with no urgent errand{place_at}"
},
["city_idle_walking"] = new[]
{
- "{actor} walks the streets",
- "{actor} strolls through town",
- "{actor} paces the settlement"
+ "{actor} walks the streets at an easy pace{place_at}{job_as}",
+ "{actor} strolls through the settlement, watching life pass{place_at}",
+ "{actor} paces familiar paths between errands{place_at}"
},
["move"] = new[]
{
- "{actor} is on the move",
- "{actor} travels onward",
- "{actor} heads out"
+ "{actor} is on the move with purpose{place_at}{job_as}",
+ "{actor} travels onward through the world{place_at}",
+ "{actor} heads out toward the next mark{place_at}"
},
["pollinate"] = new[]
{
- "{actor} dusts the blossom",
- "{actor} works the flower",
- "{actor} leaves pollen behind"
+ "{actor} dusts the blossom with careful work{place_at}",
+ "{actor} works the flower and leaves the air sweet{place_at}",
+ "{actor} leaves pollen behind on the bloom{place_at}"
},
["BehPollinate"] = new[]
{
- "{actor} dusts the blossom",
- "{actor} works the flower",
- "{actor} leaves pollen behind"
+ "{actor} dusts the blossom with careful work{place_at}",
+ "{actor} works the flower and leaves the air sweet{place_at}",
+ "{actor} leaves pollen behind on the bloom{place_at}"
},
["farm"] = new[]
{
- "{actor} tends the fields",
- "{actor} works the soil",
- "{actor} farms the plot"
+ "{actor} tends the fields with patient labor{place_at}{job_as}",
+ "{actor} works the soil until it answers{place_at}",
+ "{actor} farms the plot through the day{place_at}"
},
["harvest"] = new[]
{
- "{actor} harvests the crop",
- "{actor} gathers the yield",
- "{actor} cuts the ripe fields"
+ "{actor} harvests the ripe crop with practiced hands{place_at}{job_as}",
+ "{actor} gathers the yield before it spoils{place_at}",
+ "{actor} cuts the ripe fields clean{place_at}"
},
["forag"] = new[]
{
- "{actor} forages",
- "{actor} gathers wild food",
- "{actor} searches for forage"
+ "{actor} forages through wild growth for food{place_at}",
+ "{actor} gathers what the land offers freely{place_at}",
+ "{actor} searches the brush for forage{place_at}"
},
["gather"] = new[]
{
- "{actor} gathers stores",
- "{actor} collects goods",
- "{actor} forages supplies"
+ "{actor} gathers stores for leaner days{place_at}{job_as}",
+ "{actor} collects goods with steady purpose{place_at}",
+ "{actor} forages supplies into the pack{place_at}"
},
["mine"] = new[]
{
- "{actor} delves the mine",
- "{actor} chips at ore",
- "{actor} works the dig"
+ "{actor} delves the mine for ore and stone{place_at}{job_as}",
+ "{actor} chips at the rock face with hard labor{place_at}",
+ "{actor} works the dig until the haul is ready{place_at}"
},
["build"] = new[]
{
- "{actor} builds",
- "{actor} raises walls",
- "{actor} works the construction"
+ "{actor} builds with focused craft{place_at}{job_as}",
+ "{actor} raises walls and timber into shape{place_at}",
+ "{actor} labors on construction until it stands{place_at}"
},
["woodcut"] = new[]
{
- "{actor} fells timber",
- "{actor} cuts wood",
- "{actor} takes an axe to the trees"
+ "{actor} fells timber for the settlement's need{place_at}{job_as}",
+ "{actor} takes an axe to the trees with measured swings{place_at}",
+ "{actor} cuts wood and stacks the haul{place_at}"
},
["fire"] = new[]
{
- "{actor} fights the blaze",
- "{actor} douses flames",
- "{actor} races the fire"
+ "{actor} races the blaze and fights to keep it down{place_at}",
+ "{actor} douses flames before they claim more ground{place_at}",
+ "{actor} battles the fire with urgent work{place_at}"
},
["BehCityActorRemoveFire"] = new[]
{
- "{actor} fights the blaze",
- "{actor} douses flames",
- "{actor} beats back the fire"
+ "{actor} races the blaze and fights to keep it down{place_at}",
+ "{actor} douses flames before they claim more ground{place_at}",
+ "{actor} beats back the fire with urgent work{place_at}"
},
["social"] = new[]
{
- "{actor} talks with {target}",
- "{actor} socializes with {target}",
- "{actor} converses with {target}"
+ "{actor} talks at length{with}{place_at}{job_as}",
+ "{actor} socializes and shares the moment{with}{place_at}",
+ "{actor} converses warmly{with}{place_at}"
},
["socialize"] = new[]
{
- "{actor} talks with {target}",
- "{actor} socializes with {target}",
- "{actor} joins company near {target}"
+ "{actor} talks at length{with}{place_at}{job_as}",
+ "{actor} socializes and shares the moment{with}{place_at}",
+ "{actor} joins company nearby{with}{place_at}"
},
["lover"] = new[]
{
- "{actor} seeks out {target}",
- "{actor} is drawn to {target}",
- "{actor} pursues {target}'s affection"
+ "{actor} seeks out {target} with unmistakable longing{place_at}",
+ "{actor} is drawn toward {target} through the day{place_at}",
+ "{actor} pursues {target}'s affection with quiet resolve{place_at}"
},
["breed"] = new[]
{
- "{actor} tries to start a family with {target}",
- "{actor} courts {target}",
- "{actor} seeks offspring with {target}"
+ "{actor} tries to start a family with {target}{place_at}",
+ "{actor} courts {target} with clear intent{place_at}",
+ "{actor} seeks offspring with {target}{place_at}"
},
["sexual_reproduction"] = new[]
{
- "{actor} tries to start a family with {target}",
- "{actor} courts {target}",
- "{actor} seeks offspring with {target}"
+ "{actor} tries to start a family with {target}{place_at}",
+ "{actor} courts {target} with clear intent{place_at}",
+ "{actor} seeks offspring with {target}{place_at}"
},
["unload"] = new[]
{
- "{actor} unloads goods in town",
- "{actor} delivers the haul",
- "{actor} empties the pack at home"
+ "{actor} unloads {carrying} into store{place_at}",
+ "{actor} delivers {carrying} after a long haul{place_at}",
+ "{actor} empties the pack of {carrying}{place_at}"
},
["BehUnloadResources"] = new[]
{
- "{actor} unloads goods in town",
- "{actor} delivers the haul",
- "{actor} empties the pack at home"
+ "{actor} unloads {carrying} at {place}",
+ "{actor} delivers {carrying} into store{place_at}",
+ "{actor} empties the pack of {carrying}{place_at}"
},
["job"] = new[]
{
- "{actor} seeks work",
- "{actor} looks for a job",
- "{actor} searches for employment"
+ "{actor} seeks honest work among the living{place_at}",
+ "{actor} looks for a job that will keep them fed{place_at}",
+ "{actor} searches for employment through the settlement{place_at}"
},
["find_city_job"] = new[]
{
- "{actor} seeks work in town",
- "{actor} looks for a city job",
- "{actor} searches for employment"
+ "{actor} seeks work in town with practical hope{place_at}",
+ "{actor} looks for a city job among open posts{place_at}",
+ "{actor} searches the settlement for employment{place_at}"
},
["find_house"] = new[]
{
- "{actor} looks for a house",
- "{actor} seeks a home",
- "{actor} searches for shelter"
+ "{actor} looks for a house to call home{place_at}",
+ "{actor} seeks shelter and a lasting roof{place_at}",
+ "{actor} searches for a place to settle{place_at}"
},
["claim"] = new[]
{
- "{actor} claims land",
- "{actor} stakes a claim",
- "{actor} takes claim of ground"
+ "{actor} claims new ground for the settlement{place_at}",
+ "{actor} stakes a claim on open land{place_at}",
+ "{actor} takes claim of border ground{place_at}"
},
["warrior"] = new[]
{
- "{actor} follows the army",
- "{actor} marches with the warband",
- "{actor} keeps formation"
+ "{actor} follows the army with disciplined steps{place_at}",
+ "{actor} marches with the warband{place_at}{job_as}",
+ "{actor} keeps formation and waits for orders{place_at}"
},
["army"] = new[]
{
- "{actor} follows the army",
- "{actor} marches with the warband",
- "{actor} keeps formation"
+ "{actor} follows the army with disciplined steps{place_at}",
+ "{actor} marches with the warband{place_at}",
+ "{actor} keeps formation among the ranks{place_at}"
},
["wait"] = new[]
{
- "{actor} waits",
- "{actor} holds still",
- "{actor} bides time"
+ "{actor} waits in watchful quiet{place_at}{job_as}",
+ "{actor} holds still and lets the moment pass{place_at}",
+ "{actor} bides time without wasting motion{place_at}"
},
["make_decision"] = new[]
{
- "{actor} considers next steps",
- "{actor} weighs a choice",
- "{actor} pauses in thought"
+ "{actor} considers next steps with care{place_at}",
+ "{actor} weighs a choice before moving{place_at}{job_as}",
+ "{actor} pauses in thought while the world waits{place_at}"
},
["reflection"] = new[]
{
- "{actor} reflects",
- "{actor} takes a quiet moment",
- "{actor} thinks things over"
+ "{actor} reflects on what the day has asked{place_at}",
+ "{actor} takes a quiet moment to think things over{place_at}",
+ "{actor} settles into reflection away from noise{place_at}"
},
["run_away"] = new[]
{
- "{actor} flees",
- "{actor} runs for safety",
- "{actor} retreats"
+ "{actor} flees hard for safer ground{place_at}",
+ "{actor} runs for safety without looking back{place_at}",
+ "{actor} retreats in a burst of fear{place_at}"
},
["trade"] = new[]
{
- "{actor} trades",
- "{actor} barters goods",
- "{actor} is on a trade run"
+ "{actor} trades goods with careful eye{place_at}{job_as}",
+ "{actor} barters through the market's press{place_at}",
+ "{actor} is on a trade run with full purpose{place_at}"
},
["fish"] = new[]
{
- "{actor} fishes",
- "{actor} casts for fish",
- "{actor} works the waters"
+ "{actor} fishes the waters with patient focus{place_at}{job_as}",
+ "{actor} casts for fish where the current runs{place_at}",
+ "{actor} works the waters for a catch{place_at}"
},
["family_group"] = new[]
{
- "{actor} joins the herd",
- "{actor} seeks the family group",
- "{actor} gathers with kin"
+ "{actor} joins the herd and stays close to kin{place_at}",
+ "{actor} seeks the family group across open ground{place_at}",
+ "{actor} gathers with kin for safety and company{place_at}"
},
["generate_loot"] = new[]
{
- "{actor} gathers loot",
- "{actor} claims spoils",
- "{actor} picks through loot"
+ "{actor} gathers loot from what the fight left behind{place_at}",
+ "{actor} claims spoils with practical hands{place_at}",
+ "{actor} picks through loot before moving on{place_at}"
},
["laughing"] = new[]
{
- "{actor} laughs",
- "{actor} bursts into laughter",
- "{actor} is laughing"
+ "{actor} laughs aloud and lets the mood carry{place_at}",
+ "{actor} bursts into laughter without restraint{place_at}",
+ "{actor} is laughing through the moment{place_at}"
},
["happy_laughing"] = new[]
{
- "{actor} laughs with joy",
- "{actor} is laughing happily",
- "{actor} chuckles aloud"
+ "{actor} laughs with bright, unguarded joy{place_at}",
+ "{actor} is laughing happily among the living{place_at}",
+ "{actor} chuckles aloud until the mood softens{place_at}"
},
["just_laughed"] = new[]
{
- "{actor} just finished laughing",
- "{actor} settles after a laugh",
- "{actor} catches their breath from laughing"
+ "{actor} just finished laughing and catches a breath{place_at}",
+ "{actor} settles after a laugh, still smiling{place_at}",
+ "{actor} catches their breath from laughing{place_at}"
},
["singing"] = new[]
{
- "{actor} sings",
- "{actor} lifts a song",
- "{actor} is singing"
+ "{actor} sings out for whoever will hear{place_at}{job_as}",
+ "{actor} lifts a song into the open air{place_at}",
+ "{actor} is singing through the quiet stretch{place_at}"
},
["task_unit_play"] = new[]
{
- "{actor} plays",
- "{actor} is at play",
- "{actor} amuses themselves"
+ "{actor} plays with restless, living energy{place_at}",
+ "{actor} turns the moment into play{place_at}",
+ "{actor} amuses themselves without apology{place_at}"
},
["just_played"] = new[]
{
- "{actor} just finished playing",
- "{actor} wraps up playtime",
- "{actor} steps away from play"
+ "{actor} just finished playing and settles again{place_at}",
+ "{actor} wraps up playtime and looks outward{place_at}",
+ "{actor} steps away from play with leftover cheer{place_at}"
},
["child_play_at_one_spot"] = new[]
{
- "{actor} plays in place",
- "{actor} keeps busy with play",
- "{actor} amuses themselves on the spot"
+ "{actor} plays in place with busy hands and feet{place_at}",
+ "{actor} keeps busy with play without wandering far{place_at}",
+ "{actor} amuses themselves right where they stand{place_at}"
},
["child_random_flips"] = new[]
{
- "{actor} flips about",
- "{actor} tumbles in play",
- "{actor} shows off flips"
+ "{actor} flips about in a burst of play{place_at}",
+ "{actor} tumbles through playful motion{place_at}",
+ "{actor} shows off flips for whoever watches{place_at}"
},
["child_random_jump"] = new[]
{
- "{actor} jumps about",
- "{actor} leaps in play",
- "{actor} bounces around"
+ "{actor} jumps about with bright energy{place_at}",
+ "{actor} leaps in play across the open ground{place_at}",
+ "{actor} bounces around as if the day were light{place_at}"
},
["child_follow_parent"] = new[]
{
- "{actor} follows a parent",
- "{actor} stays close to {target}",
- "{actor} trails after kin"
+ "{actor} follows a parent with careful steps{place_at}",
+ "{actor} stays close to {target} through the day{place_at}",
+ "{actor} trails after kin and will not stray far{place_at}"
},
["random_fun_move"] = new[]
{
- "{actor} frolics about",
- "{actor} moves for fun",
- "{actor} skips along playfully"
+ "{actor} frolics about with no errand but joy{place_at}",
+ "{actor} moves for fun through the open moment{place_at}",
+ "{actor} skips along playfully without hurry{place_at}"
},
["godfinger_random_fun_move"] = new[]
{
- "{actor} frolics under divine whim",
- "{actor} is nudged into playful motion",
- "{actor} moves about for fun"
+ "{actor} frolics under a strange divine whim{place_at}",
+ "{actor} is nudged into playful motion by unseen will{place_at}",
+ "{actor} moves about for fun as if guided lightly{place_at}"
},
["try_to_read"] = new[]
{
- "{actor} tries to read",
- "{actor} opens a book",
- "{actor} settles in to read"
+ "{actor} tries to read and settle into the page{place_at}",
+ "{actor} opens a book and gives it quiet attention{place_at}",
+ "{actor} settles in to read while the world softens{place_at}"
},
["try_to_poop"] = new[]
{
- "{actor} looks for a private moment",
- "{actor} tends to nature's call",
- "{actor} steps aside briefly"
+ "{actor} looks for a private moment away from eyes{place_at}",
+ "{actor} tends to nature's call with hurried dignity{place_at}",
+ "{actor} steps aside briefly for a private need{place_at}"
},
["try_to_launch_fireworks"] = new[]
{
- "{actor} tries to launch fireworks",
- "{actor} readies a firework",
- "{actor} prepares a celebration blast"
+ "{actor} tries to launch fireworks into celebration{place_at}",
+ "{actor} readies a firework with careful hands{place_at}",
+ "{actor} prepares a celebration blast for the sky{place_at}"
}
};
@@ -410,39 +412,146 @@ public static class ActivityProse
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 = "";
- 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))
+ if (ctx == null)
{
- template = PatternTemplate(key, hasTarget);
+ 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);
+
if (string.IsNullOrEmpty(template))
{
- template = LocalizedTemplate(rawFact, key, hasTarget);
- }
-
- if (string.IsNullOrEmpty(template))
- {
- template = "{actor} " + LowerFirst(HumanizeKey(key));
+ template = "{actor} sets about " + LowerFirst(HumanizeKey(key))
+ + " through the living moment{place_at}{job_as}";
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);
+ 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;
+ }
+ }
+
+ /// Action-only phrase from pattern heuristics (no subject).
+ public static string PatternActionPhrase(string key, bool hasTarget)
+ {
+ string full = PatternTemplate(key, hasTarget);
+ if (string.IsNullOrEmpty(full))
+ {
+ return "";
+ }
+
+ return ActivityClauseAssembler.StripSubject(full);
+ }
+
+ public static string HumanizeKeyPublic(string key)
+ {
+ return HumanizeKey(key);
+ }
+
+ public static string HumanizeJobPublic(string jobId)
+ {
+ return HumanizeJob(jobId);
+ }
+
+ /// Observed localized fact as an action-only phrase for unknown task ids.
+ public static string FactualActionPhrase(string rawFact, string key, bool hasTarget)
+ {
+ return ActivityClauseAssembler.StripSubject(LocalizedTemplate(rawFact, key, hasTarget));
}
/// Legacy helper used by older call sites / harness.
@@ -457,43 +566,63 @@ public static class ActivityProse
Format(
kind,
key,
- actorName: "",
- targetName: target,
- targetIsActor: !string.IsNullOrEmpty(target),
- placeOrFallback: target,
- rawFact: rawFact,
- subjectId: subjectId,
- lineIndex: lineIndex,
+ ActivityContext.ForHarness("", target, place: target),
+ rawFact,
+ subjectId,
+ lineIndex,
out string plain,
out _);
return plain;
}
- private static string ResolveTargetPlain(string targetName, bool targetIsActor, string placeOrFallback)
+ private static string Apply(string template, ActivityContext ctx, bool colored)
{
- if (!string.IsNullOrEmpty(targetName))
+ if (string.IsNullOrEmpty(template))
{
- return targetName;
+ return "";
}
- if (!string.IsNullOrEmpty(placeOrFallback))
+ 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))
{
- return placeOrFallback;
+ target = colored && ctx.TargetIsActor ? ColorName(targetRaw) : targetRaw;
}
- return "";
- }
+ 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 job = HumanizeJob(ctx.JobId);
+ string carrying = string.IsNullOrEmpty(ctx.CarryingLabel) ? "goods" : ctx.CarryingLabel;
+
+ string withClause = string.IsNullOrEmpty(target) ? "" : (" with " + target);
+ string placeAt = string.IsNullOrEmpty(place) ? "" : (" in " + place);
+ string jobAs = string.IsNullOrEmpty(job) ? "" : (" as " + job);
+
+ 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}", jobAs);
+ t = t.Replace("{carrying}", carrying);
+ t = t.Replace("{place}", string.IsNullOrEmpty(place) ? "town" : place);
+ t = t.Replace("{job}", string.IsNullOrEmpty(job) ? "their work" : job);
- 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(" after {target}", "")
.Replace(" at {target}", "")
.Replace(" {target}", "")
.Replace("{target}", "someone");
@@ -503,9 +632,31 @@ public static class ActivityProse
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 PatternTemplate(string key, bool hasTarget)
{
if (string.IsNullOrEmpty(key))
@@ -514,65 +665,73 @@ public static class ActivityProse
}
string k = key.ToLowerInvariant();
+ const string clauses = "{place_at}{job_as}";
if (k.StartsWith("try_to_"))
{
- return "{actor} tries to " + HumanizeKey(k.Substring("try_to_".Length));
+ return "{actor} tries to " + HumanizeKey(k.Substring("try_to_".Length))
+ + " with careful effort" + clauses;
}
if (k.StartsWith("check_"))
{
- return "{actor} checks " + HumanizeKey(k.Substring("check_".Length));
+ return "{actor} checks " + HumanizeKey(k.Substring("check_".Length))
+ + " before deciding what comes next" + clauses;
}
if (k.StartsWith("find_"))
{
- return "{actor} looks for " + HumanizeKey(k.Substring("find_".Length));
+ return "{actor} looks for " + HumanizeKey(k.Substring("find_".Length))
+ + " through the living world" + clauses;
}
if (k.StartsWith("decide_"))
{
- return "{actor} decides " + HumanizeKey(k.Substring("decide_".Length));
+ return "{actor} decides " + HumanizeKey(k.Substring("decide_".Length))
+ + " after a moment of thought" + clauses;
}
if (k.StartsWith("build_"))
{
- return "{actor} builds " + HumanizeKey(k.Substring("build_".Length));
+ return "{actor} builds " + HumanizeKey(k.Substring("build_".Length))
+ + " with focused craft" + clauses;
}
if (k.StartsWith("boat_"))
{
- return "{actor} " + BoatVerb(k.Substring("boat_".Length));
+ return "{actor} " + BoatVerb(k.Substring("boat_".Length)) + clauses;
}
if (k.StartsWith("warrior_"))
{
- return WarriorTemplate(k, hasTarget);
+ return WarriorTemplate(k, hasTarget) + clauses;
}
if (k.StartsWith("socialize_"))
{
return hasTarget
- ? "{actor} socializes with {target}"
- : "{actor} socializes";
+ ? "{actor} socializes and shares the moment with {target}" + clauses
+ : "{actor} socializes among whoever is near" + clauses;
}
if (k.StartsWith("sexual_reproduction") || k.StartsWith("asexual_reproduction"))
{
return hasTarget
- ? "{actor} tries to start a family with {target}"
- : "{actor} tries to reproduce";
+ ? "{actor} tries to start a family with {target}" + clauses
+ : "{actor} tries to reproduce and carry life forward" + clauses;
}
if (k.StartsWith("child_"))
{
- return "{actor} " + LowerFirst(HumanizeKey(k.Substring("child_".Length)));
+ return "{actor} " + LowerFirst(HumanizeKey(k.Substring("child_".Length)))
+ + " with youthful energy" + clauses;
}
if (k.StartsWith("ant_") || k.StartsWith("bee_") || k.StartsWith("ufo_")
|| k.StartsWith("dragon_") || k.StartsWith("worm_"))
{
- return "{actor} " + LowerFirst(HumanizeKey(StripCreaturePrefix(k)));
+ return "{actor} " + LowerFirst(HumanizeKey(StripCreaturePrefix(k)))
+ + " in their own strange way" + clauses;
}
if (k.StartsWith("task_unit_"))
@@ -582,54 +741,69 @@ public static class ActivityProse
if (k.Contains("laugh"))
{
- return "{actor} laughs";
+ return "{actor} laughs aloud and lets the mood carry" + clauses;
}
if (k.Contains("sing"))
{
- return "{actor} sings";
+ return "{actor} sings out for whoever will hear" + clauses;
}
if (k.Contains("play") && !k.Contains("display"))
{
- return hasTarget ? "{actor} plays with {target}" : "{actor} plays";
+ return hasTarget
+ ? "{actor} plays with {target} through the moment" + clauses
+ : "{actor} plays with restless living energy" + clauses;
}
if (k.Contains("wait"))
{
- return "{actor} waits";
+ return "{actor} waits in watchful quiet" + clauses;
}
if (k.Contains("sleep"))
{
- return "{actor} sleeps";
+ return "{actor} settles into sleep and lets the world go quiet" + clauses;
}
if (k.Contains("idle") || k.Contains("random_move") || k.EndsWith("_move")
|| k.Contains("walking"))
{
- return "{actor} wanders";
+ return "{actor} wanders without hurry through the living world" + clauses;
}
if (k.Contains("fight") || k.Contains("attack") || k.Contains("hunt"))
{
- return hasTarget ? "{actor} strikes at {target}" : "{actor} fights";
+ return hasTarget
+ ? "{actor} strikes at {target} with clear intent" + clauses
+ : "{actor} fights with urgent focus" + clauses;
}
if (k.Contains("heal") || k.Contains("cure"))
{
- return hasTarget ? "{actor} tends to {target}" : "{actor} tends to wounds";
+ return hasTarget
+ ? "{actor} tends carefully to {target}" + clauses
+ : "{actor} tends to wounds with worried care" + clauses;
}
if (k.Contains("eat") || k.Contains("food"))
{
- return "{actor} seeks a meal";
+ return "{actor} settles in to take a proper meal" + clauses;
}
if (k.Contains("farm") || k.Contains("harvest") || k.Contains("forag")
- || k.Contains("gather") || k.Contains("chop") || k.Contains("mine"))
+ || k.Contains("gather") || k.Contains("chop") || k.Contains("mine")
+ || k.Contains("build") || k.Contains("woodcut"))
{
- return "{actor} " + LowerFirst(HumanizeKey(k));
+ return "{actor} " + LowerFirst(HumanizeKey(k))
+ + " with steady labor" + clauses;
+ }
+
+ if (k.Contains("trade") || k.Contains("fish") || k.Contains("unload")
+ || k.Contains("claim") || k.Contains("fire"))
+ {
+ return "{actor} " + LowerFirst(HumanizeKey(k))
+ + " with full purpose" + clauses;
}
return null;
@@ -639,65 +813,70 @@ public static class ActivityProse
{
if (k.Contains("follow"))
{
- return hasTarget ? "{actor} follows {target}" : "{actor} follows the army";
+ return hasTarget
+ ? "{actor} follows {target} with disciplined steps"
+ : "{actor} follows the army with disciplined steps";
}
if (k.Contains("train"))
{
- return "{actor} trains for war";
+ return "{actor} trains for war with hard repetition";
}
if (k.Contains("join"))
{
- return "{actor} tries to join the army";
+ return "{actor} tries to join the army and take a place in the ranks";
}
if (k.Contains("attack"))
{
- return hasTarget ? "{actor} marches on {target}" : "{actor} marches to attack";
+ return hasTarget
+ ? "{actor} marches on {target} with the warband"
+ : "{actor} marches to attack with the warband";
}
if (k.Contains("wait") || k.Contains("idle"))
{
- return "{actor} holds formation";
+ return "{actor} holds formation and waits for orders";
}
- return "{actor} " + LowerFirst(HumanizeKey(k.Replace("warrior_", "")));
+ return "{actor} " + LowerFirst(HumanizeKey(k.Replace("warrior_", "")))
+ + " among the ranks";
}
private static string BoatVerb(string rest)
{
if (rest.Contains("fish"))
{
- return "fishes from the boat";
+ return "fishes from the boat with patient focus";
}
if (rest.Contains("trade"))
{
- return "trades by boat";
+ return "trades by boat along the water roads";
}
if (rest.Contains("dock") || rest.Contains("return"))
{
- return "returns to dock";
+ return "returns to dock after time on the water";
}
if (rest.Contains("load"))
{
- return "loads the boat";
+ return "loads the boat for the next run";
}
if (rest.Contains("unload"))
{
- return "unloads the boat";
+ return "unloads the boat and clears the hold";
}
if (rest.Contains("idle"))
{
- return "idles aboard";
+ return "idles aboard while the water holds still";
}
- return LowerFirst(HumanizeKey(rest)) + " aboard";
+ return LowerFirst(HumanizeKey(rest)) + " aboard with seafarer's care";
}
private static string StripCreaturePrefix(string k)
@@ -708,27 +887,31 @@ public static class ActivityProse
private static string LocalizedStyleFromId(string rest, bool hasTarget)
{
+ const string clauses = "{place_at}{job_as}";
if (rest == "play")
{
- return "{actor} plays";
+ return "{actor} plays with restless living energy" + clauses;
}
if (rest == "wait")
{
- return "{actor} waits";
+ return "{actor} waits in watchful quiet" + clauses;
}
if (rest == "walk")
{
- return "{actor} walks";
+ return "{actor} walks with easy purpose through the world" + clauses;
}
if (rest.Contains("social"))
{
- return hasTarget ? "{actor} socializes with {target}" : "{actor} socializes";
+ return hasTarget
+ ? "{actor} socializes and shares the moment with {target}" + clauses
+ : "{actor} socializes among whoever is near" + clauses;
}
- return "{actor} " + LowerFirst(HumanizeKey(rest));
+ return "{actor} " + LowerFirst(HumanizeKey(rest))
+ + " through the living moment" + clauses;
}
///
@@ -790,11 +973,12 @@ public static class ActivityProse
// "Laughing" / "Job Seeking" / "Holding Still"
if (IsGerundPhrase(body))
{
- return "{actor} is " + LowerFirst(body);
+ return "{actor} is " + LowerFirst(body)
+ + " through the living moment{place_at}{job_as}";
}
- // Already a short verb phrase
- return "{actor} " + LowerFirst(body);
+ // Already a short verb phrase - still give it place/job room.
+ return "{actor} " + LowerFirst(body) + "{place_at}{job_as}";
}
private static bool IsGerundPhrase(string body)
diff --git a/IdleSpectator/ActivityVerbMap.cs b/IdleSpectator/ActivityVerbMap.cs
new file mode 100644
index 0000000..0d0886d
--- /dev/null
+++ b/IdleSpectator/ActivityVerbMap.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+
+namespace IdleSpectator;
+
+/// Maps every live task shape to an action concept; no creature voice logic.
+public static class ActivityVerbMap
+{
+ public static string Resolve(string key)
+ {
+ if (string.IsNullOrEmpty(key)) return "";
+ string k = key.ToLowerInvariant();
+ if (k.Contains("laugh")) return "laugh";
+ if (k.Contains("sing") || k.Contains("just_sang")) return "sing";
+ if ((k.Contains("play") && !k.Contains("display"))
+ || k.Contains("fun_move") || k.Contains("frolic") || k.StartsWith("child_")) return "play";
+ if (k.Contains("hunt") || k == "behattackactorhuntingtarget") return "hunt";
+ if (k.Contains("fight") || k.Contains("attack") || k.Contains("tantrum")) return "fight";
+ if (k.Contains("social") || k.Contains("talk") || k.Contains("trysocialize")
+ || k.Contains("crying") || k.Contains("just_cried") || k.Contains("swore")
+ || k.Contains("madness")) return "social";
+ if (k.Contains("lover") || k.Contains("breed") || k.Contains("reproduction")
+ || k.Contains("desire")) return "lover";
+ if (k.Contains("farm") || k.Contains("plant") || k.Contains("fertiliz")
+ || k.Contains("harvest") || k.Contains("hoe")) return "farm";
+ if (k == "work" || k.Contains("mine") || k.Contains("woodcut") || k.Contains("chop")
+ || k.Contains("gather") || k.Contains("forag") || k.Contains("build")
+ || k.Contains("construct") || k.Contains("collect_") || k.Contains("cleaning")
+ || k.Contains("make_items") || k.Contains("make_skeleton") || k.Contains("manure")
+ || k.Contains("burn_tumor") || k == "axe" || k == "hammer" || k == "basket"
+ || k == "bucket" || k == "flag" || k == "book" || k == "coffee_cup") return "work";
+ if (k.Contains("pollinat") || k.Contains("bee_") || k == "behpollinate") return "pollinate";
+ if (k.Contains("trade") || k.Contains("barter") || k.Contains("tax")
+ || k.Contains("boat_trading")) return "trade";
+ if (k.Contains("fish")) return "fish";
+ if (k.Contains("unload") || k.Contains("throw_resource") || k.Contains("carrying")) return "haul";
+ if (k.Contains("heal") || k.Contains("cure")) return "heal";
+ if (k.Contains("fire") || k.Contains("fireman") || k.Contains("blaze")) return "fire";
+ if (k.Contains("sleep") || k.Contains("wakeup") || k.Contains("burrow")) return "sleep";
+ if (k.Contains("eat") || k.Contains("food") || k.Contains("meal") || k.StartsWith("diet_")) return "eat";
+ if (k.Contains("run_away") || k.Contains("flee") || k.Contains("retreat")
+ || k.Contains("danger_check") || k.Contains("banish") || k.Contains("kill_unruly")) return "flee";
+ if (k.Contains("job") || k.Contains("employ") || k.Contains("find_house")
+ || k.Contains("claim") || k.Contains("king_") || k.Contains("leader_")
+ || k.Contains("citizen") || k.Contains("join_city") || k.Contains("check_city")
+ || k.Contains("check_plot") || k.Contains("family_check") || k.Contains("embark")
+ || k.Contains("boat_return") || k.Contains("force_into_a_boat")
+ || k.Contains("boat_transport") || k.Contains("boat_check") || k.Contains("investigate"))
+ return "settle";
+ if (k.Contains("warrior") || k.Contains("army") || k.Contains("formation")
+ || k.Contains("invincible")) return "warrior";
+ if (k.Contains("wait") || k == "reflection" || k.Contains("hold_still")
+ || k.Contains("make_decision") || k.Contains("think") || k.Contains("stuck")) return "wait";
+ if (k.Contains("idle") || k.Contains("random_move") || k.Contains("walking")
+ || k.EndsWith("_move") || k.Contains("wander") || k == "move"
+ || k.Contains("dragon_") || k.Contains("godfinger") || k.Contains("follow_")
+ || k.StartsWith("ant_") || k.StartsWith("ufo_") || k.StartsWith("worm_")
+ || k.Contains("slide") || k.Contains("land") || k.Contains("fly")) return "move";
+ if (k.Contains("read")) return "read";
+ if (k.Contains("loot") || k.Contains("family_group")) return "gather_life";
+ return "";
+ }
+
+ public static bool HasActionCore(string key)
+ {
+ return !string.IsNullOrEmpty(Resolve(key))
+ || !string.IsNullOrEmpty(ActivityProse.PatternActionPhrase(key, false))
+ || !string.IsNullOrEmpty(key);
+ }
+
+ public static List EnumerateLiveTaskIds()
+ {
+ return ActivityAssetCatalog.EnumerateLiveTaskIds();
+ }
+}
diff --git a/IdleSpectator/ActivityVoice.cs b/IdleSpectator/ActivityVoice.cs
new file mode 100644
index 0000000..924a35a
--- /dev/null
+++ b/IdleSpectator/ActivityVoice.cs
@@ -0,0 +1,176 @@
+namespace IdleSpectator;
+
+public enum ActivityAssetKind
+{
+ Creature,
+ Vehicle,
+ Special
+}
+
+public enum ActivityModifier
+{
+ None,
+ Undead,
+ Elemental,
+ Mush,
+ Tumor,
+ Alien,
+ Construct
+}
+
+public enum ActivityBodyTag
+{
+ Default,
+ Humanoid,
+ Canine,
+ Feline,
+ Bird,
+ Insect,
+ Arachnid,
+ Amphibian,
+ Aquatic,
+ Reptile,
+ Livestock,
+ Plant,
+ Fungi,
+ Protist,
+ Machina,
+ Mythic,
+ Gregalia,
+ Dragon,
+ Primate,
+ Rodent,
+ Ursid,
+ Seal,
+ Procyonid,
+ Hyaenid,
+ Armadillo,
+ Crawler,
+ Crystal,
+ Cold,
+ Crab,
+ Vehicle,
+ Undead,
+ Elemental,
+ Mush,
+ Tumor,
+ Alien,
+ Construct
+}
+
+public enum ActivityEcologyTag
+{
+ Default,
+ Civilized,
+ Land,
+ Air,
+ Water,
+ Plant,
+ Fungal,
+ Mythic,
+ Machine,
+ Cold,
+ Alien,
+ Undead
+}
+
+/// One immutable interpretation of a live ActorAsset for activity prose.
+public sealed class ProseVoice
+{
+ public string LiveSpeciesId { get; }
+ public string BaseSpeciesId { get; }
+ public string BaseFamily { get; }
+ public ActivityBodyTag BaseBodyTag { get; }
+ public ActivityEcologyTag EcologyTag { get; }
+ public ActivityModifier Modifier { get; }
+ public ActivityBodyTag EffectiveActionTag { get; }
+ public ActivityAssetKind AssetKind { get; }
+ public bool BaseIsCiv { get; }
+ public bool BaseIsHumanoid { get; }
+ public bool BaseIsAnimal { get; }
+ public string FlavorKey { get; }
+ public int FlavorOrdinal { get; }
+ public string ProfileTag { get; }
+ public bool BaseRecoveryExpected { get; }
+ public bool BaseRecoverySucceeded { get; }
+
+ public ProseVoice(
+ string liveSpeciesId,
+ string baseSpeciesId,
+ string baseFamily,
+ ActivityBodyTag baseBodyTag,
+ ActivityEcologyTag ecologyTag,
+ ActivityModifier modifier,
+ ActivityBodyTag effectiveActionTag,
+ ActivityAssetKind assetKind,
+ bool baseIsCiv,
+ bool baseIsHumanoid,
+ bool baseIsAnimal,
+ string flavorKey,
+ int flavorOrdinal,
+ string profileTag,
+ bool baseRecoveryExpected,
+ bool baseRecoverySucceeded)
+ {
+ LiveSpeciesId = liveSpeciesId ?? "";
+ BaseSpeciesId = baseSpeciesId ?? LiveSpeciesId;
+ BaseFamily = string.IsNullOrEmpty(baseFamily) ? ActivityVoiceFamilies.Default : baseFamily;
+ BaseBodyTag = baseBodyTag;
+ EcologyTag = ecologyTag;
+ Modifier = modifier;
+ EffectiveActionTag = effectiveActionTag;
+ AssetKind = assetKind;
+ BaseIsCiv = baseIsCiv;
+ BaseIsHumanoid = baseIsHumanoid;
+ BaseIsAnimal = baseIsAnimal;
+ FlavorKey = string.IsNullOrEmpty(flavorKey) ? LiveSpeciesId : flavorKey;
+ FlavorOrdinal = flavorOrdinal;
+ ProfileTag = profileTag ?? "";
+ BaseRecoveryExpected = baseRecoveryExpected;
+ BaseRecoverySucceeded = baseRecoverySucceeded;
+ }
+
+ public static ProseVoice Unknown(string speciesId = "")
+ {
+ return new ProseVoice(
+ speciesId,
+ speciesId,
+ ActivityVoiceFamilies.Default,
+ ActivityBodyTag.Default,
+ ActivityEcologyTag.Default,
+ ActivityModifier.None,
+ ActivityBodyTag.Default,
+ ActivityAssetKind.Creature,
+ false,
+ false,
+ false,
+ speciesId,
+ 0,
+ "",
+ false,
+ false);
+ }
+}
+
+public static class ActivityVoiceFamilies
+{
+ public const string Default = "default";
+ public const string Humanoid = "humanoidCiv";
+ public const string Canine = "canine";
+ public const string Feline = "feline";
+ public const string Bird = "bird";
+ public const string Insect = "insect";
+ public const string Arachnid = "arachnid";
+ public const string Amphibian = "amphibian";
+ public const string Aquatic = "aquatic";
+ public const string Reptile = "reptile";
+ public const string Livestock = "livestock";
+ public const string Plant = "plant";
+ public const string Fungi = "fungi";
+ public const string Protist = "protist";
+ public const string Machina = "machina";
+ public const string Mythic = "mythic";
+ public const string Gregalia = "gregalia";
+ public const string Mammal = "mammal";
+ public const string Special = "special";
+}
diff --git a/IdleSpectator/ActivityVoiceHarness.cs b/IdleSpectator/ActivityVoiceHarness.cs
new file mode 100644
index 0000000..78fba01
--- /dev/null
+++ b/IdleSpectator/ActivityVoiceHarness.cs
@@ -0,0 +1,384 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace IdleSpectator;
+
+public sealed class ActivityVoiceAuditResult
+{
+ public bool Passed;
+ public string Detail = "";
+}
+
+/// Exhaustive live-library gates for voice classification and prose fingerprints.
+public static class ActivityVoiceHarness
+{
+ private static readonly string[] FingerprintKeys =
+ {
+ "move", "wait", "task_unit_play", "task_eat", "task_sleep",
+ "task_hunt", "task_attack", "social", "laugh", "work"
+ };
+
+ public static ActivityVoiceAuditResult RunAudit(string outputDirectory)
+ {
+ List ids = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
+ var rows = new StringBuilder(32768);
+ rows.AppendLine("id\tbase\tkind\tmodifier\tfamily\tbody\teffective\tecology\tordinal\trecovery\tfingerprint");
+
+ int missing = 0;
+ int defaults = 0;
+ int emptyProse = 0;
+ int leaks = 0;
+ int recoveryFail = 0;
+ int modifierFail = 0;
+ int aquaticFalsePositive = 0;
+ int collisions = 0;
+ int vehicleMismatch = 0;
+ string sample = "";
+ string vehicleFingerprint = null;
+ var fingerprints = new Dictionary(StringComparer.Ordinal);
+
+ for (int i = 0; i < ids.Count; i++)
+ {
+ string id = ids[i];
+ ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(id);
+ if (asset == null)
+ {
+ missing++;
+ AddSample(ref sample, id + ":missing");
+ continue;
+ }
+
+ ProseVoice voice = ActivityVoiceResolver.Resolve(asset);
+ if (voice.EffectiveActionTag == ActivityBodyTag.Default
+ || string.IsNullOrEmpty(voice.BaseFamily)
+ || voice.BaseFamily == ActivityVoiceFamilies.Default)
+ {
+ defaults++;
+ AddSample(ref sample, id + ":default");
+ }
+
+ if (voice.BaseRecoveryExpected && !voice.BaseRecoverySucceeded)
+ {
+ recoveryFail++;
+ AddSample(ref sample, id + ":base?");
+ }
+
+ if (!ExpectedModifierMatches(asset, voice))
+ {
+ modifierFail++;
+ AddSample(ref sample, id + ":modifier=" + voice.Modifier);
+ }
+
+ if (MustNotBeAquatic(id)
+ && (voice.BaseBodyTag == ActivityBodyTag.Aquatic
+ || voice.EcologyTag == ActivityEcologyTag.Water
+ && voice.AssetKind != ActivityAssetKind.Vehicle))
+ {
+ aquaticFalsePositive++;
+ AddSample(ref sample, id + ":aquatic");
+ }
+
+ int localEmpty;
+ int localLeaks;
+ string fingerprint = Fingerprint(id, out localEmpty, out localLeaks);
+ emptyProse += localEmpty;
+ leaks += localLeaks;
+
+ if (voice.AssetKind == ActivityAssetKind.Vehicle)
+ {
+ if (vehicleFingerprint == null)
+ {
+ vehicleFingerprint = fingerprint;
+ }
+ else if (!vehicleFingerprint.Equals(fingerprint, StringComparison.Ordinal))
+ {
+ vehicleMismatch++;
+ AddSample(ref sample, id + ":vehicle-fingerprint");
+ }
+ }
+ else if (fingerprints.TryGetValue(fingerprint, out string prior))
+ {
+ collisions++;
+ AddSample(ref sample, prior + "=" + id);
+ }
+ else
+ {
+ fingerprints[fingerprint] = id;
+ }
+
+ rows.Append(id).Append('\t')
+ .Append(voice.BaseSpeciesId).Append('\t')
+ .Append(voice.AssetKind).Append('\t')
+ .Append(voice.Modifier).Append('\t')
+ .Append(voice.BaseFamily).Append('\t')
+ .Append(voice.BaseBodyTag).Append('\t')
+ .Append(voice.EffectiveActionTag).Append('\t')
+ .Append(voice.EcologyTag).Append('\t')
+ .Append(voice.FlavorOrdinal).Append('\t')
+ .Append(voice.BaseRecoveryExpected ? voice.BaseRecoverySucceeded.ToString() : "n/a").Append('\t')
+ .Append(fingerprint.Replace('\t', ' ')).Append('\n');
+ }
+
+ int pairFail = PairwiseFailures(ref sample);
+ bool liveContextOk = VerifyLiveContext(ref sample);
+ try
+ {
+ Directory.CreateDirectory(outputDirectory);
+ File.WriteAllText(Path.Combine(outputDirectory, "activity-voice-audit.tsv"), rows.ToString());
+ }
+ catch (Exception ex)
+ {
+ AddSample(ref sample, "write:" + ex.Message);
+ }
+
+ bool passed = ids.Count > 50
+ && missing == 0
+ && defaults == 0
+ && emptyProse == 0
+ && leaks == 0
+ && recoveryFail == 0
+ && modifierFail == 0
+ && aquaticFalsePositive == 0
+ && collisions == 0
+ && vehicleMismatch == 0
+ && pairFail == 0
+ && liveContextOk;
+ return new ActivityVoiceAuditResult
+ {
+ Passed = passed,
+ Detail = "total=" + ids.Count
+ + " missing=" + missing
+ + " defaults=" + defaults
+ + " empty=" + emptyProse
+ + " leaks=" + leaks
+ + " recoveryFail=" + recoveryFail
+ + " modifierFail=" + modifierFail
+ + " aquaticFalsePositive=" + aquaticFalsePositive
+ + " collisions=" + collisions
+ + " vehicleMismatch=" + vehicleMismatch
+ + " pairFail=" + pairFail
+ + " liveContextOk=" + liveContextOk
+ + " sample='" + sample + "'"
+ };
+ }
+
+ private static bool VerifyLiveContext(ref string sample)
+ {
+ try
+ {
+ Actor actor = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
+ if (actor == null || actor.asset == null)
+ {
+ AddSample(ref sample, "live-context:no-focus");
+ return false;
+ }
+
+ ActivityContext ctx = ActivityInterestTable.BuildContext(actor, "move");
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "move",
+ ctx,
+ "Moving",
+ actor.getID(),
+ 1,
+ out string line,
+ out _);
+ bool ok = ctx.Voice != null
+ && ctx.Voice.LiveSpeciesId == (actor.asset.id ?? "")
+ && ctx.Voice.EffectiveActionTag != ActivityBodyTag.Default
+ && !string.IsNullOrEmpty(line);
+ if (!ok)
+ {
+ AddSample(ref sample, "live-context:" + (actor.asset.id ?? "") + ":" + line);
+ }
+
+ return ok;
+ }
+ catch (Exception ex)
+ {
+ AddSample(ref sample, "live-context:" + ex.Message);
+ return false;
+ }
+ }
+
+ public static string Fingerprint(string speciesId, out int empty, out int leaks)
+ {
+ empty = 0;
+ leaks = 0;
+ var corpus = new StringBuilder();
+ ActivityContext probe = ActivityContext.ForHarness("VoiceActor", "VoiceTarget", speciesId);
+ string speciesLabel = probe.SpeciesLabel ?? "";
+
+ for (int i = 0; i < FingerprintKeys.Length; i++)
+ {
+ string key = FingerprintKeys[i];
+ bool target = key == "task_hunt" || key == "task_attack" || key == "social";
+ ActivityContext ctx = ActivityContext.ForHarness(
+ "VoiceActor",
+ target ? "VoiceTarget" : "",
+ speciesId,
+ place: "VoicePlace",
+ carrying: "VoiceCargo",
+ jobId: "VoiceJob",
+ taskKey: key,
+ traitId: "VoiceTrait");
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ key,
+ ctx,
+ key,
+ 4242,
+ i + 1,
+ out string line,
+ out _);
+ if (string.IsNullOrEmpty(line))
+ {
+ empty++;
+ }
+
+ if (ContainsSpeciesOrTrait(line, speciesId, speciesLabel, "VoiceTrait"))
+ {
+ leaks++;
+ }
+
+ corpus.Append(Normalize(line, speciesId, speciesLabel)).Append('|');
+ }
+
+ return corpus.ToString();
+ }
+
+ private static int PairwiseFailures(ref string sample)
+ {
+ string[,] pairs =
+ {
+ { "human", "zombie_human" },
+ { "dog", "zombie_animal_dog" },
+ { "lemon_snail", "zombie_animal_lemon_snail" },
+ { "dragon", "zombie_dragon" },
+ { "fairy", "zombie_animal_fairy" },
+ { "fire_elemental_horse", "fire_elemental_snake" }
+ };
+
+ int failures = 0;
+ for (int i = 0; i < pairs.GetLength(0); i++)
+ {
+ string a = pairs[i, 0];
+ string b = pairs[i, 1];
+ if (!ActivityAssetCatalog.IsLiveActorAssetId(a)
+ || !ActivityAssetCatalog.IsLiveActorAssetId(b))
+ {
+ failures++;
+ AddSample(ref sample, a + "/" + b + ":missing-pair");
+ continue;
+ }
+
+ string fa = Fingerprint(a, out _, out _);
+ string fb = Fingerprint(b, out _, out _);
+ if (fa.Equals(fb, StringComparison.Ordinal))
+ {
+ failures++;
+ AddSample(ref sample, a + "=" + b);
+ }
+ }
+
+ List ids = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
+ string boat = null;
+ for (int i = 0; i < ids.Count; i++)
+ {
+ if (!ids[i].StartsWith("boat_", StringComparison.OrdinalIgnoreCase)) continue;
+ string fp = Fingerprint(ids[i], out _, out _);
+ if (boat == null) boat = fp;
+ else if (!boat.Equals(fp, StringComparison.Ordinal))
+ {
+ failures++;
+ AddSample(ref sample, ids[i] + ":boat-pair");
+ break;
+ }
+ }
+
+ return failures;
+ }
+
+ private static string Normalize(string line, string speciesId, string speciesLabel)
+ {
+ string text = Regex.Replace(line ?? "", "<[^>]+>", "");
+ text = ReplaceIgnoreCase(text, "VoiceActor", "");
+ text = ReplaceIgnoreCase(text, "VoiceTarget", "");
+ text = ReplaceIgnoreCase(text, "VoicePlace", "");
+ text = ReplaceIgnoreCase(text, "VoiceCargo", "");
+ text = ReplaceIgnoreCase(text, "VoiceJob", "");
+ text = ReplaceIgnoreCase(text, "VoiceTrait", "");
+ text = ReplaceIgnoreCase(text, speciesLabel, "");
+ text = ReplaceIgnoreCase(text, (speciesId ?? "").Replace('_', ' '), "");
+ text = Regex.Replace(text.ToLowerInvariant(), "[^a-z0-9]+", " ").Trim();
+ return text;
+ }
+
+ private static bool ContainsSpeciesOrTrait(
+ string line,
+ string speciesId,
+ string speciesLabel,
+ string trait)
+ {
+ if (string.IsNullOrEmpty(line)) return false;
+ if (!string.IsNullOrEmpty(trait)
+ && line.IndexOf(trait, StringComparison.OrdinalIgnoreCase) >= 0) return true;
+ if (!string.IsNullOrEmpty(speciesLabel)
+ && line.IndexOf(" the " + speciesLabel, StringComparison.OrdinalIgnoreCase) >= 0) return true;
+ string idWords = (speciesId ?? "").Replace('_', ' ');
+ return !string.IsNullOrEmpty(idWords)
+ && line.IndexOf(" the " + idWords, StringComparison.OrdinalIgnoreCase) >= 0;
+ }
+
+ private static bool ExpectedModifierMatches(ActorAsset asset, ProseVoice voice)
+ {
+ string id = asset.id ?? "";
+ string wild = asset.kingdom_id_wild ?? "";
+ if (id.StartsWith("boat_", StringComparison.OrdinalIgnoreCase))
+ return voice.AssetKind == ActivityAssetKind.Vehicle;
+ if (wild.Equals("undead", StringComparison.OrdinalIgnoreCase)
+ || id.StartsWith("zombie_", StringComparison.OrdinalIgnoreCase))
+ return voice.Modifier == ActivityModifier.Undead;
+ if (wild.Equals("fire_elemental", StringComparison.OrdinalIgnoreCase)
+ || id.StartsWith("fire_elemental", StringComparison.OrdinalIgnoreCase))
+ return voice.Modifier == ActivityModifier.Elemental;
+ if (id.StartsWith("mush_", StringComparison.OrdinalIgnoreCase))
+ return voice.Modifier == ActivityModifier.Mush;
+ if (id.StartsWith("tumor_", StringComparison.OrdinalIgnoreCase))
+ return voice.Modifier == ActivityModifier.Tumor;
+ if (wild.Equals("aliens", StringComparison.OrdinalIgnoreCase))
+ return voice.Modifier == ActivityModifier.Alien;
+ if (wild.Equals("living_houses", StringComparison.OrdinalIgnoreCase))
+ return voice.Modifier == ActivityModifier.Construct;
+ if (id.Equals("assimilator", StringComparison.OrdinalIgnoreCase)
+ || id.Equals("printer", StringComparison.OrdinalIgnoreCase)
+ || id.Equals("god_finger", StringComparison.OrdinalIgnoreCase)
+ || id.Equals("crystal_sword", StringComparison.OrdinalIgnoreCase))
+ return voice.Modifier == ActivityModifier.Construct;
+ return true;
+ }
+
+ private static bool MustNotBeAquatic(string id)
+ {
+ return id.StartsWith("ant_", StringComparison.OrdinalIgnoreCase)
+ || id.IndexOf("spider", StringComparison.OrdinalIgnoreCase) >= 0
+ || id.Equals("worm", StringComparison.OrdinalIgnoreCase)
+ || id.Equals("printer", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string ReplaceIgnoreCase(string text, string value, string replacement)
+ {
+ if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(value)) return text;
+ return Regex.Replace(text, Regex.Escape(value), replacement ?? "", RegexOptions.IgnoreCase);
+ }
+
+ private static void AddSample(ref string sample, string value)
+ {
+ if (sample.Length >= 500) return;
+ if (sample.Length > 0) sample += ";";
+ sample += value;
+ }
+}
diff --git a/IdleSpectator/ActivityVoiceProfiles.cs b/IdleSpectator/ActivityVoiceProfiles.cs
new file mode 100644
index 0000000..feff24f
--- /dev/null
+++ b/IdleSpectator/ActivityVoiceProfiles.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+
+namespace IdleSpectator;
+
+/// Thin flavor identities for creatures whose voice should be unmistakable.
+public static class ActivityVoiceProfiles
+{
+ private static readonly Dictionary Profiles =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["dragon"] = "dragon",
+ ["fairy"] = "fairy",
+ ["cat"] = "cat",
+ ["dog"] = "dog",
+ ["buffalo"] = "buffalo",
+ ["lemon_snail"] = "lemon_snail",
+ ["human"] = "human",
+ ["elf"] = "elf",
+ ["dwarf"] = "dwarf",
+ ["orc"] = "orc",
+ ["crabzilla"] = "crabzilla",
+ ["god_finger"] = "god_finger",
+ ["greg"] = "greg",
+ ["angle"] = "angle",
+ ["cold_one"] = "cold",
+ ["snowman"] = "cold",
+ ["crystal_sword"] = "crystal"
+ };
+
+ public static string Resolve(string baseSpeciesId)
+ {
+ if (string.IsNullOrEmpty(baseSpeciesId))
+ {
+ return "";
+ }
+
+ return Profiles.TryGetValue(baseSpeciesId, out string profile) ? profile : "";
+ }
+}
diff --git a/IdleSpectator/ActivityVoiceResolver.cs b/IdleSpectator/ActivityVoiceResolver.cs
new file mode 100644
index 0000000..4d4736c
--- /dev/null
+++ b/IdleSpectator/ActivityVoiceResolver.cs
@@ -0,0 +1,468 @@
+using System;
+using System.Collections.Generic;
+
+namespace IdleSpectator;
+
+/// The only ActorAsset → prose voice classifier.
+public static class ActivityVoiceResolver
+{
+ private static readonly Dictionary FlavorOrdinals =
+ new Dictionary(StringComparer.Ordinal);
+ private static int _ordinalLibraryCount = -1;
+
+ public static ProseVoice Resolve(string speciesId)
+ {
+ ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(speciesId);
+ return asset != null ? Resolve(asset) : ProseVoice.Unknown(speciesId);
+ }
+
+ public static ProseVoice Resolve(ActorAsset liveAsset)
+ {
+ if (liveAsset == null)
+ {
+ return ProseVoice.Unknown();
+ }
+
+ string liveId = liveAsset.id ?? "";
+ int flavorOrdinal = FlavorOrdinal(liveId);
+
+ if (IsVehicle(liveId))
+ {
+ return Build(
+ liveAsset,
+ liveAsset,
+ ActivityAssetKind.Vehicle,
+ ActivityModifier.None,
+ ActivityBodyTag.Vehicle,
+ ActivityEcologyTag.Water,
+ ActivityVoiceFamilies.Special,
+ flavorOrdinal,
+ false,
+ true);
+ }
+
+ ActivityModifier modifier = ResolveModifier(liveAsset);
+ bool recoveryExpected = modifier == ActivityModifier.Undead
+ && liveId.StartsWith("zombie_", StringComparison.OrdinalIgnoreCase);
+ ActorAsset baseAsset = ResolveBaseAsset(liveAsset, modifier, out bool recoverySucceeded);
+ if (baseAsset == null)
+ {
+ baseAsset = liveAsset;
+ }
+
+ ActivityBodyTag body = ResolveBody(baseAsset, liveAsset, modifier);
+ ActivityEcologyTag ecology = ResolveEcology(baseAsset, body, modifier);
+ string family = ResolveFamily(baseAsset, body);
+ ActivityBodyTag effective = EffectiveTag(modifier, body);
+ ActivityAssetKind kind = IsExplicitSpecial(liveId)
+ ? ActivityAssetKind.Special
+ : ActivityAssetKind.Creature;
+
+ return Build(
+ liveAsset,
+ baseAsset,
+ kind,
+ modifier,
+ effective,
+ ecology,
+ family,
+ flavorOrdinal,
+ recoveryExpected,
+ recoverySucceeded);
+ }
+
+ public static void ApplyToContext(ActivityContext ctx, ActorAsset liveAsset)
+ {
+ if (ctx == null)
+ {
+ return;
+ }
+
+ ProseVoice voice = Resolve(liveAsset);
+ ctx.Voice = voice;
+ ctx.SpeciesId = voice.LiveSpeciesId;
+ ctx.BaseSpeciesId = voice.BaseSpeciesId;
+ ctx.Family = voice.BaseFamily;
+ ctx.MannerTag = TagName(voice.EffectiveActionTag);
+ ctx.ModifierTag = ModifierName(voice.Modifier);
+ ctx.IsCiv = voice.BaseIsCiv;
+ ctx.IsHumanoid = voice.BaseIsHumanoid;
+ ctx.IsAnimal = voice.BaseIsAnimal;
+
+ if (liveAsset != null)
+ {
+ ctx.TaxonomicKingdom = liveAsset.name_taxonomic_kingdom ?? "";
+ ctx.TaxonomicPhylum = liveAsset.name_taxonomic_phylum ?? "";
+ ctx.TaxonomicClass = liveAsset.name_taxonomic_class ?? "";
+ ctx.TaxonomicOrder = liveAsset.name_taxonomic_order ?? "";
+ ctx.TaxonomicFamily = liveAsset.name_taxonomic_family ?? "";
+ ctx.TaxonomicGenus = liveAsset.name_taxonomic_genus ?? "";
+ ctx.TaxonomicSpecies = liveAsset.name_taxonomic_species ?? "";
+ }
+ }
+
+ public static string TagName(ActivityBodyTag tag)
+ {
+ return tag.ToString().ToLowerInvariant();
+ }
+
+ public static string ModifierName(ActivityModifier modifier)
+ {
+ return modifier == ActivityModifier.None ? "" : modifier.ToString().ToLowerInvariant();
+ }
+
+ private static ProseVoice Build(
+ ActorAsset liveAsset,
+ ActorAsset baseAsset,
+ ActivityAssetKind kind,
+ ActivityModifier modifier,
+ ActivityBodyTag effective,
+ ActivityEcologyTag ecology,
+ string family,
+ int flavorOrdinal,
+ bool recoveryExpected,
+ bool recoverySucceeded)
+ {
+ string liveId = liveAsset?.id ?? "";
+ string baseId = baseAsset?.id ?? liveId;
+ ActivityBodyTag baseBody = kind == ActivityAssetKind.Vehicle
+ ? ActivityBodyTag.Vehicle
+ : ResolveBody(baseAsset, liveAsset, modifier);
+ return new ProseVoice(
+ liveId,
+ baseId,
+ family,
+ baseBody,
+ ecology,
+ modifier,
+ effective,
+ kind,
+ baseAsset != null && baseAsset.civ,
+ baseAsset != null && (baseAsset.is_humanoid || baseAsset.civ),
+ baseAsset != null && baseAsset.default_animal,
+ liveId,
+ flavorOrdinal,
+ ActivityVoiceProfiles.Resolve(baseId),
+ recoveryExpected,
+ recoverySucceeded);
+ }
+
+ private static ActivityModifier ResolveModifier(ActorAsset asset)
+ {
+ string id = asset?.id ?? "";
+ string wild = asset?.kingdom_id_wild ?? "";
+
+ if (EqualsIgnore(wild, "undead")
+ || id.StartsWith("zombie_", StringComparison.OrdinalIgnoreCase)
+ || EqualsIgnore(id, "zombie")
+ || EqualsIgnore(id, "skeleton")
+ || EqualsIgnore(id, "ghost"))
+ {
+ return ActivityModifier.Undead;
+ }
+
+ if (EqualsIgnore(wild, "fire_elemental")
+ || id.StartsWith("fire_elemental", StringComparison.OrdinalIgnoreCase))
+ {
+ return ActivityModifier.Elemental;
+ }
+
+ if (EqualsIgnore(wild, "mush") || id.StartsWith("mush_", StringComparison.OrdinalIgnoreCase))
+ {
+ return ActivityModifier.Mush;
+ }
+
+ if (EqualsIgnore(wild, "tumor") || id.StartsWith("tumor_", StringComparison.OrdinalIgnoreCase))
+ {
+ return ActivityModifier.Tumor;
+ }
+
+ if (EqualsIgnore(wild, "aliens") || EqualsIgnore(id, "alien") || EqualsIgnore(id, "ufo"))
+ {
+ return ActivityModifier.Alien;
+ }
+
+ if (EqualsIgnore(wild, "living_houses")
+ || EqualsIgnore(id, "living_house")
+ || EqualsIgnore(id, "assimilator")
+ || EqualsIgnore(id, "printer")
+ || EqualsIgnore(id, "god_finger")
+ || EqualsIgnore(id, "crystal_sword"))
+ {
+ return ActivityModifier.Construct;
+ }
+
+ return ActivityModifier.None;
+ }
+
+ private static ActorAsset ResolveBaseAsset(
+ ActorAsset liveAsset,
+ ActivityModifier modifier,
+ out bool recoverySucceeded)
+ {
+ recoverySucceeded = true;
+ if (liveAsset == null || modifier != ActivityModifier.Undead)
+ {
+ return liveAsset;
+ }
+
+ string id = liveAsset.id ?? "";
+ string candidate = "";
+ if (id.StartsWith("zombie_civ_", StringComparison.OrdinalIgnoreCase))
+ {
+ candidate = id.Substring("zombie_".Length);
+ }
+ else if (id.StartsWith("zombie_animal_", StringComparison.OrdinalIgnoreCase))
+ {
+ candidate = id.Substring("zombie_animal_".Length);
+ }
+ else if (id.StartsWith("zombie_", StringComparison.OrdinalIgnoreCase))
+ {
+ candidate = id.Substring("zombie_".Length);
+ }
+
+ if (string.IsNullOrEmpty(candidate))
+ {
+ return liveAsset;
+ }
+
+ ActorAsset baseAsset = ActivityAssetCatalog.TryGetActorAsset(candidate);
+ recoverySucceeded = baseAsset != null;
+ return baseAsset ?? liveAsset;
+ }
+
+ private static ActivityBodyTag ResolveBody(
+ ActorAsset baseAsset,
+ ActorAsset liveAsset,
+ ActivityModifier modifier)
+ {
+ string id = baseAsset?.id ?? liveAsset?.id ?? "";
+ string liveId = liveAsset?.id ?? id;
+ string taxClass = baseAsset?.name_taxonomic_class ?? "";
+ string taxOrder = baseAsset?.name_taxonomic_order ?? "";
+ string taxFamily = baseAsset?.name_taxonomic_family ?? "";
+
+ if (IsVehicle(liveId)) return ActivityBodyTag.Vehicle;
+ if (EqualsIgnore(id, "dragon")) return ActivityBodyTag.Dragon;
+ if (EqualsIgnore(id, "crabzilla")) return ActivityBodyTag.Crab;
+ if (EqualsIgnore(id, "crystal_sword")) return ActivityBodyTag.Crystal;
+ if (EqualsIgnore(id, "snowman") || EqualsIgnore(id, "cold_one")) return ActivityBodyTag.Cold;
+ if (EqualsIgnore(id, "living_plants")) return ActivityBodyTag.Plant;
+ if (EqualsIgnore(id, "living_house") || EqualsIgnore(id, "god_finger")) return ActivityBodyTag.Construct;
+ if (EqualsIgnore(id, "assimilator") || EqualsIgnore(id, "printer")) return ActivityBodyTag.Machina;
+ if (EqualsIgnore(id, "sand_spider")) return ActivityBodyTag.Arachnid;
+ if (EqualsIgnore(id, "worm")) return ActivityBodyTag.Crawler;
+ if (id.StartsWith("ant_", StringComparison.OrdinalIgnoreCase)) return ActivityBodyTag.Insect;
+
+ if (modifier == ActivityModifier.Elemental)
+ {
+ if (liveId.EndsWith("_horse", StringComparison.OrdinalIgnoreCase)) return ActivityBodyTag.Livestock;
+ if (liveId.EndsWith("_snake", StringComparison.OrdinalIgnoreCase)) return ActivityBodyTag.Reptile;
+ if (liveId.EndsWith("_slug", StringComparison.OrdinalIgnoreCase)) return ActivityBodyTag.Crawler;
+ if (liveId.EndsWith("_blob", StringComparison.OrdinalIgnoreCase)) return ActivityBodyTag.Protist;
+ return ActivityBodyTag.Mythic;
+ }
+
+ if (modifier == ActivityModifier.Mush) return ActivityBodyTag.Fungi;
+ if (modifier == ActivityModifier.Tumor) return ActivityBodyTag.Tumor;
+ if (modifier == ActivityModifier.Alien) return ActivityBodyTag.Alien;
+
+ if (baseAsset != null && baseAsset.civ) return ActivityBodyTag.Humanoid;
+ if (EqualsIgnore(taxClass, "aves")) return ActivityBodyTag.Bird;
+ if (EqualsIgnore(taxClass, "insecta")
+ || EqualsIgnore(taxOrder, "hymenoptera")
+ || EqualsIgnore(taxOrder, "coleoptera")
+ || EqualsIgnore(taxOrder, "lepidoptera")
+ || EqualsIgnore(taxOrder, "diptera")
+ || EqualsIgnore(taxOrder, "orthoptera")) return ActivityBodyTag.Insect;
+ if (EqualsIgnore(taxClass, "arachnida")
+ || EqualsIgnore(taxOrder, "scorpiones")
+ || EqualsIgnore(taxOrder, "araneae")) return ActivityBodyTag.Arachnid;
+ if (EqualsIgnore(taxClass, "amphibia")) return ActivityBodyTag.Amphibian;
+ if (EqualsIgnore(taxClass, "malacostraca")
+ || EqualsIgnore(taxClass, "actinopterygii")
+ || EqualsIgnore(taxClass, "chondrichthyes")
+ || EqualsIgnore(taxClass, "cephalopoda")
+ || EqualsIgnore(taxOrder, "testudines")) return ActivityBodyTag.Aquatic;
+ if (EqualsIgnore(taxClass, "reptilia")
+ || EqualsIgnore(taxOrder, "squamata")
+ || EqualsIgnore(taxOrder, "crocodilia")) return ActivityBodyTag.Reptile;
+ if (EqualsIgnore(taxFamily, "canidae")) return ActivityBodyTag.Canine;
+ if (EqualsIgnore(taxFamily, "felidae")) return ActivityBodyTag.Feline;
+ if (EqualsIgnore(taxFamily, "ursidae")) return ActivityBodyTag.Ursid;
+ if (EqualsIgnore(taxFamily, "phocidae")) return ActivityBodyTag.Seal;
+ if (EqualsIgnore(taxFamily, "procyonidae")) return ActivityBodyTag.Procyonid;
+ if (EqualsIgnore(taxFamily, "hyaenidae")) return ActivityBodyTag.Hyaenid;
+ if (EqualsIgnore(taxOrder, "primates")) return ActivityBodyTag.Primate;
+ if (EqualsIgnore(taxOrder, "rodentia")) return ActivityBodyTag.Rodent;
+ if (EqualsIgnore(taxOrder, "cingulata") || EqualsIgnore(taxFamily, "dasypodidae"))
+ return ActivityBodyTag.Armadillo;
+ if (EqualsIgnore(taxOrder, "artiodactyla")
+ || EqualsIgnore(taxOrder, "perissodactyla")
+ || EqualsIgnore(taxFamily, "bovidae")
+ || EqualsIgnore(taxFamily, "suidae")
+ || EqualsIgnore(taxFamily, "leporidae")
+ || EqualsIgnore(taxFamily, "elephantidae")
+ || EqualsIgnore(taxFamily, "rhinocerotidae")
+ || EqualsIgnore(taxFamily, "camelidae")
+ || EqualsIgnore(taxFamily, "equidae")) return ActivityBodyTag.Livestock;
+
+ string kingdom = baseAsset?.name_taxonomic_kingdom ?? "";
+ if (EqualsIgnore(kingdom, "plantae")) return ActivityBodyTag.Plant;
+ if (EqualsIgnore(kingdom, "fungi")) return ActivityBodyTag.Fungi;
+ if (EqualsIgnore(kingdom, "protista")) return ActivityBodyTag.Protist;
+ if (EqualsIgnore(kingdom, "machina")) return ActivityBodyTag.Machina;
+ if (EqualsIgnore(kingdom, "mythoria")) return ActivityBodyTag.Mythic;
+ if (EqualsIgnore(kingdom, "gregalia")) return ActivityBodyTag.Gregalia;
+
+ // All remaining current animalia assets are terrestrial mammals or explicit specials.
+ if (EqualsIgnore(taxClass, "mammalia")) return ActivityBodyTag.Livestock;
+ if (!string.IsNullOrEmpty(id)) return ActivityBodyTag.Mythic;
+ return ActivityBodyTag.Default;
+ }
+
+ private static string ResolveFamily(ActorAsset asset, ActivityBodyTag body)
+ {
+ if (asset != null && asset.civ) return ActivityVoiceFamilies.Humanoid;
+ string kingdom = asset?.name_taxonomic_kingdom ?? "";
+ if (EqualsIgnore(kingdom, "plantae")) return ActivityVoiceFamilies.Plant;
+ if (EqualsIgnore(kingdom, "fungi")) return ActivityVoiceFamilies.Fungi;
+ if (EqualsIgnore(kingdom, "protista")) return ActivityVoiceFamilies.Protist;
+ if (EqualsIgnore(kingdom, "machina")) return ActivityVoiceFamilies.Machina;
+ if (EqualsIgnore(kingdom, "mythoria")) return ActivityVoiceFamilies.Mythic;
+ if (EqualsIgnore(kingdom, "gregalia")) return ActivityVoiceFamilies.Gregalia;
+
+ return body switch
+ {
+ ActivityBodyTag.Canine => ActivityVoiceFamilies.Canine,
+ ActivityBodyTag.Feline => ActivityVoiceFamilies.Feline,
+ ActivityBodyTag.Bird => ActivityVoiceFamilies.Bird,
+ ActivityBodyTag.Insect => ActivityVoiceFamilies.Insect,
+ ActivityBodyTag.Arachnid => ActivityVoiceFamilies.Arachnid,
+ ActivityBodyTag.Amphibian => ActivityVoiceFamilies.Amphibian,
+ ActivityBodyTag.Aquatic => ActivityVoiceFamilies.Aquatic,
+ ActivityBodyTag.Seal => ActivityVoiceFamilies.Aquatic,
+ ActivityBodyTag.Reptile => ActivityVoiceFamilies.Reptile,
+ ActivityBodyTag.Livestock => ActivityVoiceFamilies.Livestock,
+ ActivityBodyTag.Plant => ActivityVoiceFamilies.Plant,
+ ActivityBodyTag.Fungi => ActivityVoiceFamilies.Fungi,
+ ActivityBodyTag.Protist => ActivityVoiceFamilies.Protist,
+ ActivityBodyTag.Machina => ActivityVoiceFamilies.Machina,
+ ActivityBodyTag.Mythic => ActivityVoiceFamilies.Mythic,
+ ActivityBodyTag.Dragon => ActivityVoiceFamilies.Mythic,
+ ActivityBodyTag.Cold => ActivityVoiceFamilies.Mythic,
+ ActivityBodyTag.Crystal => ActivityVoiceFamilies.Special,
+ ActivityBodyTag.Crab => ActivityVoiceFamilies.Special,
+ ActivityBodyTag.Alien => ActivityVoiceFamilies.Special,
+ ActivityBodyTag.Construct => ActivityVoiceFamilies.Special,
+ ActivityBodyTag.Tumor => ActivityVoiceFamilies.Special,
+ ActivityBodyTag.Gregalia => ActivityVoiceFamilies.Gregalia,
+ ActivityBodyTag.Humanoid => ActivityVoiceFamilies.Humanoid,
+ ActivityBodyTag.Vehicle => ActivityVoiceFamilies.Special,
+ ActivityBodyTag.Default => ActivityVoiceFamilies.Default,
+ _ => ActivityVoiceFamilies.Mammal
+ };
+ }
+
+ private static ActivityEcologyTag ResolveEcology(
+ ActorAsset asset,
+ ActivityBodyTag body,
+ ActivityModifier modifier)
+ {
+ if (modifier == ActivityModifier.Undead) return ActivityEcologyTag.Undead;
+ if (modifier == ActivityModifier.Alien) return ActivityEcologyTag.Alien;
+ if (modifier == ActivityModifier.Construct) return ActivityEcologyTag.Machine;
+ if (body == ActivityBodyTag.Vehicle || body == ActivityBodyTag.Aquatic || body == ActivityBodyTag.Seal)
+ return ActivityEcologyTag.Water;
+ if (body == ActivityBodyTag.Bird || body == ActivityBodyTag.Insect) return ActivityEcologyTag.Air;
+ if (body == ActivityBodyTag.Plant) return ActivityEcologyTag.Plant;
+ if (body == ActivityBodyTag.Fungi || modifier == ActivityModifier.Mush) return ActivityEcologyTag.Fungal;
+ if (body == ActivityBodyTag.Machina) return ActivityEcologyTag.Machine;
+ if (body == ActivityBodyTag.Cold) return ActivityEcologyTag.Cold;
+ if (asset != null && asset.civ) return ActivityEcologyTag.Civilized;
+ if (body == ActivityBodyTag.Mythic || body == ActivityBodyTag.Dragon
+ || modifier == ActivityModifier.Elemental) return ActivityEcologyTag.Mythic;
+ return ActivityEcologyTag.Land;
+ }
+
+ private static ActivityBodyTag EffectiveTag(ActivityModifier modifier, ActivityBodyTag body)
+ {
+ return modifier switch
+ {
+ ActivityModifier.Undead => ActivityBodyTag.Undead,
+ ActivityModifier.Elemental => ActivityBodyTag.Elemental,
+ ActivityModifier.Mush => ActivityBodyTag.Mush,
+ ActivityModifier.Tumor => ActivityBodyTag.Tumor,
+ ActivityModifier.Alien => ActivityBodyTag.Alien,
+ ActivityModifier.Construct => ActivityBodyTag.Construct,
+ _ => body
+ };
+ }
+
+ private static bool IsVehicle(string id)
+ {
+ return !string.IsNullOrEmpty(id)
+ && id.StartsWith("boat_", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsExplicitSpecial(string id)
+ {
+ return EqualsIgnore(id, "crabzilla")
+ || EqualsIgnore(id, "god_finger")
+ || EqualsIgnore(id, "living_plants")
+ || EqualsIgnore(id, "living_house")
+ || EqualsIgnore(id, "printer")
+ || EqualsIgnore(id, "assimilator")
+ || EqualsIgnore(id, "crystal_sword");
+ }
+
+ private static int FlavorOrdinal(string id)
+ {
+ EnsureFlavorOrdinals();
+ if (!string.IsNullOrEmpty(id) && FlavorOrdinals.TryGetValue(id, out int ordinal))
+ {
+ return ordinal;
+ }
+
+ return Math.Abs(StableHash(id)) % 1024;
+ }
+
+ private static void EnsureFlavorOrdinals()
+ {
+ List ids = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
+ if (ids.Count == _ordinalLibraryCount && FlavorOrdinals.Count == ids.Count)
+ {
+ return;
+ }
+
+ ids.Sort(StringComparer.Ordinal);
+ FlavorOrdinals.Clear();
+ for (int i = 0; i < ids.Count; i++)
+ {
+ FlavorOrdinals[ids[i]] = i;
+ }
+
+ _ordinalLibraryCount = ids.Count;
+ }
+
+ private static int StableHash(string value)
+ {
+ unchecked
+ {
+ uint h = 2166136261;
+ string s = value ?? "";
+ for (int i = 0; i < s.Length; i++)
+ {
+ h ^= s[i];
+ h *= 16777619;
+ }
+
+ return (int)(h & 0x7fffffff);
+ }
+ }
+
+ private static bool EqualsIgnore(string a, string b)
+ {
+ return !string.IsNullOrEmpty(a) && a.Equals(b, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/IdleSpectator/ActorActivityPatches.cs b/IdleSpectator/ActorActivityPatches.cs
index adea71a..c6edfb2 100644
--- a/IdleSpectator/ActorActivityPatches.cs
+++ b/IdleSpectator/ActorActivityPatches.cs
@@ -5,6 +5,8 @@ namespace IdleSpectator;
///
/// Feeds from task changes and curated behaviour beats.
+/// Only patch Beh types that declare execute - Harmony fails with "method null"
+/// when the method is inherited-only (e.g. BehSocializeTalk).
///
[HarmonyPatch]
public static class ActorActivityPatches
@@ -63,7 +65,11 @@ public static class ActorActivityPatches
return;
}
- ActivityLog.NoteActionBeat(pActor, "BehUnloadResources", "Unloads resources", "town");
+ string carrying = ActivityInterestTable.TryGetCarryingLabel(pActor);
+ string fact = string.IsNullOrEmpty(carrying)
+ ? "Unloads resources"
+ : "Unloads " + carrying;
+ ActivityLog.NoteActionBeat(pActor, "BehUnloadResources", fact, "town");
}
[HarmonyPatch(typeof(BehAttackActorHuntingTarget), nameof(BehAttackActorHuntingTarget.execute))]
@@ -94,4 +100,170 @@ public static class ActorActivityPatches
ActivityLog.NoteActionBeat(pActor, "BehCityActorRemoveFire", "Fights fire", "blaze");
}
+
+ [HarmonyPatch(typeof(BehTryToSocialize), nameof(BehTryToSocialize.execute))]
+ [HarmonyPostfix]
+ public static void PostfixTrySocialize(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ string target = ActivityInterestTable.TargetLabel(pActor);
+ ActivityLog.NoteActionBeat(
+ pActor,
+ "BehTryToSocialize",
+ string.IsNullOrEmpty(target) ? "Tries to socialize" : "Tries to socialize with " + target,
+ target);
+ }
+
+ [HarmonyPatch(typeof(BehPlantCrops), nameof(BehPlantCrops.execute))]
+ [HarmonyPostfix]
+ public static void PostfixPlantCrops(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ ActivityLog.NoteActionBeat(pActor, "BehPlantCrops", "Plants crops", "");
+ }
+
+ [HarmonyPatch(typeof(BehCityActorFertilizeCrop), nameof(BehCityActorFertilizeCrop.execute))]
+ [HarmonyPostfix]
+ public static void PostfixFertilize(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ ActivityLog.NoteActionBeat(pActor, "BehCityActorFertilizeCrop", "Fertilizes crops", "");
+ }
+
+ [HarmonyPatch(typeof(BehThrowResources), nameof(BehThrowResources.execute))]
+ [HarmonyPrefix]
+ public static void PrefixThrow(Actor pActor, out bool __state)
+ {
+ __state = false;
+ try
+ {
+ __state = pActor != null && pActor.isCarryingResources();
+ }
+ catch
+ {
+ __state = false;
+ }
+ }
+
+ [HarmonyPatch(typeof(BehThrowResources), nameof(BehThrowResources.execute))]
+ [HarmonyPostfix]
+ public static void PostfixThrow(Actor pActor, bool __state)
+ {
+ if (pActor == null || !__state)
+ {
+ return;
+ }
+
+ string carrying = ActivityInterestTable.TryGetCarryingLabel(pActor);
+ string fact = string.IsNullOrEmpty(carrying)
+ ? "Throws resources"
+ : "Throws " + carrying;
+ ActivityLog.NoteActionBeat(pActor, "BehThrowResources", fact, "");
+ }
+
+ [HarmonyPatch(typeof(BehBuildTarget), nameof(BehBuildTarget.execute))]
+ [HarmonyPostfix]
+ public static void PostfixBuild(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ string target = ActivityInterestTable.TargetLabel(pActor);
+ ActivityLog.NoteActionBeat(
+ pActor,
+ "BehBuildTarget",
+ string.IsNullOrEmpty(target) ? "Works a build" : "Builds " + target,
+ target);
+ }
+
+ [HarmonyPatch(typeof(BehPoopOutside), nameof(BehPoopOutside.execute))]
+ [HarmonyPostfix]
+ public static void PostfixPoopOutside(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ ActivityLog.NoteActionBeat(pActor, "BehPoopOutside", "Private moment outdoors", "");
+ }
+
+ [HarmonyPatch(typeof(BehPoopInside), nameof(BehPoopInside.execute))]
+ [HarmonyPostfix]
+ public static void PostfixPoopInside(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ ActivityLog.NoteActionBeat(pActor, "BehPoopInside", "Private moment indoors", "");
+ }
+
+ [HarmonyPatch(typeof(BehBoatFishing), nameof(BehBoatFishing.execute))]
+ [HarmonyPostfix]
+ public static void PostfixBoatFish(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ ActivityLog.NoteActionBeat(pActor, "BehBoatFishing", "Fishes from the boat", "");
+ }
+
+ [HarmonyPatch(typeof(BehBoatCollectFish), nameof(BehBoatCollectFish.execute))]
+ [HarmonyPostfix]
+ public static void PostfixBoatCollectFish(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ ActivityLog.NoteActionBeat(pActor, "BehBoatCollectFish", "Collects the catch", "");
+ }
+
+ [HarmonyPatch(typeof(BehClaimZoneForCityActorBorder), nameof(BehClaimZoneForCityActorBorder.execute))]
+ [HarmonyPostfix]
+ public static void PostfixClaimBorder(Actor pActor, BehResult __result)
+ {
+ if (pActor == null || __result == BehResult.Stop)
+ {
+ return;
+ }
+
+ string place = "";
+ try
+ {
+ if (pActor.city != null)
+ {
+ place = pActor.city.name ?? "";
+ }
+ }
+ catch
+ {
+ place = "";
+ }
+
+ ActivityLog.NoteActionBeat(
+ pActor,
+ "BehClaimZoneForCityActorBorder",
+ string.IsNullOrEmpty(place) ? "Claims border land" : "Claims border land for " + place,
+ place);
+ }
}
diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs
index fc54961..412b2be 100644
--- a/IdleSpectator/AgentHarness.cs
+++ b/IdleSpectator/AgentHarness.cs
@@ -408,12 +408,37 @@ public static class AgentHarness
}
string targetName = cmd.expect ?? "";
+ string speciesId = cmd.value ?? "";
+ string place = "";
+ string carrying = "";
+ // Optional: label "wheat@Riverhold" encodes carrying@place for unload tests.
+ if (!string.IsNullOrEmpty(cmd.label) && cmd.label.IndexOf('@') >= 0
+ && (key.IndexOf("Unload", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || key.IndexOf("Throw", System.StringComparison.OrdinalIgnoreCase) >= 0))
+ {
+ string[] parts = cmd.label.Split(new[] { '@' }, 2);
+ if (parts.Length == 2)
+ {
+ carrying = parts[0].Trim();
+ place = parts[1].Trim();
+ }
+ }
+
int n = Math.Max(1, cmd.count);
int okN = 0;
for (int i = 0; i < n; i++)
{
string one = n > 1 ? $"{line} #{i + 1}" : line;
- if (ActivityLog.ForceNote(id, key, one, asBeat, actorName, targetName))
+ if (ActivityLog.ForceNote(
+ id,
+ key,
+ one,
+ asBeat,
+ actorName,
+ targetName,
+ speciesId,
+ place,
+ carrying))
{
okN++;
}
@@ -1956,6 +1981,707 @@ public static class AgentHarness
detail = $"plain='{plain}' actor='{actorName}'";
break;
}
+ case "activity_prose_alive":
+ {
+ // Generic tasks must read as living diary lines, not "{name} wanders".
+ string actorName = "Dorraa";
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "random_move",
+ ActivityContext.ForHarness(actorName, "", "buffalo"),
+ "Wandering",
+ 11,
+ 1,
+ out string moveLine,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "wait",
+ ActivityContext.ForHarness(actorName, "", "buffalo"),
+ "Waiting",
+ 12,
+ 1,
+ out string waitLine,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "city_idle_walking",
+ ActivityContext.ForHarness(actorName, "", "human", place: "Riverhold", jobId: "farmer"),
+ "Walking",
+ 13,
+ 1,
+ out string walkLine,
+ out _);
+
+ bool moveOk = !string.IsNullOrEmpty(moveLine)
+ && moveLine.Length >= 28
+ && !moveLine.Equals(actorName + " wanders", System.StringComparison.Ordinal)
+ && (moveLine.IndexOf("wander", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || moveLine.IndexOf("drift", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || moveLine.IndexOf("roam", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || moveLine.IndexOf("ambl", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || moveLine.IndexOf("trudg", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || moveLine.IndexOf("move", System.StringComparison.OrdinalIgnoreCase) >= 0);
+ bool waitOk = !string.IsNullOrEmpty(waitLine)
+ && waitLine.Length >= 18
+ && !waitLine.Equals(actorName + " waits", System.StringComparison.Ordinal);
+ bool walkOk = !string.IsNullOrEmpty(walkLine)
+ && walkLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && walkLine.Length >= 28;
+ pass = moveOk && waitOk && walkOk;
+ detail =
+ $"moveOk={moveOk} waitOk={waitOk} walkOk={walkOk} "
+ + $"move='{moveLine}' wait='{waitLine}' walk='{walkLine}'";
+ break;
+ }
+ case "activity_prose_species":
+ {
+ // Species stays on the dossier nametag. Lines must diverge by manner / taxonomy
+ // without spelling the species noun in the activity text.
+ string actorName = "Tester";
+ try
+ {
+ if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
+ {
+ actorName = MoveCamera._focus_unit.getName() ?? "Tester";
+ }
+ }
+ catch
+ {
+ actorName = "Tester";
+ }
+
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ActivityContext.ForHarness(actorName, "", "dog"),
+ "Playing",
+ 1,
+ 1,
+ out string dogLine,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ActivityContext.ForHarness(actorName, "", "cat"),
+ "Playing",
+ 2,
+ 1,
+ out string catLine,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ActivityContext.ForHarness(actorName, "", "human", place: "Riverhold"),
+ "Playing",
+ 3,
+ 1,
+ out string humanLine,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.ActionBeat,
+ "BehUnloadResources",
+ ActivityContext.ForHarness(
+ actorName,
+ "",
+ "human",
+ place: "Riverhold",
+ carrying: "wheat"),
+ "Unloads wheat",
+ 4,
+ 1,
+ out string unloadLine,
+ out _);
+
+ // Species must never leak into the place clause (wild kingdoms often = species id).
+ ActivityContext buffaloBadPlace = ActivityContext.ForHarness(
+ "Dorraa",
+ "",
+ "buffalo",
+ place: "buffalo");
+ buffaloBadPlace.PlaceLabel = ActivityInterestTable.PickPlaceLabel(
+ "buffalo",
+ "",
+ "buffalo",
+ "buffalo");
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ buffaloBadPlace,
+ "Playing",
+ 5,
+ 1,
+ out string buffaloLine,
+ out _);
+
+ ActivityContext buffaloGoodPlace = ActivityContext.ForHarness(
+ "Dorraa",
+ "",
+ "buffalo",
+ place: "Riverhold");
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ buffaloGoodPlace,
+ "Playing",
+ 6,
+ 1,
+ out string buffaloTownLine,
+ out _);
+
+ bool dogOk = !string.IsNullOrEmpty(dogLine)
+ && dogLine.Length >= 16
+ && dogLine.IndexOf(" the dog", System.StringComparison.OrdinalIgnoreCase) < 0;
+ bool catOk = !string.IsNullOrEmpty(catLine)
+ && catLine.Length >= 16
+ && catLine.IndexOf(" the cat", System.StringComparison.OrdinalIgnoreCase) < 0;
+ bool humanOk = !string.IsNullOrEmpty(humanLine)
+ && !humanLine.Equals(dogLine, System.StringComparison.Ordinal)
+ && !humanLine.Equals(catLine, System.StringComparison.Ordinal)
+ && humanLine.IndexOf(" the human", System.StringComparison.OrdinalIgnoreCase) < 0;
+ bool unloadOk = !string.IsNullOrEmpty(unloadLine)
+ && unloadLine.IndexOf("wheat", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && unloadLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0;
+ bool distinct = !dogLine.Equals(catLine, System.StringComparison.Ordinal);
+ bool placeRejectOk = !string.IsNullOrEmpty(buffaloLine)
+ && buffaloLine.IndexOf(" in buffalo", System.StringComparison.OrdinalIgnoreCase) < 0
+ && buffaloLine.IndexOf(" the buffalo", System.StringComparison.OrdinalIgnoreCase) < 0;
+ bool placeKeepOk = !string.IsNullOrEmpty(buffaloTownLine)
+ && buffaloTownLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0;
+
+ // Taxonomy kingdoms like "insect" must never become place clauses.
+ string insectPlace = ActivityInterestTable.PickPlaceLabel(
+ "insect",
+ "",
+ "insect",
+ "beetle");
+ ActivityContext beetleCtx = ActivityContext.ForHarness("Eeeew", "", "beetle", place: insectPlace);
+ beetleCtx.PlaceLabel = insectPlace;
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ beetleCtx,
+ "Playing",
+ 7,
+ 1,
+ out string beetleLine,
+ out _);
+ bool insectRejectOk = string.IsNullOrEmpty(insectPlace)
+ && !string.IsNullOrEmpty(beetleLine)
+ && beetleLine.IndexOf(" in insect", System.StringComparison.OrdinalIgnoreCase) < 0
+ && beetleLine.IndexOf(" the beetle", System.StringComparison.OrdinalIgnoreCase) < 0;
+
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ActivityContext.ForHarness("Creaker", "", "frog"),
+ "Playing",
+ 8,
+ 1,
+ out string frogLine,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "child_random_jump",
+ ActivityContext.ForHarness("Creaker", "", "frog"),
+ "Jumping",
+ 9,
+ 1,
+ out string frogJumpLine,
+ out _);
+ bool frogOk = !string.IsNullOrEmpty(frogLine)
+ && frogLine.Length >= 16
+ && frogLine.IndexOf(" the frog", System.StringComparison.OrdinalIgnoreCase) < 0
+ && (frogLine.IndexOf("hop", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || frogLine.IndexOf("leap", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || frogLine.IndexOf("bounc", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || frogLine.IndexOf("splash", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || frogLine.IndexOf("play", System.StringComparison.OrdinalIgnoreCase) >= 0);
+ bool frogJumpOk = !string.IsNullOrEmpty(frogJumpLine)
+ && frogJumpLine.Length >= 16
+ && frogJumpLine.IndexOf(" the frog", System.StringComparison.OrdinalIgnoreCase) < 0
+ && frogJumpLine.IndexOf("young", System.StringComparison.OrdinalIgnoreCase) >= 0;
+
+ // Live ActorAsset taxonomy - not curated id maps.
+ ActivityContext frogCtx = ActivityContext.ForHarness("Creaker", "", "frog");
+ ActivityContext dogCtxLive = ActivityContext.ForHarness(actorName, "", "dog");
+ bool liveTaxOk = ActivityAssetCatalog.IsLiveActorAssetId("frog")
+ && frogCtx.Voice.BaseBodyTag == ActivityBodyTag.Amphibian
+ && frogCtx.TaxonomicClass.Equals(
+ "amphibia",
+ System.StringComparison.OrdinalIgnoreCase)
+ && dogCtxLive.Family == ActivityVoiceFamilies.Canine
+ && dogCtxLive.TaxonomicFamily.Equals(
+ "canidae",
+ System.StringComparison.OrdinalIgnoreCase);
+
+ // Species with no polish override still gets taxonomy manner without naming itself.
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ActivityContext.ForHarness("Horn", "", "rhino"),
+ "Playing",
+ 10,
+ 1,
+ out string rhinoLine,
+ out _);
+ bool rhinoOk = !string.IsNullOrEmpty(rhinoLine)
+ && rhinoLine.Length >= 16
+ && rhinoLine.IndexOf(" the rhino", System.StringComparison.OrdinalIgnoreCase) < 0;
+
+ pass = dogOk && catOk && humanOk && unloadOk && distinct && placeRejectOk && placeKeepOk
+ && insectRejectOk && frogOk && frogJumpOk && liveTaxOk && rhinoOk;
+ detail =
+ $"dogOk={dogOk} catOk={catOk} humanOk={humanOk} unloadOk={unloadOk} distinct={distinct} "
+ + $"placeRejectOk={placeRejectOk} placeKeepOk={placeKeepOk} insectRejectOk={insectRejectOk} "
+ + $"frogOk={frogOk} frogJumpOk={frogJumpOk} liveTaxOk={liveTaxOk} rhinoOk={rhinoOk} "
+ + $"frogFamily={frogCtx.Family} frogClass={frogCtx.TaxonomicClass} "
+ + $"dogFamily={dogCtxLive.Family} dogTaxFamily={dogCtxLive.TaxonomicFamily} "
+ + $"dog='{dogLine}' cat='{catLine}' human='{humanLine}' unload='{unloadLine}' "
+ + $"buffalo='{buffaloLine}' buffaloTown='{buffaloTownLine}' beetle='{beetleLine}' "
+ + $"frog='{frogLine}' frogJump='{frogJumpLine}' rhino='{rhinoLine}'";
+ break;
+ }
+ case "activity_taxonomy_audit":
+ {
+ ActivityVoiceAuditResult audit = ActivityVoiceHarness.RunAudit(HarnessDir());
+ pass = audit.Passed;
+ detail = audit.Detail;
+ break;
+ }
+ case "activity_prose_showcase":
+ {
+ // Taxonomy must map to real buckets - never fantasy kingdoms -> monster.
+ string[] speciesIds = { "zombie", "angle", "lemon_snail", "dragon" };
+ string[] wantFamily =
+ {
+ ActivityVoiceFamilies.Mammal,
+ ActivityVoiceFamilies.Mythic,
+ ActivityVoiceFamilies.Plant,
+ ActivityVoiceFamilies.Mythic
+ };
+ ActivityBodyTag[] wantManner =
+ {
+ ActivityBodyTag.Undead,
+ ActivityBodyTag.Mythic,
+ ActivityBodyTag.Plant,
+ ActivityBodyTag.Dragon
+ };
+ var verbs = new (string key, string fact, bool withTarget)[]
+ {
+ ("task_unit_play", "Playing", false),
+ ("move", "Moving", false),
+ ("task_hunt", "Hunting", true),
+ ("task_sleep", "Sleeping", false),
+ ("task_eat", "Eating", false),
+ ("laugh", "Laughing", false),
+ ("task_attack", "Fighting", true)
+ };
+
+ var dump = new StringBuilder(2048);
+ int lineNo = 0;
+ int okLines = 0;
+ int leakLines = 0;
+ int classOk = 0;
+ for (int s = 0; s < speciesIds.Length; s++)
+ {
+ string sid = speciesIds[s];
+ ActivityContext probe = ActivityContext.ForHarness("Tester", "", sid);
+ bool mapped = probe.Family == wantFamily[s]
+ && probe.Voice.EffectiveActionTag == wantManner[s];
+ if (mapped)
+ {
+ classOk++;
+ }
+
+ dump.AppendLine(
+ "=== " + sid
+ + " label='" + probe.SpeciesLabel + "'"
+ + " tax='" + probe.TaxonomicKingdom + "'"
+ + " family=" + probe.Family
+ + " manner=" + probe.MannerTag
+ + " wantFamily=" + wantFamily[s]
+ + " wantManner=" + wantManner[s]
+ + " mapped=" + mapped
+ + " live=" + ActivityAssetCatalog.IsLiveActorAssetId(sid)
+ + " ===");
+ for (int v = 0; v < verbs.Length; v++)
+ {
+ string key = verbs[v].key;
+ string fact = verbs[v].fact;
+ bool withTarget = verbs[v].withTarget;
+ lineNo++;
+ string target = withTarget ? "Prey" : "";
+ ActivityContext ctx = ActivityContext.ForHarness("Tester", target, sid);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ key,
+ ctx,
+ fact,
+ 9000 + s,
+ lineNo,
+ out string plain,
+ out _);
+ bool leak = !string.IsNullOrEmpty(probe.SpeciesLabel)
+ && plain.IndexOf(
+ " the " + probe.SpeciesLabel,
+ StringComparison.OrdinalIgnoreCase) >= 0;
+ if (leak)
+ {
+ leakLines++;
+ }
+ else if (!string.IsNullOrEmpty(plain) && plain.Length >= 8)
+ {
+ okLines++;
+ }
+
+ dump.AppendLine(" " + key + ": " + plain);
+ Debug.Log(
+ "[IdleSpectator][SHOWCASE] " + sid + "|" + key + "|"
+ + probe.MannerTag + "|" + plain);
+ }
+ }
+
+ int kingdomFail = 0;
+ string kingdomFailSample = "";
+ List actors = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
+ for (int i = 0; i < actors.Count; i++)
+ {
+ ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(actors[i]);
+ if (asset == null || asset.civ)
+ {
+ continue;
+ }
+
+ string tax = asset.name_taxonomic_kingdom ?? "";
+ if (string.IsNullOrEmpty(tax))
+ {
+ continue;
+ }
+
+ string fam = ActivityVoiceResolver.Resolve(asset).BaseFamily;
+ bool fantasyKingdom =
+ tax.Equals("plantae", StringComparison.OrdinalIgnoreCase)
+ || tax.Equals("fungi", StringComparison.OrdinalIgnoreCase)
+ || tax.Equals("protista", StringComparison.OrdinalIgnoreCase)
+ || tax.Equals("machina", StringComparison.OrdinalIgnoreCase)
+ || tax.Equals("mythoria", StringComparison.OrdinalIgnoreCase)
+ || tax.Equals("gregalia", StringComparison.OrdinalIgnoreCase);
+ if (fantasyKingdom && fam == ActivityVoiceFamilies.Default)
+ {
+ kingdomFail++;
+ if (kingdomFailSample.Length < 180)
+ {
+ if (kingdomFailSample.Length > 0)
+ {
+ kingdomFailSample += "; ";
+ }
+
+ kingdomFailSample += actors[i] + ":" + tax + "->" + fam;
+ }
+ }
+ }
+
+ string path = Path.Combine(HarnessDir(), "activity-prose-showcase.txt");
+ try
+ {
+ File.WriteAllText(path, dump.ToString());
+ }
+ catch (Exception ex)
+ {
+ Debug.LogWarning("[IdleSpectator][SHOWCASE] write failed: " + ex.Message);
+ }
+
+ pass = okLines >= 20 && leakLines == 0 && classOk == speciesIds.Length && kingdomFail == 0;
+ detail = "okLines=" + okLines
+ + " leakLines=" + leakLines
+ + " classOk=" + classOk
+ + " kingdomFail=" + kingdomFail
+ + " kingdomFailSample='" + kingdomFailSample + "'"
+ + " path=" + path;
+ break;
+ }
+ case "activity_prose_grounding":
+ {
+ // Same verb, different observed clauses must diverge and mention those facts.
+ const string verb = "BehUnloadResources";
+ ActivityProse.Format(
+ ActivityKind.ActionBeat,
+ verb,
+ ActivityContext.ForHarness(
+ "Mira",
+ "",
+ "human",
+ place: "Riverhold",
+ carrying: "wheat",
+ jobId: "farmer",
+ traitId: "curious",
+ isChild: true),
+ "Unloads wheat",
+ 101,
+ 1,
+ out string farmerLine,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.ActionBeat,
+ verb,
+ ActivityContext.ForHarness(
+ "Mira",
+ "",
+ "human",
+ place: "Ironford",
+ carrying: "stone",
+ jobId: "miner",
+ traitId: "brave"),
+ "Unloads stone",
+ 102,
+ 1,
+ out string minerLine,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ActivityContext.ForHarness(
+ "Mira",
+ "Barkley",
+ "human",
+ place: "Riverhold",
+ jobId: "farmer",
+ isKing: true,
+ inCombat: true),
+ "Playing",
+ 103,
+ 1,
+ out string kingCombatLine,
+ out _);
+
+ bool farmerOk = !string.IsNullOrEmpty(farmerLine)
+ && farmerLine.IndexOf("young", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && farmerLine.IndexOf("farmer", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && farmerLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && farmerLine.IndexOf("wheat", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && farmerLine.IndexOf(" the human", System.StringComparison.OrdinalIgnoreCase) < 0
+ && farmerLine.IndexOf("curious", System.StringComparison.OrdinalIgnoreCase) < 0;
+ bool minerOk = !string.IsNullOrEmpty(minerLine)
+ && minerLine.IndexOf("miner", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && minerLine.IndexOf("Ironford", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && minerLine.IndexOf("stone", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && minerLine.IndexOf("brave", System.StringComparison.OrdinalIgnoreCase) < 0;
+ bool divergeOk = !farmerLine.Equals(minerLine, System.StringComparison.Ordinal);
+ bool kingOk = !string.IsNullOrEmpty(kingCombatLine)
+ && kingCombatLine.IndexOf("king", System.StringComparison.OrdinalIgnoreCase) >= 0
+ && (kingCombatLine.IndexOf("combat", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || kingCombatLine.IndexOf("locked on", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || kingCombatLine.IndexOf("Barkley", System.StringComparison.OrdinalIgnoreCase) >= 0);
+
+ // Taxonomy manner divergence without SpeciesOverrides (rhino vs beetle vs frog).
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ActivityContext.ForHarness("A", "", "beetle"),
+ "Playing",
+ 104,
+ 1,
+ out string beetlePlay,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ActivityContext.ForHarness("B", "", "frog"),
+ "Playing",
+ 105,
+ 1,
+ out string frogPlay,
+ out _);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ActivityContext.ForHarness("C", "", "rhino"),
+ "Playing",
+ 106,
+ 1,
+ out string rhinoPlay,
+ out _);
+ bool taxDiverge = !string.IsNullOrEmpty(beetlePlay)
+ && !string.IsNullOrEmpty(frogPlay)
+ && !string.IsNullOrEmpty(rhinoPlay)
+ && !beetlePlay.Equals(frogPlay, System.StringComparison.Ordinal)
+ && !frogPlay.Equals(rhinoPlay, System.StringComparison.Ordinal)
+ && beetlePlay.IndexOf(" the beetle", System.StringComparison.OrdinalIgnoreCase) < 0
+ && frogPlay.IndexOf(" the frog", System.StringComparison.OrdinalIgnoreCase) < 0
+ && rhinoPlay.IndexOf(" the rhino", System.StringComparison.OrdinalIgnoreCase) < 0
+ && (beetlePlay.IndexOf("scuttl", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || beetlePlay.IndexOf("skitter", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || beetlePlay.IndexOf("buzz", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || beetlePlay.IndexOf("tiny", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || beetlePlay.IndexOf("busy", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || beetlePlay.IndexOf("circuit", System.StringComparison.OrdinalIgnoreCase) >= 0)
+ && (frogPlay.IndexOf("hop", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || frogPlay.IndexOf("bounc", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || frogPlay.IndexOf("leap", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || frogPlay.IndexOf("splash", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || frogPlay.IndexOf("damp", System.StringComparison.OrdinalIgnoreCase) >= 0
+ || frogPlay.IndexOf("jump", System.StringComparison.OrdinalIgnoreCase) >= 0);
+
+ pass = farmerOk && minerOk && divergeOk && kingOk && taxDiverge;
+ detail =
+ $"farmerOk={farmerOk} minerOk={minerOk} divergeOk={divergeOk} kingOk={kingOk} taxDiverge={taxDiverge} "
+ + $"farmer='{farmerLine}' miner='{minerLine}' king='{kingCombatLine}' "
+ + $"beetle='{beetlePlay}' frog='{frogPlay}' rhino='{rhinoPlay}'";
+ break;
+ }
+ case "activity_prose_library":
+ {
+ // Coverage from live libraries - not curated shortlists.
+ List actors = ActivityAssetCatalog.EnumerateLiveActorAssetIds();
+ List tasks = ActivityVerbMap.EnumerateLiveTaskIds();
+ int actorFail = 0;
+ int actorChecked = 0;
+ string actorFailSample = "";
+ for (int i = 0; i < actors.Count; i++)
+ {
+ string sid = actors[i];
+ ActorAsset asset = ActivityAssetCatalog.TryGetActorAsset(sid);
+ if (asset == null)
+ {
+ continue;
+ }
+
+ // Stratified sample: every 3rd asset, plus always dog/cat/frog/beetle/rhino/human.
+ bool force = sid == "dog" || sid == "cat" || sid == "frog" || sid == "beetle"
+ || sid == "rhino" || sid == "human";
+ if (!force && (i % 3) != 0)
+ {
+ continue;
+ }
+
+ actorChecked++;
+ ActivityContext ctx = ActivityContext.ForHarness("Lib", "", sid);
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ "task_unit_play",
+ ctx,
+ "Playing",
+ 200 + i,
+ 1,
+ out string line,
+ out _);
+ bool humanoid = ctx.IsCiv && ctx.IsHumanoid;
+ bool ok = !string.IsNullOrEmpty(line) && line.Length >= 18;
+ // Dossier shows species - activity lines must not spell "the {species}".
+ string label = string.IsNullOrEmpty(ctx.SpeciesLabel) ? sid : ctx.SpeciesLabel;
+ if (!string.IsNullOrEmpty(label))
+ {
+ ok = ok && line.IndexOf(" the " + label, System.StringComparison.OrdinalIgnoreCase) < 0;
+ }
+
+ if (humanoid)
+ {
+ ok = ok && line.IndexOf(" the human", System.StringComparison.OrdinalIgnoreCase) < 0;
+ }
+
+ if (!ok)
+ {
+ actorFail++;
+ if (string.IsNullOrEmpty(actorFailSample))
+ {
+ actorFailSample = sid + ":'" + line + "'";
+ }
+ else if (actorFail <= 6 && actorFailSample.Length < 220)
+ {
+ actorFailSample += "; " + sid + ":'" + line + "'";
+ }
+ }
+ }
+
+ int taskFail = 0;
+ int taskChecked = 0;
+ string taskFailSample = "";
+ // Prefer live task library; fall back to snapshot file ids if library empty.
+ List taskIds = tasks;
+ if (taskIds.Count == 0)
+ {
+ try
+ {
+ string path = System.IO.Path.Combine(ModClass.ModFolder, ".harness", "actor_task_ids.txt");
+ if (System.IO.File.Exists(path))
+ {
+ foreach (string line in System.IO.File.ReadAllLines(path))
+ {
+ if (!string.IsNullOrEmpty(line))
+ {
+ taskIds.Add(line.Trim());
+ }
+ }
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+
+ for (int i = 0; i < taskIds.Count; i++)
+ {
+ string tid = taskIds[i];
+ if (string.IsNullOrEmpty(tid))
+ {
+ continue;
+ }
+
+ taskChecked++;
+ if (!ActivityVerbMap.HasActionCore(tid))
+ {
+ taskFail++;
+ if (string.IsNullOrEmpty(taskFailSample))
+ {
+ taskFailSample = tid + ":no-core";
+ }
+
+ continue;
+ }
+
+ ActivityProse.Format(
+ ActivityKind.TaskStart,
+ tid,
+ ActivityContext.ForHarness("Lib", "", "dog", taskKey: tid),
+ tid,
+ 300 + i,
+ 1,
+ out string line,
+ out _);
+ bool ok = !string.IsNullOrEmpty(line)
+ && line.Length >= 16
+ && !line.Equals("Lib " + tid, System.StringComparison.OrdinalIgnoreCase)
+ && line.IndexOf("Lib", System.StringComparison.Ordinal) >= 0;
+ // Reject bare gerund dead ends like "Lib laughing"
+ string[] parts = line.Split(new[] { ' ' }, 3);
+ if (parts.Length == 2 && parts[1].EndsWith("ing", System.StringComparison.OrdinalIgnoreCase))
+ {
+ ok = false;
+ }
+
+ if (!ok)
+ {
+ taskFail++;
+ if (taskFailSample.IndexOf(tid, System.StringComparison.Ordinal) < 0
+ && taskFailSample.Length < 120)
+ {
+ taskFailSample += (string.IsNullOrEmpty(taskFailSample) ? "" : ";")
+ + tid + ":'" + line + "'";
+ }
+ }
+ }
+
+ bool actorsOk = actors.Count >= 20 && actorFail == 0 && actorChecked >= 10;
+ bool tasksOk = taskChecked >= 50 && taskFail == 0;
+ pass = actorsOk && tasksOk;
+ detail =
+ $"actors={actors.Count} actorChecked={actorChecked} actorFail={actorFail} "
+ + $"tasksLive={tasks.Count} taskChecked={taskChecked} taskFail={taskFail} "
+ + $"actorFailSample='{actorFailSample}' taskFailSample='{taskFailSample}'";
+ break;
+ }
case "lore_detail_pane":
{
string want = (cmd.value ?? "life").Trim().ToLowerInvariant();
@@ -2451,6 +3177,54 @@ public static class AgentHarness
detail = $"top='{have}' want='{want}' tab={ChronicleHud.ActiveTab}";
break;
}
+ case "fallen_list_top_not":
+ {
+ string reject = (cmd.value ?? cmd.label ?? "").Trim();
+ string have = ChronicleHud.LastListTopTitle ?? "";
+ pass = Chronicle.HudVisible
+ && ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Fallen
+ && ChronicleHud.DetailUnitId == 0
+ && !string.IsNullOrEmpty(reject)
+ && have.IndexOf(reject, System.StringComparison.OrdinalIgnoreCase) < 0;
+ detail = $"top='{have}' reject='{reject}' tab={ChronicleHud.ActiveTab}";
+ break;
+ }
+ case "fallen_list_before":
+ {
+ string newer = (cmd.value ?? "").Trim();
+ string older = (cmd.label ?? "").Trim();
+ List rows = Chronicle.ListCharacters(
+ max: 0,
+ scope: Chronicle.CharacterListScope.Fallen);
+ int newerIndex = -1;
+ int olderIndex = -1;
+ for (int i = 0; i < rows.Count; i++)
+ {
+ string title = rows[i]?.Title ?? "";
+ if (newerIndex < 0
+ && title.IndexOf(newer, System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ newerIndex = i;
+ }
+
+ if (olderIndex < 0
+ && title.IndexOf(older, System.StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ olderIndex = i;
+ }
+ }
+
+ pass = Chronicle.HudVisible
+ && ChronicleHud.ActiveTab == ChronicleHud.LoreTab.Fallen
+ && !string.IsNullOrEmpty(newer)
+ && !string.IsNullOrEmpty(older)
+ && newerIndex >= 0
+ && olderIndex >= 0
+ && newerIndex < olderIndex;
+ detail =
+ $"newer='{newer}' newerIndex={newerIndex} older='{older}' olderIndex={olderIndex} rows={rows.Count}";
+ break;
+ }
case "lore_list_stale":
{
bool want = ParseBool(cmd.value, defaultValue: true);
diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs
index dd9c7b6..3e06aa3 100644
--- a/IdleSpectator/HarnessScenarios.cs
+++ b/IdleSpectator/HarnessScenarios.cs
@@ -38,6 +38,10 @@ internal static class HarnessScenarios
case "activity":
case "activity_idle_smoke":
return ActivityIdleSmoke();
+ case "activity_prose_showcase":
+ return ActivityProseShowcase();
+ case "activity_taxonomy_audit":
+ return ActivityTaxonomyAudit();
case "activity_rate":
case "activity_rate_sample":
return ActivityRateSample();
@@ -52,6 +56,30 @@ internal static class HarnessScenarios
}
}
+ private static List ActivityTaxonomyAudit()
+ {
+ return new List
+ {
+ Step("ata0", "wait_world"),
+ Step("ata1", "pick_unit", asset: "auto"),
+ Step("ata2", "focus", asset: "auto"),
+ Step("ata3", "assert", expect: "activity_taxonomy_audit"),
+ Step("ata4", "snapshot")
+ };
+ }
+
+ /// Dump sample activity lines for a few species (dev / product check).
+ private static List ActivityProseShowcase()
+ {
+ return new List
+ {
+ Step("aps0", "wait_world"),
+ Step("aps1", "dismiss_windows"),
+ Step("aps2", "assert", expect: "activity_prose_showcase"),
+ Step("aps3", "snapshot")
+ };
+ }
+
///
/// Timed Fallen/Living list rebuilds at rising subject counts.
/// Picks the highest size under a 2ms Fallen avg budget (runtime cap only for that session).
@@ -453,11 +481,24 @@ internal static class HarnessScenarios
Step("act12", "activity_probe"),
Step("act13", "assert", expect: "activity_first_scoring"),
Step("act13b", "assert", expect: "activity_prose_sentence"),
+ Step("act13b2", "assert", expect: "activity_prose_alive"),
+ Step("act13c", "assert", expect: "activity_prose_species"),
+ Step("act13d", "assert", expect: "activity_prose_grounding"),
+ Step("act13e", "assert", expect: "activity_prose_library"),
+ Step("act13f", "assert", expect: "activity_taxonomy_audit"),
Step("act14", "activity_force", asset: "laughing", label: "Laughing", count: 1),
Step("act14b", "assert", expect: "activity_log_contains", value: "laugh"),
Step("act14c", "activity_force", asset: "eating", label: "Taking a meal", count: 1),
Step("act15", "activity_force", asset: "farm", label: "Tends the fields", count: 1),
Step("act16", "activity_force", asset: "hunt", label: "Hunts", count: 1, expect: "Barkley"),
+ Step("act16b", "activity_force", asset: "task_unit_play", label: "Playing", value: "dog"),
+ Step("act16c", "assert", expect: "activity_log_contains", value: "play"),
+ Step("act16d", "activity_force", asset: "task_unit_play", label: "Playing", value: "cat"),
+ Step("act16e", "assert", expect: "activity_prose_variety", value: "2"),
+ Step("act16f", "activity_force", asset: "BehUnloadResources", label: "wheat@Riverhold", tier: "beat", value: "human"),
+ Step("act16g", "assert", expect: "activity_log_contains", value: "wheat"),
+ Step("act16h", "activity_force", asset: "BehSocializeTalk", label: "Talks", count: 1, tier: "beat", expect: "Barkley"),
+ Step("act16i", "assert", expect: "activity_log_contains", value: "Barkley"),
Step("act17", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 3, tier: "beat"),
Step("act17b", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
Step("act18", "wait", wait: 0.25f),
@@ -753,7 +794,7 @@ internal static class HarnessScenarios
Step("ch18ft2", "lore_tab", value: "fallen"),
Step("ch18ft3", "wait", wait: 0.2f),
Step("ch18ft4", "assert", expect: "lore_tab", value: "fallen"),
- Step("ch18ft5", "assert", expect: "fallen_list_top", value: "Slainbone"),
+ Step("ch18ft5", "assert", expect: "fallen_list_before", value: "Slainbone", label: "Oldbone"),
// Snapshot stays put until Refresh (new deaths do not auto-rebuild).
Step("ch18ft5b", "assert", expect: "lore_list_stale", value: "false"),
Step(
@@ -766,7 +807,7 @@ internal static class HarnessScenarios
expect: "Age",
tier: "place"),
Step("ch18ft5d", "assert", expect: "lore_list_stale", value: "true"),
- Step("ch18ft5e", "assert", expect: "fallen_list_top", value: "Slainbone"),
+ Step("ch18ft5e", "assert", expect: "fallen_list_top_not", value: "FreshFallen"),
Step("ch18ft5f", "lore_refresh"),
Step("ch18ft5g", "assert", expect: "lore_list_stale", value: "false"),
Step("ch18ft5h", "assert", expect: "fallen_list_top", value: "FreshFallen"),
diff --git a/IdleSpectator/InterestCollector.cs b/IdleSpectator/InterestCollector.cs
index 3a8d8d8..df77836 100644
--- a/IdleSpectator/InterestCollector.cs
+++ b/IdleSpectator/InterestCollector.cs
@@ -10,7 +10,7 @@ public static class InterestCollector
public static void OnWorldLogMessage(WorldLogMessage message)
{
- if (message == null)
+ if (message == null || AgentHarness.Busy)
{
return;
}
diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs
index ecef77f..6782d8e 100644
--- a/IdleSpectator/WatchCaption.cs
+++ b/IdleSpectator/WatchCaption.cs
@@ -28,7 +28,7 @@ public static class WatchCaption
private const float TraitsH = 14f;
private const float ReasonH = 14f;
private const float HistoryLineMinH = 13f;
- private const float HistoryLineMaxH = 72f;
+ private const float HistoryLineMaxH = 100f;
private const float Gap = 2f;
private const int HistoryPeekMax = 3;
private const float BtnSize = 16f;
diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json
index bc244f8..eb37d5f 100644
--- a/IdleSpectator/mod.json
+++ b/IdleSpectator/mod.json
@@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
- "version": "0.12.30",
- "description": "AFK Idle Spectator (I) + Lore (L). Full activity prose coverage for actor tasks.",
+ "version": "0.12.70",
+ "description": "AFK Idle Spectator (I) + Lore (L). Detailed living activity prose by default.",
"GUID": "com.dazed.idlespectator"
}