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