using System; namespace IdleSpectator; /// /// Assembles one alive activity sentence from observed clauses. /// 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. /// 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); // Strip any leftover job tokens - job belongs on the dossier, not Activity. action = StripToken(action, "{job_as}"); action = StripToken(action, "{job}"); string subject = BuildSubject(ctx); string verb = ActivityVerbMap.Resolve(key); if (IsCompanionVerb(verb)) { action = BindSingletonCompanions(ctx, action); } // Traits stay on the dossier chips - they only salt variety below, never named here. string target = BuildObservedTargetClause(ctx, action, verb); action = EnsurePlaceClause(action, ctx, verb, key); string carrying = BuildCarryingClause(ctx, action); string pressure = BuildPressureClause(ctx, action + target); string line = subject + " " + action + target + carrying + pressure; return CollapseSpaces(line).Trim(); } /// /// Attach {place_at} when the action still lacks a place token. /// Objects (bonfire, house, …) appear on site-work verbs; settlements stay settle/trade/farm/unload. /// private static string EnsurePlaceClause(string action, ActivityContext ctx, string verb, string key) { if (ctx == null || string.IsNullOrEmpty(ctx.PlaceLabel) || string.IsNullOrEmpty(action) || action.IndexOf("{place_at}", StringComparison.Ordinal) >= 0 || action.IndexOf("{place}", StringComparison.Ordinal) >= 0) { return action; } if (ctx.PlaceIsObject) { if (WantsObjectPlace(verb) || IsUnloadKey(key)) { return action + "{place_at}"; } return action; } if (WantsSettlementPlace(verb) || IsUnloadKey(key)) { return action + "{place_at}"; } return action; } private static bool IsUnloadKey(string key) { return !string.IsNullOrEmpty(key) && (key.Equals("BehUnloadResources", StringComparison.OrdinalIgnoreCase) || key.Equals("BehThrowResources", StringComparison.OrdinalIgnoreCase)); } private static bool WantsSettlementPlace(string verb) { return verb == "settle" || verb == "trade" || verb == "farm"; } private static bool WantsObjectPlace(string verb) { return verb == "work" || verb == "haul" || verb == "farm" || verb == "trade" || verb == "settle" || verb == "fire" || verb == "pollinate" || verb == "fish" || verb == "heal" || verb == "read"; } private static bool IsCompanionVerb(string verb) { return verb == "social" || verb == "lover" || verb == "cry" || verb == "swear" || verb == "group"; } private static string BuildObservedTargetClause(ActivityContext ctx, string action, string verb) { if (ctx == null || !IsCompanionVerb(verb) || !ctx.TargetIsActor || string.IsNullOrEmpty(ctx.TargetName) || ctx.InCombat || ctx.HasAttackTarget || action.IndexOf("{target}", StringComparison.Ordinal) >= 0 || action.IndexOf("{with}", StringComparison.Ordinal) >= 0) { return ""; } 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 || string.IsNullOrEmpty(ctx.CarryingLabel) || action.IndexOf("{carrying}", StringComparison.Ordinal) >= 0 || action.IndexOf(ctx.CarryingLabel, StringComparison.OrdinalIgnoreCase) >= 0) { return ""; } if (ActionImpliesLoad(action)) { return ""; } return " while carrying {carrying}"; } private static bool ActionImpliesLoad(string action) { if (string.IsNullOrEmpty(action)) { return false; } for (int i = 0; i < CargoImplicationWords.Length; i++) { if (action.IndexOf(CargoImplicationWords[i], StringComparison.OrdinalIgnoreCase) >= 0) { return true; } } return false; } private static string BuildSubject(ActivityContext ctx) { 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) { if (!ctx.InCombat && !ctx.HasAttackTarget) { return ""; } // An action that already names the observed target or reads as combat-aware // does not need a second, generic combat suffix. if (action.IndexOf("{target}", StringComparison.Ordinal) >= 0 || action.IndexOf("fight", StringComparison.OrdinalIgnoreCase) >= 0 || action.IndexOf("clash", StringComparison.OrdinalIgnoreCase) >= 0 || action.IndexOf("attack", StringComparison.OrdinalIgnoreCase) >= 0 || action.IndexOf("blow", StringComparison.OrdinalIgnoreCase) >= 0 || action.IndexOf("hunt", StringComparison.OrdinalIgnoreCase) >= 0 || action.IndexOf("stalk", StringComparison.OrdinalIgnoreCase) >= 0 || action.IndexOf("strike", StringComparison.OrdinalIgnoreCase) >= 0 || action.IndexOf("close on", StringComparison.OrdinalIgnoreCase) >= 0) { return ""; } if (ctx.TargetIsActor && !string.IsNullOrEmpty(ctx.TargetName)) { return " while keeping {target} in sight"; } return " while danger presses close"; } /// 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); } /// 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(); } /// /// 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 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; } }