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" }