From 88a0c38c00b36ea6b19d3cf7ad3ff622ce53633a Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 02:39:32 -0500 Subject: [PATCH] Enhance ActivityInterestTable and ActivityLog with comprehensive context building for actors. Introduced the BuildContext method to encapsulate actor details, improving activity tracking and logging. Updated ActivityLog to utilize the new context for better clarity in logged actions. Adjusted test scenarios in HarnessScenarios to validate these enhancements. Incremented version in mod.json to reflect the update. --- IdleSpectator/ActivityActionComposer.cs | 178 ++++++ IdleSpectator/ActivityActionLexicon.cs | 370 +++++++++++ IdleSpectator/ActivityAssetCatalog.cs | 212 +++++++ IdleSpectator/ActivityClauseAssembler.cs | 387 +++++++++++ IdleSpectator/ActivityContext.cs | 128 ++++ IdleSpectator/ActivityInterestTable.cs | 508 +++++++++++++++ IdleSpectator/ActivityLog.cs | 184 +++++- IdleSpectator/ActivityProse.cs | 724 +++++++++++++-------- IdleSpectator/ActivityVerbMap.cs | 75 +++ IdleSpectator/ActivityVoice.cs | 176 +++++ IdleSpectator/ActivityVoiceHarness.cs | 384 +++++++++++ IdleSpectator/ActivityVoiceProfiles.cs | 40 ++ IdleSpectator/ActivityVoiceResolver.cs | 468 ++++++++++++++ IdleSpectator/ActorActivityPatches.cs | 174 ++++- IdleSpectator/AgentHarness.cs | 776 ++++++++++++++++++++++- IdleSpectator/HarnessScenarios.cs | 45 +- IdleSpectator/InterestCollector.cs | 2 +- IdleSpectator/WatchCaption.cs | 2 +- IdleSpectator/mod.json | 4 +- 19 files changed, 4534 insertions(+), 303 deletions(-) create mode 100644 IdleSpectator/ActivityActionComposer.cs create mode 100644 IdleSpectator/ActivityActionLexicon.cs create mode 100644 IdleSpectator/ActivityAssetCatalog.cs create mode 100644 IdleSpectator/ActivityClauseAssembler.cs create mode 100644 IdleSpectator/ActivityContext.cs create mode 100644 IdleSpectator/ActivityVerbMap.cs create mode 100644 IdleSpectator/ActivityVoice.cs create mode 100644 IdleSpectator/ActivityVoiceHarness.cs create mode 100644 IdleSpectator/ActivityVoiceProfiles.cs create mode 100644 IdleSpectator/ActivityVoiceResolver.cs 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" }