diff --git a/IdleSpectator/ActivityClauseAssembler.cs b/IdleSpectator/ActivityClauseAssembler.cs index 09ef87c..86ed429 100644 --- a/IdleSpectator/ActivityClauseAssembler.cs +++ b/IdleSpectator/ActivityClauseAssembler.cs @@ -57,12 +57,72 @@ public static class ActivityClauseAssembler // 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" diff --git a/IdleSpectator/ActivityContext.cs b/IdleSpectator/ActivityContext.cs index f52e02c..dddc068 100644 --- a/IdleSpectator/ActivityContext.cs +++ b/IdleSpectator/ActivityContext.cs @@ -48,6 +48,11 @@ public sealed class ActivityContext public bool TargetIsActor; /// Building, city, kingdom, or soft place label for clauses. public string PlaceLabel = ""; + /// + /// True when is a concrete building/object (bonfire, house, …). + /// False for settlements, realms, and soft places like "the wilds". + /// + public bool PlaceIsObject; /// Observed terrain under the actor when this snapshot was captured. public ActivityTerrain Terrain; public string CarryingLabel = ""; @@ -78,7 +83,8 @@ public sealed class ActivityContext bool isKing = false, bool isLeader = false, bool isWarrior = false, - ActivityTerrain terrain = ActivityTerrain.Unknown) + ActivityTerrain terrain = ActivityTerrain.Unknown, + bool placeIsObject = false) { ActivityContext ctx = new ActivityContext { @@ -87,6 +93,7 @@ public sealed class ActivityContext TargetIsActor = !string.IsNullOrEmpty(targetName), SpeciesId = speciesId ?? "", PlaceLabel = place ?? "", + PlaceIsObject = placeIsObject, CarryingLabel = carrying ?? "", JobId = jobId ?? "", TaskKey = taskKey ?? "", @@ -130,6 +137,7 @@ public sealed class ActivityContext && ActivityInterestTable.LooksLikeSpeciesPublic(ctx.PlaceLabel, ctx.SpeciesId)) { ctx.PlaceLabel = ""; + ctx.PlaceIsObject = false; } ctx.RoleLabel = ActivityInterestTable.DeriveRoleLabel(ctx.IsKing, ctx.IsLeader, ctx.IsWarrior); diff --git a/IdleSpectator/ActivityInterestTable.cs b/IdleSpectator/ActivityInterestTable.cs index 52c586a..393ec8f 100644 --- a/IdleSpectator/ActivityInterestTable.cs +++ b/IdleSpectator/ActivityInterestTable.cs @@ -214,36 +214,39 @@ public static class ActivityInterestTable public static string TargetLabel(Actor actor) { - ResolveTarget(actor, out string name, out _, out string place); + ResolveTarget(actor, out string name, out _, out string place, out _); return !string.IsNullOrEmpty(name) ? name : place; } /// Living actor interactee name only - never a building/place label. public static string ActorTargetName(Actor actor) { - ResolveTarget(actor, out string name, out bool isActor, out _); + ResolveTarget(actor, out string name, out bool isActor, out _, out _); return isActor ? name : ""; } /// Building/tile/settlement label only - never an actor name. public static string PlaceOrObjectLabel(Actor actor) { - ResolveTarget(actor, out _, out bool isActor, out string place); + ResolveTarget(actor, out _, out bool isActor, out string place, out _); return isActor ? "" : (place ?? ""); } /// /// Prefer live actor targets (by name). Places/buildings stay as type labels. + /// is true only for concrete building targets. /// public static void ResolveTarget( Actor actor, out string targetName, out bool targetIsActor, - out string placeLabel) + out string placeLabel, + out bool placeIsObject) { targetName = ""; targetIsActor = false; placeLabel = ""; + placeIsObject = false; if (actor == null) { return; @@ -298,10 +301,12 @@ public static class ActivityInterestTable if (!string.IsNullOrEmpty(type)) { placeLabel = type.Replace("type_", ""); + placeIsObject = true; return; } placeLabel = string.IsNullOrEmpty(id) ? "a building" : id; + placeIsObject = true; return; } @@ -466,10 +471,15 @@ public static class ActivityInterestTable ctx.InCombat = ctx.HasAttackTarget; } - ResolveTarget(actor, out string targetName, out bool targetIsActor, out string place); + ResolveTarget(actor, out string targetName, out bool targetIsActor, out string place, out bool placeIsObject); ctx.TargetName = targetName; ctx.TargetIsActor = targetIsActor; - ctx.PlaceLabel = PickPlaceLabel(place, ctx.CityName, ctx.KingdomName, ctx.SpeciesId); + string picked = PickPlaceLabel(place, ctx.CityName, ctx.KingdomName, ctx.SpeciesId); + ctx.PlaceLabel = picked; + ctx.PlaceIsObject = placeIsObject + && !string.IsNullOrEmpty(picked) + && !string.IsNullOrEmpty(place) + && picked.Equals(place.Trim(), StringComparison.OrdinalIgnoreCase); ctx.CarryingLabel = TryGetCarryingLabel(actor); ctx.TopTraitId = TryTopInterestingTraitId(actor); @@ -565,8 +575,8 @@ public static class ActivityInterestTable } /// - /// Real places only. Reject species ids, family/taxonomy buckets, and soft markers. - /// Wild kingdoms are often named after a species or type (buffalo, insect) - those are not places. + /// Prefer a concrete resolved object/building from AI targets over settlement names. + /// City/kingdom fill in only when there is no usable object/place from ResolveTarget. /// public static string PickPlaceLabel( string resolvedPlace, @@ -574,16 +584,16 @@ public static class ActivityInterestTable string kingdomName, string speciesId) { - if (IsUsablePlace(cityName, speciesId)) - { - return cityName.Trim(); - } - if (IsConcreteResolvedPlace(resolvedPlace, speciesId)) { return resolvedPlace.Trim(); } + if (IsUsablePlace(cityName, speciesId)) + { + return cityName.Trim(); + } + // Kingdom only when it reads like a settlement/realm name, not a species bucket. if (IsUsablePlace(kingdomName, speciesId) && LooksLikeRealmName(kingdomName)) { diff --git a/IdleSpectator/ActivityLog.cs b/IdleSpectator/ActivityLog.cs index 368e17a..2e6793f 100644 --- a/IdleSpectator/ActivityLog.cs +++ b/IdleSpectator/ActivityLog.cs @@ -287,6 +287,7 @@ public static class ActivityLog if (string.IsNullOrEmpty(ctx.PlaceLabel) && !string.IsNullOrEmpty(placeHint)) { ctx.PlaceLabel = placeHint; + ctx.PlaceIsObject = true; } if (actionKey == "BehUnloadResources" || actionKey == "BehThrowResources") @@ -304,6 +305,7 @@ public static class ActivityLog if (string.IsNullOrEmpty(ctx.PlaceLabel)) { ctx.PlaceLabel = !string.IsNullOrEmpty(ctx.CityName) ? ctx.CityName : "town"; + ctx.PlaceIsObject = false; } } @@ -555,6 +557,8 @@ public static class ActivityLog if (!string.IsNullOrEmpty(place)) { ctx.PlaceLabel = place; + // Explicit harness place strings are settlement/realm names, not building objects. + ctx.PlaceIsObject = false; } if (!string.IsNullOrEmpty(carrying)) diff --git a/IdleSpectator/ActivityProse.cs b/IdleSpectator/ActivityProse.cs index 790cb7b..38e6eb0 100644 --- a/IdleSpectator/ActivityProse.cs +++ b/IdleSpectator/ActivityProse.cs @@ -35,9 +35,14 @@ public static class ActivityProse actorName, targetIsActor ? targetName : "", place: placeOrFallback); - if (!targetIsActor && !string.IsNullOrEmpty(targetName) && string.IsNullOrEmpty(ctx.PlaceLabel)) + if (!targetIsActor && !string.IsNullOrEmpty(targetName)) { - ctx.PlaceLabel = targetName; + if (string.IsNullOrEmpty(ctx.PlaceLabel)) + { + ctx.PlaceLabel = targetName; + } + + ctx.PlaceIsObject = true; } if (targetIsActor) @@ -144,6 +149,47 @@ public static class ActivityProse return HumanizeJob(jobId); } + /// + /// Settlement / realm / wilds → " in X". Building/object → " at the X" (or " at a/an/the X"). + /// + public static string FormatPlaceAtClause(string place, bool placeIsObject) + { + if (string.IsNullOrEmpty(place)) + { + return ""; + } + + string noun = place.Trim().Replace('_', ' '); + if (string.IsNullOrEmpty(noun)) + { + return ""; + } + + if (placeIsObject) + { + if (HasLeadingArticle(noun)) + { + return " at " + noun; + } + + return " at the " + LowerFirst(noun); + } + + return " in " + noun; + } + + private static bool HasLeadingArticle(string noun) + { + if (string.IsNullOrEmpty(noun)) + { + return false; + } + + return noun.StartsWith("a ", System.StringComparison.OrdinalIgnoreCase) + || noun.StartsWith("an ", System.StringComparison.OrdinalIgnoreCase) + || noun.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase); + } + /// Legacy helper used by older call sites / harness. public static string Format( ActivityKind kind, @@ -193,8 +239,8 @@ public static class ActivityProse 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); + // Empty place → empty clause. Objects use "at the", settlements use "in". + string placeAt = FormatPlaceAtClause(place, ctx.PlaceIsObject); string t = template; t = t.Replace("{actor}", actor); diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 1b5b729..c5be61f 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2297,11 +2297,13 @@ public static class AgentHarness socialBindOk = socialBindOk && withTomi <= 1; // Building/place labels must never become companion "with …" on work/unload. + // Objects use "at the …"; settlements use "in …". ActivityContext bonfireCtx = ActivityContext.ForHarness( actorName, - "bonfire", + "", "human", - place: "bonfire"); + place: "bonfire", + placeIsObject: true); ActivityProse.Format( ActivityKind.TaskStart, "type_bonfire", @@ -2312,11 +2314,13 @@ public static class AgentHarness out string bonfireLine, out _); bool bonfireOk = !string.IsNullOrEmpty(bonfireLine) - && bonfireLine.IndexOf("with bonfire", System.StringComparison.OrdinalIgnoreCase) < 0; + && bonfireLine.IndexOf("at the bonfire", System.StringComparison.OrdinalIgnoreCase) >= 0 + && bonfireLine.IndexOf("with bonfire", System.StringComparison.OrdinalIgnoreCase) < 0 + && bonfireLine.IndexOf("in bonfire", System.StringComparison.OrdinalIgnoreCase) < 0; ActivityContext unloadTownCtx = ActivityContext.ForHarness( actorName, - "town", + "", "human", place: "Riverhold", carrying: "wheat"); @@ -2331,6 +2335,7 @@ public static class AgentHarness out _); bool unloadTownOk = !string.IsNullOrEmpty(unloadTownLine) && unloadTownLine.IndexOf("wheat", System.StringComparison.OrdinalIgnoreCase) >= 0 + && unloadTownLine.IndexOf("in Riverhold", System.StringComparison.OrdinalIgnoreCase) >= 0 && unloadTownLine.IndexOf("with town", System.StringComparison.OrdinalIgnoreCase) < 0; // Taxonomy kingdoms like "insect" must never become place clauses. diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 210f830..fcbd1d8 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.12.91", + "version": "0.12.93", "description": "AFK Idle Spectator (I) + Lore (L). Detailed living activity prose by default.", "GUID": "com.dazed.idlespectator" }