From 76e91385f62edbbd8d8454cc5998ed5cb4d5c48c Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 15 Jul 2026 11:54:20 -0500 Subject: [PATCH] Refactor activity action handling to improve clarity and functionality. Removed the ActivityActionLexicon class and integrated its logic into ActivityActionComposer, enhancing the management of action phrases. Updated various methods to streamline action processing and ensure proper context handling for verbs. Adjusted ActivityModifierVoiceOverlay and ActivityClauseAssembler to support new action structures. Incremented version in mod.json to reflect these changes. --- .cursor/skills/idle-spectator-e2e/SKILL.md | 9 + IdleSpectator/ActivityActionComposer.cs | 146 +++++++- IdleSpectator/ActivityActionLexicon.cs | 344 ------------------ IdleSpectator/ActivityClauseAssembler.cs | 271 ++++++-------- IdleSpectator/ActivityModifierVoiceOverlay.cs | 34 +- IdleSpectator/ActivityProse.cs | 7 +- IdleSpectator/ActivitySpeciesVoiceCatalog.cs | 37 +- .../ActivitySpeciesVoices.Invertebrates.cs | 2 +- .../ActivitySpeciesVoices.Mammals.Extended.cs | 18 +- .../ActivitySpeciesVoices.Mammals.cs | 2 +- IdleSpectator/ActivityVoiceHarness.cs | 193 +++++++++- IdleSpectator/AgentHarness.cs | 134 ++++++- IdleSpectator/HarnessScenarios.cs | 13 + IdleSpectator/UnitDossier.cs | 69 +++- IdleSpectator/WatchCaption.cs | 22 ++ IdleSpectator/mod.json | 2 +- 16 files changed, 729 insertions(+), 574 deletions(-) delete mode 100644 IdleSpectator/ActivityActionLexicon.cs diff --git a/.cursor/skills/idle-spectator-e2e/SKILL.md b/.cursor/skills/idle-spectator-e2e/SKILL.md index 2720d26..afb9eb1 100644 --- a/.cursor/skills/idle-spectator-e2e/SKILL.md +++ b/.cursor/skills/idle-spectator-e2e/SKILL.md @@ -13,6 +13,15 @@ Always validate IdleSpectator changes with the in-game harness. Do not rely on OS mouse/keyboard automation (Wayland-flaky). Do not call `Application.Quit()` (can hang Steam on Linux). +## Sandbox / permissions (required) + +**Never start, relaunch, or kill WorldBox / Steam from the Cursor sandbox.** + +- `scripts/harness-run.sh` may launch the game when it is not already running. +- `pkill`, Steam, and game process control also fail or misbehave under sandbox. +- Always run harness commands (and any WorldBox process control) with **unrestricted / `all` permissions** so they execute outside the sandbox. +- Prefer `--no-launch` only when WorldBox is already running *and* you still use unrestricted permissions for the runner itself (log tail, `.harness` I/O under the live mod path). + ## WorldBox wiki (mechanics source of truth) When deciding what is “interesting” for Spectator or Chronicle, **read the official wiki** before inventing behavior: diff --git a/IdleSpectator/ActivityActionComposer.cs b/IdleSpectator/ActivityActionComposer.cs index 2f84354..d0d7453 100644 --- a/IdleSpectator/ActivityActionComposer.cs +++ b/IdleSpectator/ActivityActionComposer.cs @@ -24,9 +24,80 @@ public static class ActivityActionComposer verb = "work"; } - string[] bag = ActivityActionLexicon.GetBag(voice, verb, hasTarget, ctx.Terrain); + string[] bag = GetBag(voice, verb, hasTarget, ctx.Terrain); string phrase = Pick(bag, subjectId, lineIndex); - return ActivityModifierVoiceOverlay.Apply(phrase, voice, verb, subjectId, lineIndex); + phrase = ActivityModifierVoiceOverlay.Apply(phrase, voice, verb, subjectId, lineIndex); + return EnsureUnloadTokens(key, phrase); + } + + private static string[] GetBag( + ProseVoice voice, + string verb, + bool hasTarget, + ActivityTerrain terrain) + { + voice ??= ProseVoice.Unknown(); + string v = string.IsNullOrEmpty(verb) ? "move" : verb; + + if (voice.AssetKind == ActivityAssetKind.Vehicle) + { + return VehicleBag(v); + } + + string[] authored = ActivitySpeciesVoiceCatalog.GetBag(voice.BaseSpeciesId, v, terrain); + if (authored != null && authored.Length > 0) + { + return authored; + } + + // Last resort for unknown future assets - not the normal prose path. + return TinyFallback(v, hasTarget); + } + + /// + /// Haul-to-store keeps place and names cargo once (no trailing while-carrying needed). + /// + private static string EnsureUnloadTokens(string key, string phrase) + { + if (string.IsNullOrEmpty(phrase) || string.IsNullOrEmpty(key)) + { + return phrase ?? ""; + } + + if (!key.Equals("BehUnloadResources", StringComparison.OrdinalIgnoreCase) + && !key.Equals("BehThrowResources", StringComparison.OrdinalIgnoreCase)) + { + return phrase; + } + + string result = phrase; + if (result.IndexOf("{carrying}", StringComparison.Ordinal) < 0) + { + result = "unloads {carrying}, " + LowerFirst(result); + } + + if (result.IndexOf("{place_at}", StringComparison.Ordinal) < 0 + && result.IndexOf("{place}", StringComparison.Ordinal) < 0) + { + result += "{place_at}"; + } + + return result; + } + + 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); } private static ProseVoice EnsureVoice(ActivityContext ctx) @@ -56,7 +127,7 @@ public static class ActivityActionComposer { if (bag == null || bag.Length == 0) { - return "moves along{place_at}{job_as}"; + return "moves along"; } if (bag.Length == 1) @@ -68,4 +139,73 @@ public static class ActivityActionComposer int index = actorVariation % Math.Min(2, bag.Length); return bag[index]; } + + private static string[] VehicleBag(string verb) + { + return verb switch + { + "move" => new[] { "cuts steadily across the water", "sails onward with the current" }, + "wait" => new[] { "rides quietly at rest on the water", "holds position on the gentle swell" }, + "fish" => new[] { "works the water for fish", "casts for a catch from the deck" }, + "trade" => new[] { "carries trade across the water{place_at}", "makes a measured trading run{place_at}" }, + "haul" => new[] { "takes cargo aboard", "settles the load for passage" }, + "settle" => new[] { "holds at the quay{place_at}", "ties up for harbor business{place_at}" }, + "farm" => new[] { "works deck cargo from the fields{place_at}", "ferries produce to harbor{place_at}" }, + _ => new[] { "continues working on the water", "keeps steadily to its course" } + }; + } + + private static string[] TinyFallback(string verb, bool hasTarget) + { + string place = (verb == "settle" || verb == "trade" || verb == "farm") ? "{place_at}" : ""; + return verb switch + { + "farm" => new[] { "tends the fields with patient care" + place, "works the soil from row to row" + place }, + "haul" => new[] { "unloads {carrying} after a long haul", "delivers {carrying} into storage" }, + "pollinate" => new[] { "works carefully among the blossoms", "moves steadily from flower to flower" }, + "fish" => new[] { "works the water for fish", "waits patiently for a bite" }, + "heal" => hasTarget + ? new[] { "tends carefully to {target}", "works to heal {target}" } + : new[] { "tends wounds with careful hands", "works patiently to recover" }, + "fire" => new[] { "fights the blaze with urgent care", "battles fire before it spreads" }, + "flee" => new[] { "bolts away from danger", "retreats before danger closes in" }, + "work" => new[] { "works steadily at the task", "labors through the task at hand" }, + "trade" => new[] { "trades and bargains" + place, "haggles through a careful deal" + place }, + "settle" => new[] { "handles civic business" + place, "sees to matters around the settlement" + place }, + "warrior" => hasTarget + ? new[] { "holds formation against {target}", "drills for war near {target}" } + : new[] { "stands ready for battle", "keeps formation ready" }, + "read" => new[] { "reads with quiet attention", "studies the page" }, + "lover" => hasTarget + ? new[] { "draws close to {target}", "shares a tender moment with {target}" } + : new[] { "follows the pull of kinship", "seeks companionship" }, + "gather_life" => new[] { "gathers with kin", "moves among the group" }, + "move" => new[] { "wanders without hurry", "roams nearby, looking around" }, + "wait" => new[] { "waits in watchful quiet", "stands still and watches" }, + "play" => new[] { "paces, turns, and doubles back", "moves in quick, restless bursts" }, + "eat" => new[] { "sits down to eat", "finishes a meal" }, + "sleep" => new[] { "settles into deep sleep", "rests after the day's demands" }, + "hunt" => hasTarget + ? new[] { "pursues {target}", "closes in on {target}" } + : new[] { "searches for prey", "moves quietly on the hunt" }, + "fight" => hasTarget + ? new[] { "fights {target} in a fierce clash", "trades blows with {target}" } + : new[] { "fights with fierce resolve", "clashes without giving ground" }, + "social" => hasTarget + ? new[] { "spends time with {target}", "talks and lingers with {target}" } + : new[] { "looks for company", "lingers near others" }, + "laugh" => new[] { "laughs out loud", "breaks into bright laughter" }, + _ => hasTarget + ? new[] + { + "attends to " + verb.Replace('_', ' ') + " with {target}", + "works on " + verb.Replace('_', ' ') + " near {target}" + } + : new[] + { + "attends to " + verb.Replace('_', ' '), + "works through " + verb.Replace('_', ' ') + } + }; + } } diff --git a/IdleSpectator/ActivityActionLexicon.cs b/IdleSpectator/ActivityActionLexicon.cs deleted file mode 100644 index d780f69..0000000 --- a/IdleSpectator/ActivityActionLexicon.cs +++ /dev/null @@ -1,344 +0,0 @@ -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, - ActivityTerrain terrain = ActivityTerrain.Unknown) - { - voice ??= ProseVoice.Unknown(); - string v = string.IsNullOrEmpty(verb) ? "move" : verb; - - if (voice.AssetKind == ActivityAssetKind.Vehicle) - { - return VehicleBag(v); - } - - string[] authored = ActivitySpeciesVoiceCatalog.GetBag(voice.BaseSpeciesId, v, terrain); - if (authored != null && authored.Length > 0) - { - return authored; - } - - // Safe fallback for unknown future assets that are not yet in the authored catalog. - if (voice.Modifier != ActivityModifier.None) - { - string[] modifier = ModifierBag(voice, v, hasTarget); - if (modifier != null) return modifier; - } - - string[] body = BodyBag(voice.BaseBodyTag, v, hasTarget, terrain); - 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 ahead in a trail of flame{place_at}", "flows forward in a rolling blaze{place_at}" }, - "wait" => new[] { "flickers in place{place_at}", "burns steadily while waiting{place_at}" }, - "play" => new[] { "flickers into quick spirals and sudden bursts{place_at}", "loops in bright arcs of flame{place_at}" }, - "eat" => new[] { "feeds the inner flame{place_at}", "draws fuel into the flames{place_at}" }, - "sleep" => new[] { "dims into a banked rest{place_at}", "settles into a low, steady 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 as flames flicker and rise{place_at}"), - "fight" => Targeted(hasTarget, "crashes into {target} in a burst of flame{place_at}", "flares against {target}{place_at}", "fights in a storm of sparks{place_at}", "clashes amid sparks and flame{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 ahead in soft, uneven steps{place_at}", "creeps forward as spores drift{place_at}" }, - "wait" => new[] { "rests while spores drift around it{place_at}", "waits while faint spores drift{place_at}" }, - "play" => new[] { "bobs and puffs spores in short bursts{place_at}", "wobbles about, shedding little clouds of spores{place_at}" }, - "eat" => new[] { "feeds slowly on decaying matter{place_at}", "draws a slow meal from decay{place_at}" }, - "sleep" => new[] { "settles into a soft, quiet rest{place_at}", "folds down among drifting spores{place_at}" }, - _ => GenericBag(verb, hasTarget) - }; - case ActivityModifier.Tumor: - return verb switch - { - "move" => new[] { "pulses forward as its mass shifts{place_at}", "lurches ahead in uneven surges{place_at}" }, - "wait" => new[] { "throbs in unsettling stillness{place_at}", "quivers without moving on{place_at}" }, - "play" => new[] { "writhes and recoils in restless loops{place_at}", "pulses in an oddly buoyant rhythm{place_at}" }, - "eat" => new[] { "feeds with consuming hunger{place_at}", "absorbs food into the growing mass{place_at}" }, - "fight" => Targeted(hasTarget, "lashes into {target} with terrible force{place_at}", "surges against {target}{place_at}", "fights in a thrashing fury{place_at}", "clashes in a violent rush{place_at}"), - _ => GenericBag(verb, hasTarget) - }; - case ActivityModifier.Alien: - return verb switch - { - "move" => new[] { "glides ahead without a sound{place_at}", "tracks a precise, unfamiliar course{place_at}" }, - "wait" => new[] { "observes in unreadable stillness{place_at}", "stands motionless and watches{place_at}" }, - "play" => new[] { "repeats curious patterns of motion{place_at}", "darts, pauses, and changes direction without warning{place_at}" }, - "hunt" => Targeted(hasTarget, "tracks {target} without a sound{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 uncanny precision{place_at}", "clashes with {target} in sudden bursts{place_at}", "fights with unreadable intent{place_at}", "attacks in a precise rush{place_at}"), - _ => GenericBag(verb, hasTarget) - }; - case ActivityModifier.Construct: - return verb switch - { - "move" => new[] { "moves in measured, jointed steps{place_at}", "shifts forward with deliberate precision{place_at}" }, - "wait" => new[] { "idles in rigid watchfulness{place_at}", "stays motionless, mechanisms quiet{place_at}" }, - "play" => new[] { "repeats a sequence of precise motions{place_at}", "spins and resets with mechanical exactness{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 ahead with a stiff, uneven gait{place_at}", "sniffs ahead and shambles on{place_at}" }, - ActivityBodyTag.Feline => new[] { "slinks ahead in eerie silence{place_at}", "pads forward with stiff precision{place_at}" }, - ActivityBodyTag.Insect => new[] { "skitters ahead on stiff little legs{place_at}", "crawls forward in twitching bursts{place_at}" }, - ActivityBodyTag.Plant => new[] { "creeps ahead in withered motion{place_at}", "drags dead growth behind it{place_at}" }, - ActivityBodyTag.Dragon => new[] { "lumbers ahead with the weight of a fallen giant{place_at}", "advances like ruin given motion{place_at}" }, - ActivityBodyTag.Bird => new[] { "flutters ahead on lifeless wings{place_at}", "hops forward in ragged, uneven motions{place_at}" }, - _ => new[] { "shambles forward in uneven steps{place_at}", "drifts along in restless undeath{place_at}" } - }; - } - - return verb switch - { - "wait" => new[] { "waits in hollow stillness{place_at}", "stands in restless silence{place_at}" }, - "play" => new[] { "lurches in clumsy circles{place_at}", "repeats jerking motions with eerie persistence{place_at}" }, - "eat" => new[] { "feeds with deathless hunger{place_at}", "tears into a meal with endless hunger{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, "lunges at {target} without fear{place_at}", "clashes with {target} in relentless fury{place_at}", "fights without fear or hesitation{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}", "stands beside {target} without a word{place_at}", "seeks company in deathly quiet{place_at}", "lingers among the living{place_at}"), - _ => GenericBag(verb, hasTarget) - }; - } - - private static string[] BodyBag( - ActivityBodyTag body, - string verb, - bool hasTarget, - ActivityTerrain terrain) - { - if (verb == "move") - { - return body switch - { - ActivityBodyTag.Humanoid => new[] { "walks along, taking in the surroundings{place_at}", "wanders ahead at an easy pace{place_at}" }, - ActivityBodyTag.Canine => new[] { "trots along without hurry{place_at}", "roams ahead, following a scent{place_at}" }, - ActivityBodyTag.Feline => new[] { "pads softly along{place_at}", "slinks ahead at an easy pace{place_at}" }, - ActivityBodyTag.Bird => new[] { "flutters steadily onward{place_at}", "hops and flaps along nearby{place_at}" }, - ActivityBodyTag.Insect => new[] { "scuttles along without hurry{place_at}", "skitters quickly forward{place_at}" }, - ActivityBodyTag.Arachnid => new[] { "skitters onward on many legs{place_at}", "creeps ahead with careful steps{place_at}" }, - ActivityBodyTag.Amphibian => new[] { "hops from patch to patch{place_at}", "bounces along in springy steps{place_at}" }, - ActivityBodyTag.Aquatic => AquaticTerrainBag( - terrain, - "glides through the water{place_at}", - "swims onward with easy strokes{place_at}", - "moves forward in a smooth, winding motion{place_at}", - "twists steadily onward{place_at}"), - ActivityBodyTag.Reptile => new[] { "slides along without hurry{place_at}", "creeps forward in a low crawl{place_at}" }, - ActivityBodyTag.Livestock => new[] { "ambles along without hurry{place_at}", "wanders in slow, heavy steps{place_at}" }, - ActivityBodyTag.Plant => new[] { "creeps along as stems and leaves sway{place_at}", "eases forward in slow, leafy steps{place_at}" }, - ActivityBodyTag.Fungi => new[] { "drifts along in soft, uneven steps{place_at}", "spreads forward as spores trail behind{place_at}" }, - ActivityBodyTag.Protist => new[] { "oozes onward without hurry{place_at}", "pulses along in slow ripples{place_at}" }, - ActivityBodyTag.Machina => new[] { "moves ahead in measured steps{place_at}", "tracks a precise course{place_at}" }, - ActivityBodyTag.Mythic => new[] { "drifts forward without a sound{place_at}", "moves with an uncanny grace{place_at}" }, - ActivityBodyTag.Gregalia => new[] { "lopes onward with an uneven gait{place_at}", "wanders in twitchy, restless steps{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 forward on its belly{place_at}", "bounds ahead with a sleek, rolling motion{place_at}" }, - ActivityBodyTag.Procyonid => new[] { "pads onward, nosing around{place_at}", "scampers ahead in nimble steps{place_at}" }, - ActivityBodyTag.Hyaenid => new[] { "lopes onward with rough confidence{place_at}", "trots ahead in rangy strides{place_at}" }, - ActivityBodyTag.Armadillo => new[] { "trundles onward with its body held low{place_at}", "scurries ahead in armored little steps{place_at}" }, - ActivityBodyTag.Crawler => new[] { "writhes steadily forward{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 in stiff, glittering steps{place_at}" }, - ActivityBodyTag.Crab => new[] { "scuttles sideways{place_at}", "sidesteps onward behind a hard shell{place_at}" }, - _ => GenericBag(verb, hasTarget) - }; - } - - if (verb == "play") - { - return body switch - { - ActivityBodyTag.Canine => new[] { "tumbles, bounds, and wheels around{place_at}", "bounds, feints, and wrestles about{place_at}" }, - ActivityBodyTag.Feline => new[] { "bats, twists, and springs aside{place_at}", "pounces and leaps without warning{place_at}" }, - ActivityBodyTag.Bird => new[] { "flutters in looping arcs{place_at}", "hops and flaps about{place_at}" }, - ActivityBodyTag.Insect => new[] { "scuttles in quick zigzags{place_at}", "darts about in short bursts{place_at}" }, - ActivityBodyTag.Arachnid => new[] { "skitters in quick loops{place_at}", "darts about on many legs{place_at}" }, - ActivityBodyTag.Amphibian => new[] { "hops about in lively bursts{place_at}", "bounces back and forth{place_at}" }, - ActivityBodyTag.Aquatic => AquaticTerrainBag( - terrain, - "splashes and darts through the water{place_at}", - "weaves quick circles through the water{place_at}", - "twists and darts about{place_at}", - "wriggles in quick loops{place_at}"), - ActivityBodyTag.Crab => new[] { "scuttles in quick sideways loops{place_at}", "sidesteps and spins about{place_at}" }, - ActivityBodyTag.Reptile => new[] { "slides, coils, and doubles back{place_at}", "winds about in slow loops{place_at}" }, - ActivityBodyTag.Livestock => new[] { "stomps and bounds clumsily{place_at}", "frolics in heavy, eager bursts{place_at}" }, - ActivityBodyTag.Plant => new[] { "sways and twists in slow arcs{place_at}", "twists and rustles energetically{place_at}" }, - ActivityBodyTag.Primate => new[] { "clambers, swings, and doubles back{place_at}", "scrambles about with nimble mischief{place_at}" }, - ActivityBodyTag.Rodent => new[] { "scampers in tight circles{place_at}", "darts about in quick zigzags{place_at}" }, - ActivityBodyTag.Ursid => new[] { "rolls over and lumbers upright again{place_at}", "lumbers about with clumsy cheer{place_at}" }, - ActivityBodyTag.Crawler => new[] { "loops around and doubles back{place_at}", "writhes about in restless circles{place_at}" }, - _ => new[] { "paces, turns, and doubles back{place_at}", "moves in quick, restless bursts{place_at}" } - }; - } - - if (verb == "wait") - { - return body switch - { - ActivityBodyTag.Insect => new[] { "holds still with its antennae raised{place_at}", "waits while its antennae twitch{place_at}" }, - ActivityBodyTag.Amphibian => new[] { "crouches low and waits{place_at}", "sits still, ready to spring{place_at}" }, - ActivityBodyTag.Feline => new[] { "settles into a watchful crouch{place_at}", "waits without looking away{place_at}" }, - ActivityBodyTag.Canine => new[] { "waits with alert ears{place_at}", "holds a restless pause{place_at}" }, - ActivityBodyTag.Aquatic => AquaticTerrainBag( - terrain, - "hovers in the water and waits{place_at}", - "drifts almost motionless in the water{place_at}", - "rests almost motionless{place_at}", - "waits with only a faint twitch{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}", "stands still with leaves barely stirring{place_at}" }, - ActivityBodyTag.Machina => new[] { "idles in a watchful hold{place_at}", "stays still and scans the surroundings{place_at}" }, - _ => new[] { "waits in watchful quiet{place_at}", "stands quietly and watches{place_at}" } - }; - } - - if (verb == "hunt") - { - return body switch - { - ActivityBodyTag.Canine => Targeted(hasTarget, "hunts {target} by scent{place_at}", "follows {target}'s trail{place_at}", "follows the scent of prey{place_at}", "hunts by scent{place_at}"), - ActivityBodyTag.Feline => Targeted(hasTarget, "hunts {target} in silence{place_at}", "stalks {target} without a sound{place_at}", "hunts without making a sound{place_at}", "stalks prey in silence{place_at}"), - ActivityBodyTag.Bird => Targeted(hasTarget, "tracks {target} with sharp eyes{place_at}", "circles after {target}{place_at}", "searches keenly for prey{place_at}", "circles after prey{place_at}"), - ActivityBodyTag.Insect => Targeted(hasTarget, "scuttles after {target}{place_at}", "darts toward {target}{place_at}", "searches for prey in quick bursts{place_at}", "scuttles after prey{place_at}"), - ActivityBodyTag.Reptile => Targeted(hasTarget, "slides after {target}{place_at}", "creeps closer to {target}{place_at}", "waits low for prey to approach{place_at}", "stalks prey in a low crawl{place_at}"), - _ => Targeted(hasTarget, "pursues {target}{place_at}", "closes in on {target}{place_at}", "searches for prey{place_at}", "moves quietly on the hunt{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[] { "gulps down a meal{place_at}", "tears into a meal{place_at}" }, - ActivityBodyTag.Feline => new[] { "eats in small, careful bites{place_at}", "takes its time over a meal{place_at}" }, - ActivityBodyTag.Insect => new[] { "nibbles rapidly at a meal{place_at}", "eats in quick little bites{place_at}" }, - ActivityBodyTag.Plant => new[] { "draws nourishment slowly{place_at}", "feeds without moving from the spot{place_at}" }, - ActivityBodyTag.Livestock => new[] { "grazes with steady appetite{place_at}", "settles in to feed{place_at}" }, - _ => new[] { "sits down to eat{place_at}", "finishes a meal{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 => AquaticTerrainBag( - terrain, - "drifts into rest in the water{place_at}", - "settles almost motionless in the water{place_at}", - "goes still to rest{place_at}", - "settles into a quiet sleep{place_at}"), - ActivityBodyTag.Plant => new[] { "settles into leafy rest{place_at}", "goes completely still{place_at}" }, - _ => new[] { "settles into deep sleep{place_at}", "rests after the day's demands{place_at}" } - }, - "social" => Targeted(hasTarget, "spends time with {target}{place_at}", "talks and lingers with {target}{place_at}", "looks for company{place_at}", "lingers near others{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}", "raises a clear 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[] { "continues working on the water{place_at}", "keeps steadily to its course{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 from row to row{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 carefully among the blossoms{place_at}", "moves steadily from flower to flower{place_at}" }, - "fish" => new[] { "works the water for fish{place_at}{job_as}", "waits patiently for a bite{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[] { "bolts away from danger{place_at}", "retreats before danger closes in{place_at}" }, - "work" => new[] { "works steadily at the task{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[] { "handles civic business{place_at}{job_as}", "sees to matters around the settlement{place_at}" }, - "warrior" => Targeted(hasTarget, "holds formation against {target}{place_at}", "drills for war near {target}{place_at}", "stands ready for battle{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 companionship{place_at}"), - "gather_life" => new[] { "gathers with kin{place_at}", "moves among the group{place_at}" }, - "move" => new[] { "wanders without hurry{place_at}", "roams nearby, looking around{place_at}" }, - "wait" => new[] { "waits in watchful quiet{place_at}", "stands still and watches{place_at}" }, - "play" => new[] { "paces, turns, and doubles back{place_at}", "moves in quick, restless bursts{place_at}" }, - "eat" => new[] { "sits down to eat{place_at}", "finishes a meal{place_at}" }, - "sleep" => new[] { "settles into deep sleep{place_at}", "rests after the day's demands{place_at}" }, - "hunt" => Targeted(hasTarget, "pursues {target}{place_at}", "closes in on {target}{place_at}", "searches for prey{place_at}", "moves quietly on the hunt{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, "spends time with {target}{place_at}", "talks and lingers with {target}{place_at}", "looks for company{place_at}", "lingers near others{place_at}"), - _ => hasTarget - ? new[] { "attends to " + verb.Replace('_', ' ') + " with {target}{place_at}", "works on " + verb.Replace('_', ' ') + " near {target}{place_at}" } - : new[] { "attends to " + verb.Replace('_', ' ') + "{place_at}", "works through " + verb.Replace('_', ' ') + "{place_at}" } - }; - } - - private static string[] Targeted( - bool hasTarget, - string withTargetA, - string withTargetB, - string noTargetA, - string noTargetB) - { - return hasTarget - ? new[] { withTargetA, withTargetB } - : new[] { noTargetA, noTargetB }; - } - - private static string[] AquaticTerrainBag( - ActivityTerrain terrain, - string liquidA, - string liquidB, - string nonLiquidA, - string nonLiquidB) - { - return terrain == ActivityTerrain.Liquid - ? new[] { liquidA, liquidB } - : new[] { nonLiquidA, nonLiquidB }; - } -} diff --git a/IdleSpectator/ActivityClauseAssembler.cs b/IdleSpectator/ActivityClauseAssembler.cs index 38c1ce8..0370889 100644 --- a/IdleSpectator/ActivityClauseAssembler.cs +++ b/IdleSpectator/ActivityClauseAssembler.cs @@ -4,11 +4,20 @@ namespace IdleSpectator; /// /// Assembles one alive activity sentence from observed clauses. -/// Packs observed slots (life, identity, role/job, act, target, carrying, place, combat). +/// Packs observed slots (life, identity, act, target, carrying, place, combat). /// Traits salt wording variety only - never named in the line (dossier already shows them). +/// Job / role stay on the dossier reason row - never in Activity prose. /// public static class ActivityClauseAssembler { + private static readonly string[] CargoImplicationWords = + { + "carrying", "haul", "hauls", "hauling", "load", "loads", "loading", + "goods", "wheat", "berries", "spoils", "material", "cargo", "delivery", + "unload", "unloads", "unloading", "store", "stores", "storage", "delivers", + "weight", "bears", "heaves", "drags", "pulls", "draws", "packs", "carries" + }; + /// /// Build a template still containing tokens for . /// Optional is celebrity/family/beat flavor - still enriched. @@ -34,25 +43,12 @@ public static class ActivityClauseAssembler subjectId, lineIndex); - bool placeInAppositive = false; - string subject = BuildSubject(ctx, action, out placeInAppositive); + // Strip any leftover job tokens - job belongs on the dossier, not Activity. + action = StripToken(action, "{job_as}"); + action = StripToken(action, "{job}"); - 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}"); - } - } + string subject = BuildSubject(ctx); + action = BindSingletonCompanions(ctx, action); // Traits stay on the dossier chips - they only salt variety below, never named here. @@ -79,6 +75,73 @@ public static class ActivityClauseAssembler return " with {target}"; } + /// + /// Rewrite singleton companion wording to the observed name; leave plural group wording alone. + /// + private static string BindSingletonCompanions(ActivityContext ctx, string action) + { + if (ctx == null + || !ctx.TargetIsActor + || string.IsNullOrEmpty(ctx.TargetName) + || string.IsNullOrEmpty(action) + || action.IndexOf("{target}", StringComparison.Ordinal) >= 0) + { + return action; + } + + string t = action; + + // Longest / most specific first. Do not rewrite "among the others", "nearby family", + // or "nearby group". + t = ReplaceIgnoreCase(t, "another's", "{target}'s"); + t = ReplaceIgnoreCase(t, "another presence", "{target}"); + t = ReplaceIgnoreCase(t, "nearby company", "{target}"); + t = ReplaceIgnoreCase(t, "offers another", "offers {target}"); + t = ReplaceIgnoreCase(t, "with another", "with {target}"); + t = ReplaceIgnoreCase(t, "beside another", "beside {target}"); + t = ReplaceIgnoreCase(t, "against another", "against {target}"); + t = ReplaceIgnoreCase(t, "around another", "around {target}"); + t = ReplaceIgnoreCase(t, "facing another", "facing {target}"); + t = ReplaceIgnoreCase(t, "near another", "near {target}"); + t = ReplaceIgnoreCase(t, "from another", "from {target}"); + t = ReplaceIgnoreCase(t, " to another", " to {target}"); + t = ReplaceIgnoreCase(t, "greets another", "greets {target}"); + t = ReplaceIgnoreCase(t, "claps another", "claps {target}"); + t = ReplaceIgnoreCase(t, "nuzzles another", "nuzzles {target}"); + t = ReplaceIgnoreCase(t, "grooms another", "grooms {target}"); + t = ReplaceIgnoreCase(t, "circles another", "circles {target}"); + t = ReplaceIgnoreCase(t, "mirrors another", "mirrors {target}"); + t = ReplaceIgnoreCase(t, "answers another", "answers {target}"); + t = ReplaceIgnoreCase(t, "touches another", "touches {target}"); + t = ReplaceIgnoreCase(t, "joins another", "joins {target}"); + t = ReplaceIgnoreCase(t, "faces another", "faces {target}"); + t = ReplaceIgnoreCase(t, "sits beside another", "sits beside {target}"); + t = ReplaceIgnoreCase(t, "rests flank to flank beside another", "rests flank to flank beside {target}"); + t = ReplaceIgnoreCase(t, "winds around another", "winds around {target}"); + t = ReplaceIgnoreCase(t, "clucks back and forth with another", "clucks back and forth with {target}"); + t = ReplaceIgnoreCase(t, "rumbles a low greeting to another", "rumbles a low greeting to {target}"); + t = ReplaceIgnoreCase(t, "croaks a greeting to another", "croaks a greeting to {target}"); + t = ReplaceIgnoreCase(t, "shoulder-bumps another", "shoulder-bumps {target}"); + t = ReplaceIgnoreCase(t, "exchanges a sweeping bow with another", "exchanges a sweeping bow with {target}"); + t = ReplaceIgnoreCase(t, "bows stiffly to another", "bows stiffly to {target}"); + t = ReplaceIgnoreCase(t, "claps a loud welcome to another", "claps a loud welcome to {target}"); + t = ReplaceIgnoreCase(t, "shares low whispers with another", "shares low whispers with {target}"); + t = ReplaceIgnoreCase(t, "clasps forearms with another", "clasps forearms with {target}"); + t = ReplaceIgnoreCase(t, "exchanges brief greetings with another", "exchanges brief greetings with {target}"); + t = ReplaceIgnoreCase(t, "trades names and short updates with another", "trades names and short updates with {target}"); + t = ReplaceIgnoreCase(t, "greets another with", "greets {target} with"); + t = ReplaceIgnoreCase(t, " another a ", " {target} a "); + t = ReplaceIgnoreCase(t, " another on ", " {target} on "); + t = ReplaceIgnoreCase(t, " another with ", " {target} with "); + t = ReplaceIgnoreCase(t, " another in ", " {target} in "); + t = ReplaceIgnoreCase(t, " another during ", " {target} during "); + t = ReplaceIgnoreCase(t, " another while ", " {target} while "); + t = ReplaceIgnoreCase(t, " another between ", " {target} between "); + t = ReplaceIgnoreCase(t, " another and ", " {target} and "); + + return t; + } + private static string BuildCarryingClause(ActivityContext ctx, string action) { if (ctx == null @@ -89,57 +152,37 @@ public static class ActivityClauseAssembler return ""; } + if (ActionImpliesLoad(action)) + { + return ""; + } + return " while carrying {carrying}"; } - private static string BuildSubject(ActivityContext ctx, string action, out bool placeInAppositive) + private static bool ActionImpliesLoad(string action) { - 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)) + if (string.IsNullOrEmpty(action)) { - return life + identity; + return false; } - return life + identity + ", " + appositive + ","; + for (int i = 0; i < CargoImplicationWords.Length; i++) + { + if (action.IndexOf(CargoImplicationWords[i], StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + + return false; } - private static string BuildAppositive(ActivityContext ctx, string action, out bool placeInAppositive) + private static string BuildSubject(ActivityContext ctx) { - 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; + string life = ctx != null && ctx.IsChild ? "young " : ""; + // Species stays on the dossier nametag (Name (species)) - never "the angle" / "the dog" here. + return life + "{actor}"; } private static string BuildPressureClause(ActivityContext ctx, string action) @@ -172,29 +215,6 @@ public static class ActivityClauseAssembler return " while danger presses close"; } - 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) { @@ -242,16 +262,6 @@ public static class ActivityClauseAssembler 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) { @@ -279,22 +289,6 @@ public static class ActivityClauseAssembler 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. /// @@ -336,50 +330,6 @@ public static class ActivityClauseAssembler 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)) @@ -406,19 +356,4 @@ public static class ActivityClauseAssembler 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/ActivityModifierVoiceOverlay.cs b/IdleSpectator/ActivityModifierVoiceOverlay.cs index 9c43a9a..3da35a5 100644 --- a/IdleSpectator/ActivityModifierVoiceOverlay.cs +++ b/IdleSpectator/ActivityModifierVoiceOverlay.cs @@ -8,7 +8,7 @@ namespace IdleSpectator; /// public static class ActivityModifierVoiceOverlay { - private const string ContextTokens = "{place_at}{job_as}"; + private const string PlaceToken = "{place_at}"; public static string Apply( string basePhrase, @@ -32,10 +32,34 @@ public static class ActivityModifierVoiceOverlay } int index = (int)((subjectId ^ (lineIndex * 397L)) & 1L) % bag.Length; - string core = basePhrase.EndsWith(ContextTokens, StringComparison.Ordinal) - ? basePhrase.Substring(0, basePhrase.Length - ContextTokens.Length) - : basePhrase; - return core + bag[index] + ContextTokens; + string core = basePhrase + .Replace("{job_as}", "") + .Replace("{job}", ""); + + bool restorePlace = false; + if (WantsPlace(verb) && core.EndsWith(PlaceToken, StringComparison.Ordinal)) + { + core = core.Substring(0, core.Length - PlaceToken.Length); + restorePlace = true; + } + else if (core.EndsWith(PlaceToken, StringComparison.Ordinal)) + { + // Never introduce or keep place on non-place-centric verbs via overlay. + core = core.Substring(0, core.Length - PlaceToken.Length); + } + + string result = core + bag[index]; + if (restorePlace) + { + result += PlaceToken; + } + + return result; + } + + private static bool WantsPlace(string verb) + { + return verb == "settle" || verb == "trade" || verb == "farm"; } private static string[] OverlayBag( diff --git a/IdleSpectator/ActivityProse.cs b/IdleSpectator/ActivityProse.cs index e6c4de5..790cb7b 100644 --- a/IdleSpectator/ActivityProse.cs +++ b/IdleSpectator/ActivityProse.cs @@ -190,22 +190,21 @@ public static class ActivityProse place = ""; } - string job = HumanizeJob(ctx.JobId); string carrying = string.IsNullOrEmpty(ctx.CarryingLabel) ? "goods" : ctx.CarryingLabel; string withClause = string.IsNullOrEmpty(target) ? "" : (" with " + target); + // Empty place → empty clause (no forced "in town"). Job never appears in Activity. 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("{job_as}", ""); + t = t.Replace("{job}", ""); t = t.Replace("{carrying}", carrying); t = t.Replace("{place}", string.IsNullOrEmpty(place) ? "town" : place); - t = t.Replace("{job}", string.IsNullOrEmpty(job) ? "their work" : job); if (string.IsNullOrEmpty(target)) { diff --git a/IdleSpectator/ActivitySpeciesVoiceCatalog.cs b/IdleSpectator/ActivitySpeciesVoiceCatalog.cs index c19b243..1ceacff 100644 --- a/IdleSpectator/ActivitySpeciesVoiceCatalog.cs +++ b/IdleSpectator/ActivitySpeciesVoiceCatalog.cs @@ -74,16 +74,16 @@ public static partial class ActivitySpeciesVoiceCatalog { var bags = new Dictionary(StringComparer.Ordinal) { - ["move"] = Contextualize(move), - ["wait"] = Contextualize(wait), - ["play"] = Contextualize(play), - ["eat"] = Contextualize(eat), - ["sleep"] = Contextualize(sleep), - ["hunt"] = Contextualize(hunt), - ["fight"] = Contextualize(fight), - ["social"] = Contextualize(social), - ["laugh"] = Contextualize(laugh), - ["work"] = Contextualize(work) + ["move"] = Contextualize("move", move), + ["wait"] = Contextualize("wait", wait), + ["play"] = Contextualize("play", play), + ["eat"] = Contextualize("eat", eat), + ["sleep"] = Contextualize("sleep", sleep), + ["hunt"] = Contextualize("hunt", hunt), + ["fight"] = Contextualize("fight", fight), + ["social"] = Contextualize("social", social), + ["laugh"] = Contextualize("laugh", laugh), + ["work"] = Contextualize("work", work) }; voices[id] = new ActivitySpeciesVoice(id, bags); } @@ -97,7 +97,7 @@ public static partial class ActivitySpeciesVoiceCatalog { if (voices.TryGetValue(id, out ActivitySpeciesVoice voice)) { - voice.SetLiquidBag(verb, Contextualize(V(first, second))); + voice.SetLiquidBag(verb, Contextualize(verb, V(first, second))); } } @@ -156,7 +156,7 @@ public static partial class ActivitySpeciesVoiceCatalog private static void Set(ActivitySpeciesVoice voice, string verb, string[] phrases) { - voice.SetBag(verb, Contextualize(phrases)); + voice.SetBag(verb, Contextualize(verb, phrases)); } private static void AddSpecialized( @@ -189,19 +189,28 @@ public static partial class ActivitySpeciesVoiceCatalog return new[] { first, second }; } - private static string[] Contextualize(string[] phrases) + /// + /// Place only on settle / trade / farm. Never append job tokens. + /// + private static string[] Contextualize(string verb, string[] phrases) { if (phrases == null) { return null; } + string suffix = WantsPlace(verb) ? "{place_at}" : ""; var result = new string[phrases.Length]; for (int i = 0; i < phrases.Length; i++) { - result[i] = (phrases[i] ?? "") + "{place_at}{job_as}"; + result[i] = (phrases[i] ?? "") + suffix; } return result; } + + private static bool WantsPlace(string verb) + { + return verb == "settle" || verb == "trade" || verb == "farm"; + } } diff --git a/IdleSpectator/ActivitySpeciesVoices.Invertebrates.cs b/IdleSpectator/ActivitySpeciesVoices.Invertebrates.cs index aa8f830..5390f74 100644 --- a/IdleSpectator/ActivitySpeciesVoices.Invertebrates.cs +++ b/IdleSpectator/ActivitySpeciesVoices.Invertebrates.cs @@ -138,7 +138,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("hitches its rootlets forward beneath a bending stalk", "leans and draws its stalk after its roots"), V("holds its closed petals above a steady stalk", "anchors its rootlets and lets its leaves settle"), V("twirls its stalk until the outer petals fan wide", "bobs its closed crown in quick springing arcs"), - V("presses root hairs outward and draws nutrients in", "cups a morsel beneath its lowest leaves"), + V("presses root hairs outward and draws nutrients upward", "cups a morsel beneath its lowest leaves"), V("folds every petal inward around its center", "bows its crown and slackens its leaves"), V("angles its crown toward {target}", "pulls its rootlets toward {target} in a tightening curve"), V("whips its stalk at {target} with its crown closed", "braces its roots at {target} and sweeps its leaves outward"), diff --git a/IdleSpectator/ActivitySpeciesVoices.Mammals.Extended.cs b/IdleSpectator/ActivitySpeciesVoices.Mammals.Extended.cs index 0a74c22..c0079c1 100644 --- a/IdleSpectator/ActivitySpeciesVoices.Mammals.Extended.cs +++ b/IdleSpectator/ActivitySpeciesVoices.Mammals.Extended.cs @@ -44,7 +44,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("touches {target} with small snout presses", "tucks broad foreclaws while nuzzling {target}"), V("scrapes short strokes with both foreclaws", "beats downward from beneath its plated brow"), V("scuttles away in a rapid armored rush", "scrambles off with armor bands pumping over short legs"), - V("scrapes a circle with broad foreclaws then tucks in", "turns low and curls beneath its shell"), + V("scrapes a circle with broad foreclaws then tucks close", "turns low and curls beneath its shell"), V("faces outward behind overlapping plates and flexed claws", "holds a low crouch with pointed snout forward"), V("tracks side to side with its snout", "holds still beneath a motionless plated brow"), V("sniffs carefully then scrapes in shallow strokes", "opens broad claws lightly and scoops"), @@ -115,7 +115,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("brushes onward through its hanging beard", "sweeps across its broad muzzle in short strokes"), V("grunts deep and dips its horns in measure", "stands broadside on four hooves with horns dipped"), V("flares its nostrils with horned brow low", "holds still with beard hanging"), - V("pulls through its neck and massive shoulder hump", "leans forward as broad hooves dig in"), + V("pulls through its neck and massive shoulder hump", "leans forward as broad hooves dig deep"), V("nudges and licks {target} slowly", "nuzzles {target} with curved horns angled aside"), V("lowers its horned brow and sweeps", "sweeps its heavy head in short beating arcs"), V("gallops away beneath tossing horns", "thunders off with shoulder hump surging"), @@ -169,7 +169,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("licks {target} roughly between gentle paw touches", "licks {target} with whiskers forward and claws tucked"), V("draws its whiskers back and sweeps", "beats with its curling tail"), V("bounds away low and elastic with tail streaming", "springs off as hind claws drive hard"), - V("kneads twice and circles tightly before tucking in", "folds paws beneath its chest and wraps its tail"), + V("kneads twice and circles tightly before curling close", "folds paws beneath its chest and wraps its tail"), V("crouches with back rippling", "advances sideways with ears flat and claws bare"), V("tracks with pupils steady as its head turns", "turns its whiskered head in tiny sweeps"), V("hooks delicately with one paw and scoops", "pulses nose and whiskers while scooping"), @@ -219,7 +219,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("licks and nudges {target} carefully", "nuzzles {target} with ears fixed forward"), V("turns its nose aside with tail lowered", "beats with quick paw strokes"), V("bounds away in long strides with ears swept back", "sprints off as hind paws drive beneath its body"), - V("circles twice and tests with its nose before tucking in", "scrapes briefly, then folds nose beneath tail"), + V("circles twice and tests with its nose before curling close", "scrapes briefly, then folds nose beneath tail"), V("stands alert with hackles raised and paws spread", "advances behind bared teeth and braced forelegs"), V("traces with its nose held close", "tilts its head through quick sweeps"), V("scoops with nose close and forepaws moving", "grips carefully in its jaws between short sniffs"), @@ -319,7 +319,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("presses {target} with precise fingertips", "presses {target} carefully with both hands while squatting"), V("turns quick hands with face held aside", "beats with open palms"), V("bounds away on four limbs with tail streaming", "scrambles off as hands and feet alternate rapidly"), - V("pats and turns with both hands before tucking in", "squats low and wraps its long tail close"), + V("pats and turns with both hands before curling close", "squats low and wraps its long tail close"), V("crouches with hands spread and teeth bared", "leaps forward behind a slap and grappling reach"), V("follows along with one pointing finger", "shifts its eyes quickly between head tilts"), V("sorts with both nimble hands and pinches", "pinches precisely with thumb and finger"), @@ -344,7 +344,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("licks and nibbles {target} gently", "nuzzles {target} with upright ears held still"), V("scrapes short strokes with both forepaws", "hops nose-first in quick beating bursts"), V("bounds away accelerating on long hind feet", "darts off through sharp leaps with ears flattened"), - V("scrapes with both forepaws and turns before tucking in", "folds hind legs beneath a compact loaf"), + V("scrapes with both forepaws and turns before curling close", "folds hind legs beneath a compact loaf"), V("stands tall on hind feet ready to strike", "boxes forward with rapid forepaw strikes"), V("tracks with its nose held close", "holds one upright ear swiveling as its nose tracks"), V("clips with incisors and presses with forepaws", "clips from a low hind-leg crouch"), @@ -369,7 +369,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("touches {target} carefully with both paws", "presses {target} fingertip-first beneath its mask"), V("holds black paws back and sweeps", "beats with its raised ringed tail"), V("gallops away arched-backed with tail streaming", "scampers off on nimble flat paws"), - V("pats around with both sensitive paws before curling in", "turns and curls masked muzzle against ringed tail"), + V("pats around with both sensitive paws before curling close", "turns and curls masked muzzle against ringed tail"), V("stands upright with black claws spread", "lunges from its haunches behind both grasping paws"), V("traces with one black finger held close", "holds still beneath motionless dark facial markings"), V("feels with both paws and pinches precisely", "spreads sensitive fingers while pinching"), @@ -494,7 +494,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("licks {target} and nibbles with careful teeth", "nuzzles {target} with ears held forward"), V("turns its muzzle aside and sweeps", "beats with its bushy tail"), V("lopes away fully extended", "runs off with level back and ears swept flat"), - V("circles and scrapes with strong forepaws before tucking in", "folds chest-down with chin between extended paws"), + V("circles and scrapes with strong forepaws before curling close", "folds chest-down with chin between extended paws"), V("stands with ears forward and jaws open", "rushes ahead from a level-backed crouch"), V("tracks with muzzle held close", "holds pointed ears fixed above a lowered muzzle"), V("scoops with nose close and forepaws precise", "scoops from a long-legged crouch"), @@ -522,7 +522,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("coils segment over segment", "sweeps its tapered front once before relaxing"), V("coils with front segments lifted", "lashes forward from a bristle-anchored curve"), V("passes its tapered front along in tiny sweeps", "ripples sideways in tiny controlled waves"), - V("opens its mouth and pulls in controlled draws", "contracts its front segments delicately while drawing in"), + V("opens its mouth and pulls in controlled draws", "contracts its front segments delicately while drawing breath"), V("contracts its rear segments and holds still", "holds the tapered front extended while its rear contracts"), V("crawls into a loop and reverses halfway", "points its tapered front one way as the body pulls another"), V("looses into a coil with bristles relaxed", "lies extended while faint pulses travel end to end"), diff --git a/IdleSpectator/ActivitySpeciesVoices.Mammals.cs b/IdleSpectator/ActivitySpeciesVoices.Mammals.cs index 733de8a..1c8c8b7 100644 --- a/IdleSpectator/ActivitySpeciesVoices.Mammals.cs +++ b/IdleSpectator/ActivitySpeciesVoices.Mammals.cs @@ -41,7 +41,7 @@ public static partial class ActivitySpeciesVoiceCatalog V("leans back and watches through narrowed eyes", "checks each side with short head turns"), V("rolls one wrist and flexes quick fingers", "practices a feint and a smooth retreat"), V("takes bites without lowering the guard", "eats quickly with one hand shielding the meal"), - V("sleeps curled tightly with both hands tucked in", "rests lightly with chin lowered to the chest"), + V("sleeps curled tightly with both hands tucked close", "rests lightly with chin lowered to the chest"), V("tracks movement near {target} from a crouch", "advances toward {target} with quick hands raised"), V("slashes toward {target} from a low stance and withdraws", "turns aside near {target} and counters with the off hand"), V("trades a brief hand sign at close range", "leans in to exchange a few short words"), diff --git a/IdleSpectator/ActivityVoiceHarness.cs b/IdleSpectator/ActivityVoiceHarness.cs index 944c7c0..860fae6 100644 --- a/IdleSpectator/ActivityVoiceHarness.cs +++ b/IdleSpectator/ActivityVoiceHarness.cs @@ -109,6 +109,8 @@ public static class ActivityVoiceHarness int terrainLeaks = 0; int variantBaseFail = 0; int authoredPhraseFail = 0; + int targetBindFail = 0; + int unloadPlaceFail = 0; int unmappedTasks = 0; int missingTaskBags = 0; int routedTasks = 0; @@ -294,6 +296,8 @@ public static class ActivityVoiceHarness } int pairFail = PairwiseFailures(ref sample); + targetBindFail = ValidateTargetBinding(ref sample); + unloadPlaceFail = ValidateUnloadPlace(ref sample); bool liveContextOk = VerifyLiveContext(ref sample); try { @@ -324,6 +328,8 @@ public static class ActivityVoiceHarness && unmappedTasks == 0 && missingTaskBags == 0 && pairFail == 0 + && targetBindFail == 0 + && unloadPlaceFail == 0 && liveContextOk; return new ActivityVoiceAuditResult { @@ -348,6 +354,8 @@ public static class ActivityVoiceHarness + " unmappedTasks=" + unmappedTasks + " missingTaskBags=" + missingTaskBags + " pairFail=" + pairFail + + " targetBindFail=" + targetBindFail + + " unloadPlaceFail=" + unloadPlaceFail + " liveContextOk=" + liveContextOk + " sample='" + sample + "'" }; @@ -371,14 +379,24 @@ public static class ActivityVoiceHarness continue; } + bool wantsPlace = verb == "farm" || verb == "trade" || verb == "settle"; for (int p = 0; p < bag.Length; p++) { string phrase = bag[p] ?? ""; bool targetMissing = (verb == "hunt" || verb == "fight") && phrase.IndexOf("{target}", StringComparison.Ordinal) < 0; - bool contextMissing = !phrase.EndsWith( - "{place_at}{job_as}", - StringComparison.Ordinal); + bool hasJob = phrase.IndexOf("{job_as}", StringComparison.Ordinal) >= 0 + || phrase.IndexOf("{job}", StringComparison.Ordinal) >= 0; + bool hasPlaceAt = phrase.IndexOf("{place_at}", StringComparison.Ordinal) >= 0; + bool placePolicyOk = wantsPlace + ? phrase.EndsWith("{place_at}", StringComparison.Ordinal) + : !hasPlaceAt; + bool jobPolicyOk = !hasJob; + + string standalone = CollapseStandalone(phrase); + string dangling = FindDanglingStandalone(standalone); + bool incomplete = standalone.Length < 12 || standalone.IndexOf(' ') < 0; + bool actorLeak = phrase.IndexOf("{actor}", StringComparison.Ordinal) >= 0; bool speciesLeak = !string.IsNullOrEmpty(label) && Regex.IsMatch( @@ -387,7 +405,9 @@ public static class ActivityVoiceHarness RegexOptions.IgnoreCase); string awkward = FindAwkwardPhrase(phrase); string terrain = FindPhrase(phrase, UngroundedTerrainFragments); - if (targetMissing || contextMissing || actorLeak || speciesLeak + if (targetMissing || !placePolicyOk || !jobPolicyOk || incomplete + || !string.IsNullOrEmpty(dangling) + || actorLeak || speciesLeak || !string.IsNullOrEmpty(awkward) || !string.IsNullOrEmpty(terrain)) { failures++; @@ -395,11 +415,15 @@ public static class ActivityVoiceHarness ref sample, baseSpeciesId + ":" + verb + ":" + p + ":target=" + !targetMissing - + ":context=" + !contextMissing + + ":place=" + placePolicyOk + + ":job=" + jobPolicyOk + + ":standalone=" + !incomplete + + ":dangling=" + dangling + ":actor=" + actorLeak + ":species=" + speciesLeak + ":awkward=" + awkward - + ":terrain=" + terrain); + + ":terrain=" + terrain + + ":text='" + standalone + "'"); } } } @@ -407,6 +431,163 @@ public static class ActivityVoiceHarness return failures; } + private static string CollapseStandalone(string phrase) + { + string t = phrase ?? ""; + t = t.Replace("{place_at}", ""); + t = t.Replace("{job_as}", ""); + t = t.Replace("{job}", ""); + t = t.Replace("{with}", ""); + t = t.Replace("{place}", ""); + t = t.Replace("{carrying}", "goods"); + t = t.Replace("{target}", "Tomi"); + t = t.Replace("{actor}", "Actor"); + t = t.Replace("{species}", "creature"); + while (t.IndexOf(" ", StringComparison.Ordinal) >= 0) + { + t = t.Replace(" ", " "); + } + + return t.Trim(); + } + + private static string FindDanglingStandalone(string standalone) + { + if (string.IsNullOrEmpty(standalone)) + { + return "empty"; + } + + if (Regex.IsMatch( + standalone, + @"\b(in|as|at|with|to|from|near|into|onto|for|of|by)\s*$", + RegexOptions.IgnoreCase)) + { + return "dangling-prep"; + } + + if (Regex.IsMatch(standalone, @"\b(the|a|an|and|or)\s*$", RegexOptions.IgnoreCase)) + { + return "dangling-article"; + } + + if (standalone.IndexOf(" ", StringComparison.Ordinal) >= 0 + || standalone.IndexOf(",,", StringComparison.Ordinal) >= 0) + { + return "dup-sep"; + } + + return ""; + } + + private static int ValidateTargetBinding(ref string sample) + { + int failures = 0; + string[,] cases = + { + { "human", "BehSocializeTalk" }, + { "dog", "BehSocializeTalk" }, + { "human", "lover" } + }; + + for (int i = 0; i < cases.GetLength(0); i++) + { + string species = cases[i, 0]; + string key = cases[i, 1]; + ActivityContext ctx = ActivityContext.ForHarness( + "Woliek", + "Tomi", + species, + place: "Celuford"); + ActivityProse.Format( + ActivityKind.ActionBeat, + key, + ctx, + "Talks", + 77, + 1, + out string line, + out _); + if (string.IsNullOrEmpty(line)) + { + failures++; + AddSample(ref sample, "target-bind:" + species + ":" + key + ":empty"); + continue; + } + + bool hasName = line.IndexOf("Tomi", StringComparison.OrdinalIgnoreCase) >= 0; + bool anonLeft = line.IndexOf("with another", StringComparison.OrdinalIgnoreCase) >= 0 + || line.IndexOf("nearby company", StringComparison.OrdinalIgnoreCase) >= 0 + || line.IndexOf("another presence", StringComparison.OrdinalIgnoreCase) >= 0; + int withName = 0; + int idx = 0; + while (idx >= 0) + { + idx = line.IndexOf("with Tomi", idx, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + break; + } + + withName++; + idx += "with Tomi".Length; + } + + // Plural group wording must stay plural (no personal-name rewrite). + bool groupOk = true; + if (line.IndexOf("among the others", StringComparison.OrdinalIgnoreCase) >= 0 + && line.IndexOf("among Tomi", StringComparison.OrdinalIgnoreCase) >= 0) + { + groupOk = false; + } + + if (!hasName || anonLeft || withName > 1 || !groupOk) + { + failures++; + AddSample( + ref sample, + "target-bind:" + species + ":" + key + + ":name=" + hasName + + ":anon=" + anonLeft + + ":withDup=" + withName + + ":group=" + groupOk + + ":line='" + line + "'"); + } + } + + return failures; + } + + private static int ValidateUnloadPlace(ref string sample) + { + ActivityContext ctx = ActivityContext.ForHarness( + "Porter", + "", + "human", + place: "Riverhold", + carrying: "wheat"); + ActivityProse.Format( + ActivityKind.ActionBeat, + "BehUnloadResources", + ctx, + "Unloads wheat", + 88, + 1, + out string line, + out _); + bool ok = !string.IsNullOrEmpty(line) + && line.IndexOf("wheat", StringComparison.OrdinalIgnoreCase) >= 0 + && line.IndexOf("Riverhold", StringComparison.OrdinalIgnoreCase) >= 0 + && line.IndexOf("while carrying", StringComparison.OrdinalIgnoreCase) < 0; + if (!ok) + { + AddSample(ref sample, "unload-place:line='" + line + "'"); + return 1; + } + + return 0; + } + private static string FindAwkwardPhrase(string fingerprint) { return FindPhrase(fingerprint, AwkwardProseFragments); diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 5edea07..2c5fd2e 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -467,7 +467,24 @@ public static class AgentHarness } WatchCaption.ForceRefreshHistory(); - bool ok = okN == n && id != 0; + if (okN > 0) + { + _cmdOk++; + } + else + { + _cmdFail++; + } + + Emit(cmd, okN > 0, detail: $"forced={okN}/{n} key={key} activity={ActivityLog.CountFor(id)}"); + break; + } + + case "dossier_force_job": + { + string job = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "farmer"; + string reason = cmd.label ?? ""; + bool ok = WatchCaption.ForceJobLabelOnFocus(job, reason); if (ok) { _cmdOk++; @@ -477,8 +494,21 @@ public static class AgentHarness _cmdFail++; } - Emit(cmd, ok, detail: - $"forced={okN}/{n} id={id} count={ActivityLog.CountFor(id)} shown={WatchCaption.LastActivityShown}"); + string shown = WatchCaption.Current?.ReasonLine ?? ""; + Emit(cmd, ok, detail: $"job='{job}' reasonOverride='{reason}' row='{shown}'"); + break; + } + + case "dossier_clear_job": + { + WatchCaption.ClearHarnessJobOverrides(); + if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null) + { + WatchCaption.SetFromActor(MoveCamera._focus_unit); + } + + _cmdOk++; + Emit(cmd, true, detail: "job overrides cleared"); break; } @@ -1988,6 +2018,41 @@ public static class AgentHarness detail = $"hit={hit} needle='{needle}' sample='{sample}' count={entries.Count}"; break; } + case "activity_log_not_contains": + { + long id = WatchCaption.CurrentUnitId; + string needle = string.IsNullOrEmpty(cmd.value) ? cmd.label : cmd.value; + IReadOnlyList entries = + ActivityLog.LatestForSubject(id, ActivityLog.MaxLinesPerSubject); + bool hit = false; + string sample = ""; + for (int i = 0; i < entries.Count; i++) + { + ActivityEntry e = entries[i]; + if (e == null) + { + continue; + } + + string line = e.DisplayLine ?? e.Line ?? ""; + if (string.IsNullOrEmpty(sample)) + { + sample = line; + } + + if (!string.IsNullOrEmpty(needle) + && line.IndexOf(needle, System.StringComparison.OrdinalIgnoreCase) >= 0) + { + hit = true; + sample = line; + break; + } + } + + pass = !hit; + detail = $"absent={!hit} needle='{needle}' sample='{sample}' count={entries.Count}"; + break; + } case "activity_prose_sentence": { // Ensure mood/task labels become sentences, not "{name} laughing". @@ -2071,7 +2136,9 @@ public static class AgentHarness && waitLine.Length >= 18 && !waitLine.Equals(actorName + " waits", System.StringComparison.Ordinal); bool walkOk = !string.IsNullOrEmpty(walkLine) - && walkLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0 + && walkLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) < 0 + && walkLine.IndexOf("farmer", System.StringComparison.OrdinalIgnoreCase) < 0 + && walkLine.IndexOf(", a ", System.StringComparison.OrdinalIgnoreCase) < 0 && walkLine.Length >= 28; pass = moveOk && waitOk && walkOk; detail = @@ -2186,13 +2253,48 @@ public static class AgentHarness && 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; + && unloadLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0 + && unloadLine.IndexOf("while carrying", 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; + // Play is not place-centric - city must not appear on play lines. bool placeKeepOk = !string.IsNullOrEmpty(buffaloTownLine) - && buffaloTownLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0; + && buffaloTownLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) < 0 + && humanLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) < 0; + + ActivityProse.Format( + ActivityKind.ActionBeat, + "BehSocializeTalk", + ActivityContext.ForHarness(actorName, "Tomi", "human", place: "Celuford"), + "Talks", + 11, + 1, + out string socialLine, + out _); + bool socialBindOk = !string.IsNullOrEmpty(socialLine) + && socialLine.IndexOf("Tomi", System.StringComparison.OrdinalIgnoreCase) >= 0 + && socialLine.IndexOf("with another", System.StringComparison.OrdinalIgnoreCase) < 0 + && socialLine.IndexOf("nearby company", System.StringComparison.OrdinalIgnoreCase) < 0; + int withTomi = 0; + int withIdx = 0; + while (withIdx >= 0) + { + withIdx = socialLine.IndexOf( + "with Tomi", + withIdx, + System.StringComparison.OrdinalIgnoreCase); + if (withIdx < 0) + { + break; + } + + withTomi++; + withIdx += "with Tomi".Length; + } + + socialBindOk = socialBindOk && withTomi <= 1; // Taxonomy kingdoms like "insect" must never become place clauses. string insectPlace = ActivityInterestTable.PickPlaceLabel( @@ -2275,16 +2377,17 @@ public static class AgentHarness && rhinoLine.IndexOf(" the rhino", System.StringComparison.OrdinalIgnoreCase) < 0; pass = dogOk && catOk && humanOk && unloadOk && distinct && placeRejectOk && placeKeepOk - && insectRejectOk && frogOk && frogJumpOk && liveTaxOk && rhinoOk; + && insectRejectOk && frogOk && frogJumpOk && liveTaxOk && rhinoOk && socialBindOk; 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} " + + $"socialBindOk={socialBindOk} " + $"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}'"; + + $"frog='{frogLine}' frogJump='{frogJumpLine}' rhino='{rhinoLine}' social='{socialLine}'"; break; } case "activity_taxonomy_audit": @@ -2519,25 +2622,32 @@ public static class AgentHarness bool farmerOk = !string.IsNullOrEmpty(farmerLine) && farmerLine.IndexOf("young", System.StringComparison.OrdinalIgnoreCase) >= 0 - && farmerLine.IndexOf("farmer", System.StringComparison.OrdinalIgnoreCase) >= 0 + && farmerLine.IndexOf("farmer", System.StringComparison.OrdinalIgnoreCase) < 0 + && farmerLine.IndexOf(", a ", System.StringComparison.OrdinalIgnoreCase) < 0 && farmerLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0 && farmerLine.IndexOf("wheat", System.StringComparison.OrdinalIgnoreCase) >= 0 + && farmerLine.IndexOf("while carrying", 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("miner", System.StringComparison.OrdinalIgnoreCase) < 0 + && minerLine.IndexOf(", a ", System.StringComparison.OrdinalIgnoreCase) < 0 && minerLine.IndexOf("Ironford", System.StringComparison.OrdinalIgnoreCase) >= 0 && minerLine.IndexOf("stone", System.StringComparison.OrdinalIgnoreCase) >= 0 + && minerLine.IndexOf("while carrying", System.StringComparison.OrdinalIgnoreCase) < 0 && minerLine.IndexOf("brave", System.StringComparison.OrdinalIgnoreCase) < 0; bool divergeOk = !farmerLine.Equals(minerLine, System.StringComparison.Ordinal); + // Quiet kings: role stays off Activity; combat / target pressure still surfaces. bool kingOk = !string.IsNullOrEmpty(kingCombatLine) - && kingCombatLine.IndexOf("king", System.StringComparison.OrdinalIgnoreCase) >= 0 + && kingCombatLine.IndexOf("king", System.StringComparison.OrdinalIgnoreCase) < 0 + && kingCombatLine.IndexOf("Riverhold", System.StringComparison.OrdinalIgnoreCase) < 0 && (kingCombatLine.IndexOf("combat", System.StringComparison.OrdinalIgnoreCase) >= 0 || kingCombatLine.IndexOf("keeping", System.StringComparison.OrdinalIgnoreCase) >= 0 || kingCombatLine.IndexOf("Barkley", System.StringComparison.OrdinalIgnoreCase) >= 0); bool huntPressureOk = !string.IsNullOrEmpty(huntCombatLine) && huntCombatLine.IndexOf("Boortkin", System.StringComparison.OrdinalIgnoreCase) >= 0 - && huntCombatLine.IndexOf("Thasthuk", System.StringComparison.OrdinalIgnoreCase) >= 0 + && huntCombatLine.IndexOf("Thasthuk", System.StringComparison.OrdinalIgnoreCase) < 0 + && huntCombatLine.IndexOf("king", System.StringComparison.OrdinalIgnoreCase) < 0 && huntCombatLine.IndexOf( "under combat pressure", System.StringComparison.OrdinalIgnoreCase) < 0; diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index e108706..269cb53 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -497,8 +497,21 @@ internal static class HarnessScenarios 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("act16g2", "assert", expect: "activity_log_contains", value: "Riverhold"), + Step("act16g3", "assert", expect: "activity_log_not_contains", value: "while carrying"), Step("act16h", "activity_force", asset: "BehSocializeTalk", label: "Talks", count: 1, tier: "beat", expect: "Barkley"), Step("act16i", "assert", expect: "activity_log_contains", value: "Barkley"), + Step("act16i2", "assert", expect: "activity_log_not_contains", value: "with another"), + // JobLabel on dossier reason row (deterministic harness force). + Step("act16j", "dossier_force_job", value: "farmer"), + Step("act16k", "assert", expect: "dossier_contains", value: "farmer"), + Step("act16l", "screenshot", value: "hud-dossier-job-only.png"), + Step("act16m", "wait", wait: 0.45f), + Step("act16n", "dossier_force_job", value: "farmer", label: "Story"), + Step("act16o", "assert", expect: "dossier_contains", value: "Story · farmer"), + Step("act16p", "screenshot", value: "hud-dossier-reason-job.png"), + Step("act16q", "wait", wait: 0.45f), + Step("act16r", "dossier_clear_job"), 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), diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index ea3f26d..9cbb508 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -16,6 +16,7 @@ public sealed class UnitDossier public string ReasonLine = ""; public string DetailLine = ""; public string CaptionText = ""; + public string JobLabel = ""; public float Score; public int Kills; public int Age; @@ -42,6 +43,18 @@ public sealed class UnitDossier public ActorTrait Trait; } + /// Harness: force JobLabel on the next dossier build (no live citizen_job). + public static string HarnessJobLabelOverride = ""; + + /// Harness: optional watch-reason fragment used with . + public static string HarnessReasonOverride = ""; + + public static void ClearHarnessOverrides() + { + HarnessJobLabelOverride = ""; + HarnessReasonOverride = ""; + } + /// /// Minimal dossier for archive subjects with no living unit (harness orphans / legacy deaths). /// @@ -100,6 +113,7 @@ public sealed class UnitDossier d.TaskText = SafeTask(actor); FillTopTraits(d, actor); CollectReasons(d, actor, speciesCounts); + d.JobLabel = ResolveJobLabel(actor); // Species icon is shown separately; keep id in the title for scanability. d.Headline = string.IsNullOrEmpty(d.SpeciesId) @@ -111,6 +125,17 @@ public sealed class UnitDossier return d; } + private static string ResolveJobLabel(Actor actor) + { + if (!string.IsNullOrEmpty(HarnessJobLabelOverride)) + { + return HarnessJobLabelOverride.Trim(); + } + + string jobId = ActivityInterestTable.SafeJobId(actor); + return ActivityProse.HumanizeJobPublic(jobId); + } + public bool ContainsIgnoreCase(string needle) { if (string.IsNullOrEmpty(needle)) @@ -191,7 +216,11 @@ public sealed class UnitDossier { string tierPart = watchTier.HasValue ? watchTier.Value.ToString() : ""; string why = ""; - if (!string.IsNullOrEmpty(watchLabel)) + if (!string.IsNullOrEmpty(HarnessReasonOverride)) + { + why = HarnessReasonOverride.Trim(); + } + else if (!string.IsNullOrEmpty(watchLabel)) { why = ShortenWatchLabel(watchLabel, d); } @@ -211,17 +240,45 @@ public sealed class UnitDossier why = ""; } + string watchReason; if (string.IsNullOrEmpty(tierPart)) { - return why ?? ""; + watchReason = why ?? ""; } - - if (string.IsNullOrEmpty(why) || why.IndexOf(tierPart, System.StringComparison.OrdinalIgnoreCase) >= 0) + else if (string.IsNullOrEmpty(why) || why.IndexOf(tierPart, System.StringComparison.OrdinalIgnoreCase) >= 0) { - return IsShownTraitName(d, tierPart) ? "" : tierPart; + watchReason = IsShownTraitName(d, tierPart) ? "" : tierPart; + } + else + { + watchReason = tierPart + " · " + why; } - return tierPart + " · " + why; + return ComposeReasonWithJob(watchReason, d.JobLabel); + } + + /// Watch reason + job on the dossier row; job alone when reason is empty. + private static string ComposeReasonWithJob(string watchReason, string jobLabel) + { + string reason = (watchReason ?? "").Trim(); + string job = (jobLabel ?? "").Trim(); + if (string.IsNullOrEmpty(job)) + { + return reason; + } + + if (string.IsNullOrEmpty(reason)) + { + return job; + } + + if (reason.Equals(job, System.StringComparison.OrdinalIgnoreCase) + || reason.IndexOf(job, System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return reason; + } + + return reason + " · " + job; } private static string FirstFlavorTag(UnitDossier d) diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index d8b1b3e..5d25898 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -666,6 +666,28 @@ public static class WatchCaption RefreshHistoryIfChanged(); } + /// + /// Harness: force JobLabel (and optional watch reason) onto the focused dossier reason row. + /// + public static bool ForceJobLabelOnFocus(string jobLabel, string reasonOverride = "") + { + if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null) + { + return false; + } + + UnitDossier.HarnessJobLabelOverride = jobLabel ?? ""; + UnitDossier.HarnessReasonOverride = reasonOverride ?? ""; + SetFromActor(MoveCamera._focus_unit); + return _current != null + && (!string.IsNullOrEmpty(_current.JobLabel) || !string.IsNullOrEmpty(_current.ReasonLine)); + } + + public static void ClearHarnessJobOverrides() + { + UnitDossier.ClearHarnessOverrides(); + } + private static void RefreshHistoryIfChanged() { if (!_visible || _current == null) diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index c29fe65..8b1f024 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.12.87", + "version": "0.12.90", "description": "AFK Idle Spectator (I) + Lore (L). Detailed living activity prose by default.", "GUID": "com.dazed.idlespectator" }