Enhance dossier interactions and improve Lore click handling.

- Add related unit tracking for activity logs and dossiers
- Implement colorized names for related units in reason lines
- Update harness scenarios to validate new interactions
- Increment version to 0.26.1 in mod.json
This commit is contained in:
DazedAnon 2026-07-17 13:36:33 -05:00
parent d1be1d9f00
commit 90d5b1604f
16 changed files with 1581 additions and 83 deletions

View file

@ -212,7 +212,8 @@ Use `fast_timing` / `director_run` instead.
- `set_setting` (`expect=enabled|show_watch_reasons|show_dossier_caption|chronicle_enabled`)
- `discovery_reset`, `discovery_note`, `discovery_mark`, `discovery_drain`
- `chronicle_force`, `chronicle_open`, `chronicle_jump`
- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `dossier_traits_ok`, `dossier_statuses_ok`, `citizen_job_labels_ok`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains`, `chronicle_latest_dated`, `focus_arrows`
- `assert` expects: `idle`, `health`, `power_bar`, `no_bad`, `tip_matches_unit`, `unit_asset`, `tip_contains`, `enabled`, `show_watch_reasons`, `current_tier`, `would_accept_curiosity`, `focus_same`, `presented`, `pending_discovery`, `pending_interest`, `in_grace`, `dossier_contains`, `caption_contains`, `dossier_traits_ok`, `dossier_statuses_ok`, `citizen_job_labels_ok`, `tooltip_library_ok`, `history_other_rows`, `lore_detail_is_other`, `reason_names_colored`, `fallen_killer_banner`, `caption_layout_ok`, `dossier_history_contains`, `chronicle_count`, `chronicle_latest_contains`, `chronicle_latest_dated`, `focus_arrows`
- `dossier_click_history_other`, `dossier_force_named_reason` - dossier row → Lore other; gold names in orange reason
- `snapshot`, `reset_counters`, `scenario`
## Process safety

19
.cursorrules Normal file
View file

@ -0,0 +1,19 @@
# Commit message generation (SCM sparkle button)
When Cursor auto-generates a Git commit message for this repository:
1. Write a short imperative subject line (why, ~72 chars).
2. Add a blank line.
3. Summarize the main changes as bullet points (2-6 bullets).
4. Do not put everything in one line.
5. Do not add co-author trailers or agent signatures.
Example shape:
```
Fix Lore row clicks dropping camera follow.
- Pause idle before focusing the browse unit
- Assert camera follow matches Lore detail after row click
- Leave status banners non-clickable
```

View file

@ -232,6 +232,44 @@ public static class ActivityInterestTable
return isActor ? name : "";
}
/// <summary>Living interactee unit id when <see cref="ActorTargetName"/> resolves (0 otherwise).</summary>
public static long ActorTargetId(Actor actor)
{
if (actor == null)
{
return 0;
}
try
{
if (actor.has_attack_target && actor.attack_target != null && actor.attack_target.isAlive()
&& actor.attack_target.isActor())
{
Actor foe = actor.attack_target.a;
if (foe != null && foe.isAlive())
{
return EventFeedUtil.SafeId(foe);
}
}
if (actor.beh_actor_target != null && actor.beh_actor_target.isAlive()
&& actor.beh_actor_target.isActor())
{
Actor other = actor.beh_actor_target.a;
if (other != null && other.isAlive())
{
return EventFeedUtil.SafeId(other);
}
}
}
catch
{
return 0;
}
return 0;
}
/// <summary>Building/tile/settlement label only - never an actor name.</summary>
public static string PlaceOrObjectLabel(Actor actor)
{

View file

@ -16,6 +16,8 @@ public sealed class ActivityEntry
{
public float CreatedAt;
public long SubjectId;
/// <summary>Other living/fallen unit named by this line (0 when none / unresolved).</summary>
public long RelatedId;
public ActivityKind Kind;
public string TaskId = "";
public string JobId = "";
@ -419,7 +421,8 @@ public static class ActivityLog
string actionKey,
string rawFact,
string actorTarget = "",
string placeHint = "")
string placeHint = "",
long relatedId = 0)
{
if (actor == null || !actor.isAlive() || string.IsNullOrEmpty(actionKey))
{
@ -504,6 +507,7 @@ public static class ActivityLog
{
CreatedAt = now,
SubjectId = id,
RelatedId = relatedId,
Kind = ActivityKind.ActionBeat,
TaskId = actionKey,
JobId = jobId,
@ -519,7 +523,12 @@ public static class ActivityLog
/// <summary>
/// Life milestone mirrored into Activity (no beat cooldown). Death may use a dead actor.
/// </summary>
public static void NoteMilestone(Actor actor, string actionKey, string rawLine, string target = "")
public static void NoteMilestone(
Actor actor,
string actionKey,
string rawLine,
string target = "",
long relatedId = 0)
{
if (actor == null || string.IsNullOrEmpty(actionKey) || string.IsNullOrEmpty(rawLine))
{
@ -612,6 +621,7 @@ public static class ActivityLog
{
CreatedAt = now,
SubjectId = id,
RelatedId = relatedId,
Kind = ActivityKind.ActionBeat,
TaskId = actionKey,
JobId = "",
@ -673,7 +683,8 @@ public static class ActivityLog
actorName = "creature";
}
ForceStatusNote(id, statusId.Trim(), gained, actorName);
ResolveStatusRelated(actor, out long relatedId, out string relatedName);
ForceStatusNote(id, statusId.Trim(), gained, actorName, relatedId, relatedName);
try
{
HappinessEventRouter.NoteStatusPhase(id, statusId.Trim());
@ -693,12 +704,50 @@ public static class ActivityLog
}
}
/// <summary>
/// Related unit only when a happiness/relationship hook stamped the context bag
/// (e.g. becomeLoversWith → fell_in_love). Do not invent lover links for every status.
/// </summary>
private static void ResolveStatusRelated(Actor actor, out long relatedId, out string relatedName)
{
relatedId = 0;
relatedName = "";
if (actor == null)
{
return;
}
try
{
if (!HappinessContextBag.TryGetRelated(actor, out Actor bagRelated, out _)
|| bagRelated == null
|| !bagRelated.isAlive())
{
return;
}
relatedId = EventFeedUtil.SafeId(bagRelated);
relatedName = bagRelated.getName() ?? "";
if (string.IsNullOrEmpty(relatedName) && bagRelated.asset != null)
{
relatedName = bagRelated.asset.id ?? "";
}
}
catch
{
relatedId = 0;
relatedName = "";
}
}
/// <summary>Harness / forced path for status lines without a live transition.</summary>
public static bool ForceStatusNote(
long subjectId,
string statusId,
bool gained,
string actorName = "")
string actorName = "",
long relatedId = 0,
string relatedName = "")
{
if (subjectId == 0 || string.IsNullOrEmpty(statusId))
{
@ -709,9 +758,30 @@ public static class ActivityLog
string name = string.IsNullOrEmpty(actorName) ? "Someone" : actorName;
string phrase = ActivityStatusProse.Phrase(id, gained, out _);
string clause = LowerFirst(phrase);
if (gained
&& !string.IsNullOrEmpty(relatedName)
&& !string.IsNullOrEmpty(clause)
&& clause.IndexOf(relatedName, System.StringComparison.OrdinalIgnoreCase) < 0)
{
// Keep short status verbs readable when we know who they are about.
clause = clause + " with " + relatedName;
}
string display = string.IsNullOrEmpty(clause) ? name : name + " " + clause;
string displayRich = ActivityProse.ColorName(name)
+ (string.IsNullOrEmpty(clause) ? "" : " " + clause);
string displayRich;
if (!string.IsNullOrEmpty(relatedName)
&& !string.IsNullOrEmpty(clause)
&& clause.IndexOf(relatedName, System.StringComparison.Ordinal) >= 0)
{
displayRich = ActivityProse.ColorName(name) + " "
+ clause.Replace(relatedName, ActivityProse.ColorName(relatedName));
}
else
{
displayRich = ActivityProse.ColorName(name)
+ (string.IsNullOrEmpty(clause) ? "" : " " + clause);
}
string key = ActivityStatusProse.TaskKey(id, gained);
string fact = (gained ? "Gained " : "Lost ") + id;
@ -732,15 +802,21 @@ public static class ActivityLog
}
}
if (relatedId == 0 && !string.IsNullOrEmpty(relatedName))
{
relatedId = ResolveLivingUnitIdByName(relatedName, subjectId);
}
RecordSample(key, isBeat: true);
Append(subjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = subjectId,
RelatedId = relatedId != subjectId ? relatedId : 0,
Kind = gained ? ActivityKind.StatusGain : ActivityKind.StatusLoss,
TaskId = key,
JobId = "",
TargetLabel = id,
TargetLabel = !string.IsNullOrEmpty(relatedName) ? relatedName : id,
Line = fact,
DisplayLine = display,
DisplayLineRich = displayRich
@ -772,10 +848,17 @@ public static class ActivityLog
}
RecordSample(key, isBeat: true);
long relatedId = occ.RelatedId;
if (relatedId == 0 && !string.IsNullOrEmpty(occ.RelatedName))
{
relatedId = ResolveLivingUnitIdByName(occ.RelatedName, occ.SubjectId);
}
Append(occ.SubjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = occ.SubjectId,
RelatedId = relatedId != occ.SubjectId ? relatedId : 0,
Kind = ActivityKind.Happiness,
TaskId = key,
JobId = occ.Amount.ToString(),
@ -1032,10 +1115,17 @@ public static class ActivityLog
idx,
out string display,
out string displayRich);
long relatedId = 0;
if (ctx != null && ctx.TargetIsActor && !string.IsNullOrEmpty(ctx.TargetName))
{
relatedId = ResolveLivingUnitIdByName(ctx.TargetName, subjectId);
}
Append(subjectId, new ActivityEntry
{
CreatedAt = Time.unscaledTime,
SubjectId = subjectId,
RelatedId = relatedId,
Kind = kind,
TaskId = taskOrAction,
JobId = ctx.JobId ?? "",
@ -1047,6 +1137,45 @@ public static class ActivityLog
return true;
}
/// <summary>Living unit id with exact name match, or 0. Used by dossier row links.</summary>
public static long ResolveLivingUnitIdByName(string name, long excludeId)
{
if (string.IsNullOrEmpty(name) || World.world?.units == null)
{
return 0;
}
try
{
foreach (Actor unit in World.world.units)
{
if (unit == null || !unit.isAlive())
{
continue;
}
long id = EventFeedUtil.SafeId(unit);
if (id == 0 || id == excludeId)
{
continue;
}
string n = unit.getName();
if (!string.IsNullOrEmpty(n)
&& string.Equals(n, name, System.StringComparison.OrdinalIgnoreCase))
{
return id;
}
}
}
catch
{
return 0;
}
return 0;
}
private static void RecordSample(string key, bool isBeat)
{
if (_clearedAt < 0f)

View file

@ -6,7 +6,11 @@ namespace IdleSpectator;
/// </summary>
public static class ActivityProse
{
public const string NameColorHex = "#F0C14A";
/// <summary>
/// Bright lemon for person names - must read clearly on dark dossier chrome and
/// against orange reason text (<c>#F2B861</c>).
/// </summary>
public const string NameColorHex = "#FFE566";
public static string ColorName(string name)
{
@ -15,7 +19,76 @@ public static class ActivityProse
return "";
}
return "<color=" + NameColorHex + ">" + name + "</color>";
return "<b><color=" + NameColorHex + ">" + name + "</color></b>";
}
/// <summary>
/// Wrap known person names in gold for orange reason beats (rest inherits ReasonColor).
/// Longer names first so partial overlaps do not corrupt replacements.
/// </summary>
public static string ColorizePersonNames(string plain, params string[] names)
{
if (string.IsNullOrEmpty(plain) || names == null || names.Length == 0)
{
return plain ?? "";
}
var unique = new System.Collections.Generic.List<string>();
for (int i = 0; i < names.Length; i++)
{
string n = names[i];
if (string.IsNullOrEmpty(n))
{
continue;
}
bool seen = false;
for (int j = 0; j < unique.Count; j++)
{
if (string.Equals(unique[j], n, System.StringComparison.Ordinal))
{
seen = true;
break;
}
}
if (!seen)
{
unique.Add(n);
}
}
unique.Sort((a, b) => b.Length.CompareTo(a.Length));
string result = plain;
for (int i = 0; i < unique.Count; i++)
{
string n = unique[i];
// Skip if already wrapped from a prior pass.
if (result.IndexOf(NameColorHex + ">" + n + "</color>", System.StringComparison.Ordinal) >= 0)
{
continue;
}
result = ReplaceNameLiteral(result, n, ColorName(n));
}
return result;
}
private static string ReplaceNameLiteral(string haystack, string name, string rich)
{
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(name))
{
return haystack;
}
int idx = haystack.IndexOf(name, System.StringComparison.Ordinal);
if (idx < 0)
{
return haystack;
}
return haystack.Substring(0, idx) + rich + haystack.Substring(idx + name.Length);
}
public static void Format(

View file

@ -92,7 +92,8 @@ public static class ActorActivityPatches
pActor,
"BehAttackActorHuntingTarget",
string.IsNullOrEmpty(actorTarget) ? "Hunts prey" : "Hunts " + actorTarget,
actorTarget: actorTarget);
actorTarget: actorTarget,
relatedId: ActivityInterestTable.ActorTargetId(pActor));
}
[HarmonyPatch(typeof(BehCityActorRemoveFire), nameof(BehCityActorRemoveFire.execute))]
@ -126,7 +127,8 @@ public static class ActorActivityPatches
pActor,
"BehTryToSocialize",
string.IsNullOrEmpty(actorTarget) ? "Tries to socialize" : "Tries to socialize with " + actorTarget,
actorTarget: actorTarget);
actorTarget: actorTarget,
relatedId: ActivityInterestTable.ActorTargetId(pActor));
}
[HarmonyPatch(typeof(BehPlantCrops), nameof(BehPlantCrops.execute))]

View file

@ -412,6 +412,7 @@ public static class AgentHarness
bool ok = Chronicle.ForceLifeMilestoneOnFocus(kind, other);
if (ok)
{
WatchCaption.ForceRefreshHistory();
_cmdOk++;
}
else
@ -421,7 +422,7 @@ public static class AgentHarness
long id = Chronicle.CurrentHistorySubjectId();
Emit(cmd, ok, detail:
$"milestone={kind} history={Chronicle.HistoryCountFor(id)} activity={ActivityLog.CountFor(id)}");
$"milestone={kind} history={Chronicle.HistoryCountFor(id)} activity={ActivityLog.CountFor(id)} otherRows={WatchCaption.CountHistoryOtherRows()}");
break;
}
@ -569,6 +570,124 @@ public static class AgentHarness
break;
}
case "dossier_force_named_reason":
{
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
string name = "";
try
{
name = focus != null ? (focus.getName() ?? "") : "";
}
catch
{
name = "";
}
string clause = !string.IsNullOrEmpty(cmd.value) ? cmd.value : "is fighting";
if (string.IsNullOrEmpty(name))
{
_cmdFail++;
Emit(cmd, false, detail: "no_focus_name");
break;
}
// Optional related: label/expect name, else remembered happiness partner.
string relatedName = !string.IsNullOrEmpty(cmd.expect)
? cmd.expect.Trim()
: (!string.IsNullOrEmpty(cmd.label) ? cmd.label.Trim() : "");
long relatedId = 0;
Actor relatedActor = null;
if (string.Equals(relatedName, "auto", System.StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrEmpty(relatedName))
{
relatedActor = _happinessPartner != null && _happinessPartner.isAlive()
? _happinessPartner
: null;
if (relatedActor != null)
{
relatedId = EventFeedUtil.SafeId(relatedActor);
relatedName = SafeName(relatedActor);
}
else
{
relatedName = "";
}
}
else
{
relatedId = ActivityLog.ResolveLivingUnitIdByName(
relatedName,
EventFeedUtil.SafeId(focus));
}
string reason = name + " " + clause.Trim();
if (!string.IsNullOrEmpty(relatedName)
&& reason.IndexOf(relatedName, System.StringComparison.OrdinalIgnoreCase) < 0)
{
reason = reason + " " + relatedName;
}
bool ok = WatchCaption.ForceJobLabelOnFocus(
!string.IsNullOrEmpty(cmd.asset) ? cmd.asset : "farmer",
reason,
relatedId,
relatedName);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail:
$"reason='{WatchCaption.Current?.ReasonLine}' relatedId={relatedId} clickable={WatchCaption.ReasonOtherClickable}");
break;
}
case "dossier_click_reason_other":
{
bool ok = WatchCaption.ClickReasonOther(out long otherId);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail: $"clicked={ok} otherId={otherId} lore={ChronicleHud.DetailUnitId}");
break;
}
case "dossier_click_history_other":
{
WatchCaption.ForceRefreshHistory();
bool ok = WatchCaption.ClickFirstHistoryOtherRow(out long otherId);
if (ok)
{
_cmdOk++;
}
else
{
_cmdFail++;
}
Emit(
cmd,
ok,
detail: $"clicked={ok} otherId={otherId} otherRows={WatchCaption.CountHistoryOtherRows()} lore={ChronicleHud.DetailUnitId}");
break;
}
case "dossier_clear_job":
{
WatchCaption.ClearHarnessJobOverrides();
@ -684,6 +803,7 @@ public static class AgentHarness
}
bool expectBlocked = (cmd.expect ?? "").IndexOf("block", StringComparison.OrdinalIgnoreCase) >= 0;
bool optional = (cmd.expect ?? "").IndexOf("optional", StringComparison.OrdinalIgnoreCase) >= 0;
bool alive = false;
try
{
@ -695,7 +815,19 @@ public static class AgentHarness
}
bool applied = alive && StatusGameApi.TryAddStatus(focus, statusId, timer);
// Some units briefly refuse a status (immunity / race); one retry covers that class.
if (!applied && alive && !expectBlocked)
{
applied = StatusGameApi.TryAddStatus(focus, statusId, timer);
}
bool ok = expectBlocked ? !applied : applied;
if (optional)
{
// Seed-only second statuses: dossier only needs one chip for the assert.
ok = true;
}
if (ok)
{
_cmdOk++;
@ -710,7 +842,7 @@ public static class AgentHarness
cmd,
ok,
detail:
$"status={statusId} applied={applied} expectBlocked={expectBlocked} alive={alive} "
$"status={statusId} applied={applied} expectBlocked={expectBlocked} optional={optional} alive={alive} "
+ $"gains={ActivityLog.StatusGainsSinceClear} refreshes={ActivityLog.StatusRefreshesSinceClear} "
+ $"active={StatusGameApi.HasStatus(focus, statusId)}");
break;
@ -5942,6 +6074,139 @@ public static class AgentHarness
detail = $"needle='{needle}' caption='{caption.Replace("\n", " | ")}' dossier={(dossier != null)} reason='{dossier?.ReasonLine}'";
break;
}
case "tooltip_library_ok":
{
pass = DossierAssetTips.RunTooltipInventoryAudit(HarnessDir(), out string tipDetail);
detail = tipDetail;
break;
}
case "history_other_rows":
{
WatchCaption.ForceRefreshHistory();
int have = WatchCaption.CountHistoryOtherRows();
int want = 1;
if (!string.IsNullOrEmpty(cmd.value)
&& int.TryParse(cmd.value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
{
want = parsed;
}
string cmp = (cmd.label ?? "min").Trim().ToLowerInvariant();
pass = cmp == "exact" ? have == want : have >= want;
long subjectId = WatchCaption.CurrentUnitId;
if (subjectId == 0)
{
subjectId = ResolveActivitySubjectId();
}
int withRelated = 0;
var bits = new System.Text.StringBuilder();
IReadOnlyList<ActivityEntry> ring = ActivityLog.LatestForSubject(subjectId, 8);
for (int i = 0; i < ring.Count; i++)
{
ActivityEntry e = ring[i];
if (e == null)
{
continue;
}
if (e.RelatedId != 0 && e.RelatedId != subjectId)
{
withRelated++;
}
if (bits.Length > 0)
{
bits.Append(';');
}
bits.Append(e.TaskId ?? "")
.Append(':')
.Append(e.RelatedId)
.Append(':')
.Append(e.TargetLabel ?? "");
}
detail =
$"otherRows={have} want={want} cmp={cmp} subject={subjectId} visible={WatchCaption.Visible} "
+ $"ringRelated={withRelated} ring=[{bits}] hist='{WatchCaption.LastHistoryJoined}'";
break;
}
case "lore_detail_is_other":
{
long loreId = ChronicleHud.DetailUnitId;
long dossierId = WatchCaption.CurrentUnitId;
long cameraId = 0;
bool cameraFollow = false;
try
{
if (MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
cameraId = MoveCamera._focus_unit.getID();
cameraFollow = MoveCamera.isCameraFollowingUnit(MoveCamera._focus_unit);
}
}
catch
{
cameraId = 0;
cameraFollow = false;
}
bool fallenKiller = (Chronicle.LastFallenFocusDetail ?? "")
.StartsWith("killer:", System.StringComparison.Ordinal);
// After row-click OpenUnitHistory(other): Lore + dossier + camera on that living unit.
bool dossierOk = loreId != 0 && loreId == dossierId;
bool cameraOk = fallenKiller
|| (cameraId != 0 && cameraId == loreId && cameraFollow);
pass = dossierOk && cameraOk;
detail =
$"loreId={loreId} dossierId={dossierId} cameraId={cameraId} cameraFollow={cameraFollow} "
+ $"follow={ChronicleHud.FollowFocus} fallenKiller={fallenKiller}";
break;
}
case "reason_names_colored":
{
string reason = WatchCaption.Current?.ReasonLine ?? "";
bool hasColor = reason.IndexOf(ActivityProse.NameColorHex, System.StringComparison.OrdinalIgnoreCase) >= 0
|| reason.IndexOf("<color=", System.StringComparison.OrdinalIgnoreCase) >= 0;
pass = hasColor && !string.IsNullOrEmpty(reason);
detail = $"colored={hasColor} reason='{reason}'";
break;
}
case "reason_other_clickable":
{
bool want = string.IsNullOrEmpty(cmd.value)
|| !string.Equals(cmd.value.Trim(), "false", System.StringComparison.OrdinalIgnoreCase);
bool have = WatchCaption.ReasonOtherClickable;
pass = have == want;
detail =
$"clickable={have} want={want} relatedId={WatchCaption.Current?.ReasonRelatedId ?? 0} "
+ $"reason='{WatchCaption.Current?.ReasonLine}'";
break;
}
case "fallen_killer_banner":
{
string banner = WatchCaption.LastCaptionText ?? "";
string reason = "";
try
{
// Banner is shown on reason text while status banner is active.
reason = WatchCaption.Current?.ReasonLine ?? "";
}
catch
{
reason = "";
}
string hay = (banner + " " + reason).ToLowerInvariant();
bool has = hay.IndexOf("camera on killer", System.StringComparison.Ordinal) >= 0;
bool killerFocus = (Chronicle.LastFallenFocusDetail ?? "")
.StartsWith("killer:", System.StringComparison.Ordinal);
pass = killerFocus && has;
detail =
$"fallenFocus={Chronicle.LastFallenFocusDetail} bannerHit={has} caption='{banner}'";
break;
}
case "citizen_job_labels_ok":
{
// Full live citizen_job_library: non-empty Title Case labels; resource-role

View file

@ -1227,7 +1227,8 @@ public static class Chronicle
string victimName = SafeName(victim);
string line = $"Killed {victimName} ({SpeciesOf(victim)})";
ActivityLog.NoteMilestone(killer, "milestone_kill", line, victimName);
long victimId = EventFeedUtil.SafeId(victim);
ActivityLog.NoteMilestone(killer, "milestone_kill", line, victimName, victimId);
try
{
HappinessEventRouter.NoteCanonicalMilestone(killer.getID(), "milestone_kill");
@ -1276,7 +1277,8 @@ public static class Chronicle
string line = FormatDeathCause(victim, attackType, killer);
string killerName = killer != null ? SafeName(killer) : "";
ActivityLog.NoteMilestone(victim, "milestone_death", line, killerName);
long killerId = EventFeedUtil.SafeId(killer);
ActivityLog.NoteMilestone(victim, "milestone_death", line, killerName, killerId);
if (ModSettings.ChronicleEnabled)
{
AppendHistory(ChronicleKind.Death, victim, killer, line, attackType);
@ -1302,8 +1304,8 @@ public static class Chronicle
string nameB = SafeName(b);
string lineA = $"Became lovers with {nameB}";
string lineB = $"Became lovers with {nameA}";
ActivityLog.NoteMilestone(a, "milestone_lover", lineA, nameB);
ActivityLog.NoteMilestone(b, "milestone_lover", lineB, nameA);
ActivityLog.NoteMilestone(a, "milestone_lover", lineA, nameB, EventFeedUtil.SafeId(b));
ActivityLog.NoteMilestone(b, "milestone_lover", lineB, nameA, EventFeedUtil.SafeId(a));
try
{
HappinessEventRouter.NoteCanonicalMilestone(a.getID(), "milestone_lover");
@ -1348,8 +1350,8 @@ public static class Chronicle
string nameB = SafeName(b);
string lineA = $"Befriended {nameB}";
string lineB = $"Befriended {nameA}";
ActivityLog.NoteMilestone(a, "milestone_friend", lineA, nameB);
ActivityLog.NoteMilestone(b, "milestone_friend", lineB, nameA);
ActivityLog.NoteMilestone(a, "milestone_friend", lineA, nameB, EventFeedUtil.SafeId(b));
ActivityLog.NoteMilestone(b, "milestone_friend", lineB, nameA, EventFeedUtil.SafeId(a));
try
{
HappinessEventRouter.NoteCanonicalMilestone(a.getID(), "milestone_friend");
@ -1931,6 +1933,37 @@ public static class Chronicle
/// <summary>Harness: world position used for the last place-style fallen focus.</summary>
public static Vector3 LastFallenFocusPosition { get; private set; } = Vector3.zero;
/// <summary>
/// Pause banner after fallen Lore open - must say when camera is on the killer.
/// </summary>
public static string FallenPauseBanner()
{
string detail = LastFallenFocusDetail ?? "";
if (detail.StartsWith("killer:", System.StringComparison.Ordinal))
{
string killerLabel = "";
if (detail.Length > 7
&& long.TryParse(detail.Substring(7), out long killerId)
&& killerId != 0)
{
Actor killer = FindUnitById(killerId);
if (killer != null)
{
killerLabel = SafeName(killer);
}
}
if (!string.IsNullOrEmpty(killerLabel))
{
return "Paused (viewing fallen - camera on killer " + killerLabel + ")";
}
return "Paused (viewing fallen - camera on killer)";
}
return "Paused (viewing fallen)";
}
/// <summary>Snapshot living dossier just before die() - for Fallen archive UI.</summary>
public static void CaptureFinalDossier(Actor victim)
{

View file

@ -759,6 +759,8 @@ public static class ChronicleHud
_followFocus = followFocus;
_detailPane = DetailPane.Life;
// Pause idle first: SetActive(false) calls ClearFollow(). Focusing before that
// would instantly drop the camera off the browse subject.
if (pauseIdle && SpectatorMode.Active)
{
_pausedIdleForLore = true;
@ -771,22 +773,18 @@ public static class ChronicleHud
}
bool live = Chronicle.FocusCameraForBrowse(unitId);
_tab = live ? LoreTab.Living : LoreTab.Fallen;
if (live)
if (!live)
{
WatchCaption.PinWhilePaused();
}
else
{
// Dead / missing: keep dossier if any; pan to killer or death place.
WatchCaption.PinWhilePaused();
Chronicle.FocusFallenSubject(unitId);
}
_tab = live ? LoreTab.Living : LoreTab.Fallen;
WatchCaption.PinWhilePaused();
if (pauseIdle)
{
WatchCaption.ShowStatusBanner(
live ? "Paused (viewing history)" : "Paused (viewing fallen)");
live ? "Paused (viewing history)" : Chronicle.FallenPauseBanner());
}
SetVisible(true);

View file

@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace IdleSpectator;
/// <summary>
/// Vanilla trait/status tooltips for dossier chips.
/// Tip types come from live <see cref="AssetManager.tooltips"/> (not invented keys).
/// </summary>
public static class DossierAssetTips
{
public const string TraitTipType = "trait";
public const string StatusTipType = "status";
private static HashSet<string> _liveTipTypes;
private static int _liveTipTypesCount = -1;
public static List<string> EnumerateLiveTooltipTypeIds()
{
var ids = new List<string>();
try
{
TooltipLibrary lib = AssetManager.tooltips;
if (lib?.list == null)
{
return ids;
}
for (int i = 0; i < lib.list.Count; i++)
{
TooltipAsset asset = lib.list[i];
if (asset != null && !string.IsNullOrEmpty(asset.id))
{
ids.Add(asset.id);
}
}
}
catch
{
// Library can be unavailable during early boot.
}
return ids;
}
public static bool HasLiveTooltipType(string tipType)
{
if (string.IsNullOrEmpty(tipType))
{
return false;
}
EnsureLiveTipTypes();
return _liveTipTypes != null && _liveTipTypes.Contains(tipType);
}
/// <summary>
/// Harness audit: dump live tooltip ids; require trait + status tip types exist.
/// </summary>
public static bool RunTooltipInventoryAudit(string outputDirectory, out string detail)
{
List<string> ids = EnumerateLiveTooltipTypeIds();
var lines = new List<string>(ids.Count + 1) { "id" };
for (int i = 0; i < ids.Count; i++)
{
lines.Add(ids[i]);
}
try
{
if (!string.IsNullOrEmpty(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
File.WriteAllLines(Path.Combine(outputDirectory, "tooltip-library-ids.tsv"), lines);
}
}
catch
{
// diagnostic dump only
}
bool hasTrait = HasLiveTooltipType(TraitTipType);
bool hasStatus = HasLiveTooltipType(StatusTipType);
bool pass = ids.Count > 0 && hasTrait && hasStatus;
detail = $"tips={ids.Count} trait={hasTrait} status={hasStatus}";
return pass;
}
public static void ShowTraitTip(GameObject host, ActorTrait trait)
{
if (host == null || trait == null || !HasLiveTooltipType(TraitTipType))
{
return;
}
try
{
if (!Config.tooltips_active)
{
return;
}
Tooltip.show(host, TraitTipType, new TooltipData { trait = trait });
}
catch
{
// Tip surface can be unavailable mid-load.
}
}
public static void ShowStatusTip(GameObject host, Actor actor, StatusAsset asset, string statusId)
{
if (host == null || !HasLiveTooltipType(StatusTipType))
{
return;
}
StatusAsset resolved = asset;
if (resolved == null && !string.IsNullOrEmpty(statusId))
{
resolved = ActivityAssetCatalog.TryGetStatusAsset(statusId);
}
if (resolved == null)
{
return;
}
Status live = FindLiveStatus(actor, resolved.id ?? statusId);
if (live == null)
{
// Vanilla status tip needs a Status instance (time bar + stats).
return;
}
try
{
if (!Config.tooltips_active)
{
return;
}
string locale = "";
string description = "";
try
{
locale = resolved.getLocaleID() ?? "";
description = resolved.getDescriptionID() ?? "";
}
catch
{
// fall through with empty locale keys - showStatus still uses status.asset stats
}
Tooltip.show(host, StatusTipType, new TooltipData
{
tip_name = locale,
tip_description = description,
status = live
});
}
catch
{
// Tip surface can be unavailable mid-load.
}
}
public static void HideTip()
{
try
{
Tooltip.hideTooltip();
}
catch
{
// ignore
}
}
private static Status FindLiveStatus(Actor actor, string statusId)
{
if (actor == null || string.IsNullOrEmpty(statusId))
{
return null;
}
try
{
if (!actor.isAlive())
{
return null;
}
foreach (Status status in actor.getStatuses())
{
if (status?.asset != null
&& string.Equals(status.asset.id, statusId, StringComparison.Ordinal))
{
return status;
}
}
}
catch
{
return null;
}
return null;
}
private static void EnsureLiveTipTypes()
{
List<string> ids = EnumerateLiveTooltipTypeIds();
if (_liveTipTypes != null && _liveTipTypesCount == ids.Count)
{
return;
}
_liveTipTypes = new HashSet<string>(StringComparer.Ordinal);
for (int i = 0; i < ids.Count; i++)
{
if (!string.IsNullOrEmpty(ids[i]))
{
_liveTipTypes.Add(ids[i]);
}
}
_liveTipTypesCount = ids.Count;
}
}
/// <summary>Pointer hover on a dossier trait/status chip root.</summary>
public sealed class DossierChipTip : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
{
public enum Kind
{
Trait,
Status
}
public Kind ChipKind;
public string AssetId = "";
public ActorTrait Trait;
public StatusAsset Status;
public void BindTrait(ActorTrait trait, string id)
{
ChipKind = Kind.Trait;
Trait = trait;
Status = null;
AssetId = id ?? (trait != null ? trait.id : "");
EnsureHitTarget();
}
public void BindStatus(StatusAsset status, string id)
{
ChipKind = Kind.Status;
Status = status;
Trait = null;
AssetId = id ?? (status != null ? status.id : "");
EnsureHitTarget();
}
public void Clear()
{
Trait = null;
Status = null;
AssetId = "";
}
public void OnPointerEnter(PointerEventData eventData)
{
if (ChipKind == Kind.Trait)
{
ActorTrait trait = Trait;
if (trait == null && !string.IsNullOrEmpty(AssetId))
{
try
{
trait = AssetManager.traits?.get(AssetId);
}
catch
{
trait = null;
}
}
DossierAssetTips.ShowTraitTip(gameObject, trait);
return;
}
Actor actor = WatchCaption.BoundActor;
DossierAssetTips.ShowStatusTip(gameObject, actor, Status, AssetId);
}
public void OnPointerExit(PointerEventData eventData)
{
DossierAssetTips.HideTip();
}
private void OnDisable()
{
DossierAssetTips.HideTip();
}
private void EnsureHitTarget()
{
Image hit = GetComponent<Image>();
if (hit == null)
{
hit = gameObject.AddComponent<Image>();
}
hit.color = new Color(1f, 1f, 1f, 0.001f);
hit.raycastTarget = true;
}
}

View file

@ -18,6 +18,8 @@ public static class DossierAvatar
private static bool _usingVanilla;
private static bool _loggedMode;
private static long _shownActorId = -1;
/// <summary>Egg/baby/adult + species - same unit id can change visual form.</summary>
private static string _shownFormKey = "";
public static bool UsingVanilla => _usingVanilla;
@ -48,6 +50,7 @@ public static class DossierAvatar
_usingVanilla = false;
_loggedMode = false;
_shownActorId = -1;
_shownFormKey = "";
}
public static void EnsureHost(Transform parent, float size)
@ -117,9 +120,11 @@ public static class DossierAvatar
}
long id = actor.getID();
if (_usingVanilla && _instance != null && _shownActorId == id && _instance.gameObject.activeSelf)
string formKey = ActorFormKey(actor);
if (_usingVanilla && _instance != null && _shownActorId == id && _instance.gameObject.activeSelf
&& formKey == _shownFormKey)
{
// Always clear species fallback - it can linger above the live portrait.
// Same unit + same life stage: only refresh the floor tile underfoot.
HideFallback();
try
{
@ -157,8 +162,10 @@ public static class DossierAvatar
_instance.avatarLoader.avatarSize = 0.55f;
}
// Re-load avatar whenever form changes (egg → chick) even if unit id is unchanged.
_instance.show(actor);
_shownActorId = id;
_shownFormKey = formKey;
DisableInteractions(_instance.gameObject);
return;
}
@ -166,17 +173,65 @@ public static class DossierAvatar
{
LogService.LogInfo("[IdleSpectator] Vanilla avatar show failed, using fallback: " + ex.Message);
FallbackToSprite(actor);
_shownActorId = id;
_shownFormKey = formKey;
return;
}
}
FallbackToSprite(actor);
_shownActorId = id;
_shownFormKey = formKey;
}
/// <summary>
/// Visual form key for the live portrait. Same actor id can change (egg → baby → adult).
/// </summary>
private static string ActorFormKey(Actor actor)
{
if (actor == null)
{
return "";
}
string species = "";
try
{
species = actor.asset != null ? (actor.asset.id ?? "") : "";
}
catch
{
species = "";
}
string stage = "adult";
try
{
if (actor.isEgg())
{
stage = "egg";
}
else if (actor.isBaby())
{
stage = "baby";
}
else if (!actor.isAdult())
{
stage = "young";
}
}
catch
{
stage = "unknown";
}
return species + "|" + stage;
}
public static void ClearActor()
{
_shownActorId = -1;
_shownFormKey = "";
HideVanillaInstance();
HideFallback();
}

View file

@ -26,7 +26,14 @@ internal static class FocusTooltipPatches
return true;
}
// Dossier chrome (not the sprite): no tip.
// Trait/status chips: DossierChipTip owns the tip; skip gameplay actor tip / hide.
if (WatchCaption.IsPointerOverAssetChip())
{
__result = true;
return false;
}
// Dossier chrome (not the sprite / chips): no tip.
if (WatchCaption.IsPointerOverPanel())
{
Tooltip.hideTooltip();

View file

@ -296,8 +296,10 @@ internal static class HarnessScenarios
Step("hp32", "assert", expect: "activity_log_contains", value: "Ava"),
Step("hp33", "assert", expect: "activity_happiness_task_id", value: "happiness:death_child"),
// Real lover grief path
Step("hp40", "spawn", asset: "human"),
// Real lover grief path (+ dossier row → other Lore).
// Idle must be on *before* the bond: first spectator enable can ClearSession the ring.
Step("hp40", "spectator", value: "on"),
Step("hp40b", "spawn", asset: "human"),
Step("hp41", "happiness_remember_partner"),
Step("hp42", "spawn", asset: "human"),
Step("hp43", "focus", asset: "auto"),
@ -306,6 +308,16 @@ internal static class HarnessScenarios
Step("hp46", "happiness_bond_lovers"),
Step("hp46b", "assert", expect: "activity_happiness_count", value: "2", label: "min"),
Step("hp46c", "assert", expect: "activity_log_contains", value: "love"),
Step("hp46d", "focus", asset: "auto"),
Step("hp46e", "wait", wait: 0.2f),
Step("hp46f", "assert", expect: "history_other_rows", value: "1", label: "min"),
Step("hp46g", "remember_focus"),
Step("hp46h", "dossier_click_history_other"),
Step("hp46i", "wait", wait: 0.2f),
Step("hp46j", "assert", expect: "lore_detail", value: "true"),
Step("hp46k", "assert", expect: "lore_detail_is_other"),
Step("hp46l", "lore_close"),
Step("hp46m", "spectator", value: "off"),
Step("hp47", "wait", value: "0.5"),
Step("hp48", "happiness_kill_partner"),
Step("hp49", "wait", value: "0.5"),
@ -2145,7 +2157,8 @@ internal static class HarnessScenarios
Step("act16r", "dossier_clear_job"),
// Statuses row (priority, max 4).
Step("act16s", "status_apply", value: "cursed", label: "20"),
Step("act16t", "status_apply", value: "poisoned", label: "20"),
// Optional second chip - some units refuse poison; cursed alone still gates the row.
Step("act16t", "status_apply", value: "poisoned", label: "20", expect: "optional"),
Step("act16u", "wait", wait: 0.25f),
Step("act16v", "assert", expect: "dossier_statuses_ok", value: "1", label: "min"),
Step("act16w", "assert", expect: "dossier_contains", value: "Cursed"),
@ -2180,6 +2193,42 @@ internal static class HarnessScenarios
Step("act20g", "chronicle_milestone", value: "lover"),
Step("act20h", "assert", expect: "activity_log_contains", value: "Became lovers"),
Step("act20i", "assert", expect: "chronicle_latest_contains", value: "Became lovers"),
// Whole-row other-person click → Lore biography (then restore original focus).
Step("act20j", "assert", expect: "tooltip_library_ok"),
Step("act20j2", "remember_focus"),
Step("act20k", "assert", expect: "history_other_rows", value: "1", label: "min"),
Step("act20l", "dossier_click_history_other"),
Step("act20m", "wait", wait: 0.3f),
Step("act20n", "assert", expect: "lore_detail", value: "true"),
Step("act20o", "assert", expect: "lore_detail_is_other"),
Step("act20p", "lore_close"),
Step("act20q", "spectator", value: "on"),
Step("act20r", "wait", wait: 0.2f),
// Return to a living subject so later activity-count asserts stay valid.
// Orange reason click → related unit Lore (same contract as history rows).
Step("act20r2", "spawn", asset: "human", count: 1),
Step("act20r2b", "happiness_remember_partner"),
Step("act20r3", "spawn", asset: "human", count: 1),
Step("act20r4", "pick_unit", asset: "human"),
Step("act20r5", "focus", asset: "auto"),
Step("act20r6", "wait", wait: 0.2f),
Step("act20s", "dossier_force_named_reason", value: "is fighting", expect: "auto"),
Step("act20t", "assert", expect: "reason_names_colored"),
Step("act20t2", "assert", expect: "reason_other_clickable"),
Step("act20t3", "remember_focus"),
Step("act20t4", "dossier_click_reason_other"),
Step("act20t5", "wait", wait: 0.2f),
Step("act20t6", "assert", expect: "lore_detail", value: "true"),
Step("act20t7", "assert", expect: "lore_detail_is_other"),
Step("act20t8", "lore_close"),
Step("act20t9", "spectator", value: "on"),
Step("act20t10", "focus", asset: "auto"),
Step("act20u", "dossier_clear_job"),
// Re-seed activity ring after the other-person Lore detour.
Step("act20v", "activity_force", asset: "BehPollinate", label: "Works the flower", count: 2, tier: "beat"),
Step("act20w", "activity_force", asset: "zzz_harness_act", label: "Harness pollen trail", count: 1),
Step("act20x", "activity_force", asset: "BehSocializeTalk", label: "Talks", count: 1, tier: "beat",
expect: "Barkley"),
Step("act21", "assert", expect: "dossier_history_shown", value: "1", label: "min", tier: "activity"),
Step("act22", "assert", expect: "dossier_history_not_contains", value: "Harness pollen"),
Step("act23", "assert", expect: "caption_layout_ok"),
@ -2433,6 +2482,7 @@ internal static class HarnessScenarios
Step("ch18k11d", "assert", expect: "dossier_species", value: "skeleton"),
Step("ch18k11e", "assert", expect: "dossier_avatar_species"),
Step("ch18k11f", "assert", expect: "fallen_killer_camera"),
Step("ch18k11g", "assert", expect: "fallen_killer_banner"),
Step("ch18k12", "screenshot", value: "hud-lore-fallen-slain.png"),
Step("ch18k13", "wait", wait: 0.55f),
// Regression: fallen species portrait must not stick onto the next living dossier.
@ -2490,6 +2540,8 @@ internal static class HarnessScenarios
Step("ch18ft4", "assert", expect: "lore_tab", value: "fallen"),
Step("ch18ft5", "assert", expect: "fallen_list_before", value: "Slainbone", label: "Oldbone"),
// Snapshot stays put until Refresh (new deaths do not auto-rebuild).
// Refresh first so live world deaths during the long scenario do not leave stale=true.
Step("ch18ft5a", "lore_refresh"),
Step("ch18ft5b", "assert", expect: "lore_list_stale", value: "false"),
Step(
"ch18ft5c",

View file

@ -14,6 +14,8 @@ public sealed class UnitDossier
public string SpeciesId = "";
public string Headline = "";
public string ReasonLine = "";
/// <summary>Other unit named by the orange reason (0 when none).</summary>
public long ReasonRelatedId;
public string DetailLine = "";
public string CaptionText = "";
public string JobLabel = "";
@ -62,10 +64,18 @@ public sealed class UnitDossier
/// <summary>Harness: optional watch-reason fragment used with <see cref="HarnessJobLabelOverride"/>.</summary>
public static string HarnessReasonOverride = "";
/// <summary>Harness: related unit for forced reason clicks / gold names.</summary>
public static long HarnessReasonRelatedId;
/// <summary>Harness: related display name for forced reason prose.</summary>
public static string HarnessReasonRelatedName = "";
public static void ClearHarnessOverrides()
{
HarnessJobLabelOverride = "";
HarnessReasonOverride = "";
HarnessReasonRelatedId = 0;
HarnessReasonRelatedName = "";
}
/// <summary>
@ -321,7 +331,23 @@ public sealed class UnitDossier
{
if (!string.IsNullOrEmpty(HarnessReasonOverride))
{
return HarnessReasonOverride.Trim();
string plain = HarnessReasonOverride.Trim();
string subject = d != null ? (d.Name ?? "") : "";
if (string.IsNullOrEmpty(subject) && actor != null)
{
subject = EventFeedUtil.SafeName(actor);
}
string relatedName = HarnessReasonRelatedName ?? "";
if (d != null)
{
d.ReasonRelatedId = ResolveReasonRelatedId(
actor,
HarnessReasonRelatedId,
relatedName);
}
return ActivityProse.ColorizePersonNames(plain, subject, relatedName);
}
return OwnedEventReason(actor, d);
@ -341,6 +367,11 @@ public sealed class UnitDossier
InterestCandidate scene = InterestDirector.TryGetOwnedReasonCandidate(actor);
if (scene == null)
{
if (dossier != null)
{
dossier.ReasonRelatedId = 0;
}
return "";
}
@ -348,6 +379,11 @@ public sealed class UnitDossier
if (scene.LeadKind == InterestLeadKind.CharacterLed
&& !InterestScoring.IsCombatAction(scene))
{
if (dossier != null)
{
dossier.ReasonRelatedId = 0;
}
return "";
}
@ -367,7 +403,51 @@ public sealed class UnitDossier
}
}
return EvaluateSceneLabel(scene, d, recordDrops: true) ?? "";
string beat = EvaluateSceneLabel(scene, d, recordDrops: true) ?? "";
if (string.IsNullOrEmpty(beat))
{
d.ReasonRelatedId = 0;
return "";
}
// Gold names inside the orange reason row (same palette as history/activity).
string subjectName = d != null ? (d.Name ?? "") : "";
if (string.IsNullOrEmpty(subjectName))
{
subjectName = EventFeedUtil.SafeName(actor);
}
string relatedName = EventFeedUtil.SafeName(scene.RelatedUnit);
long relatedId = scene.RelatedId != 0
? scene.RelatedId
: EventFeedUtil.SafeId(scene.RelatedUnit);
d.ReasonRelatedId = ResolveReasonRelatedId(actor, relatedId, relatedName);
return ActivityProse.ColorizePersonNames(beat, subjectName, relatedName);
}
private static long ResolveReasonRelatedId(Actor subject, long relatedId, string relatedName)
{
long subjectId = 0;
try
{
subjectId = subject != null ? subject.getID() : 0;
}
catch
{
subjectId = 0;
}
if (relatedId != 0 && relatedId != subjectId)
{
return relatedId;
}
if (string.IsNullOrEmpty(relatedName))
{
return 0;
}
return ActivityLog.ResolveLivingUnitIdByName(relatedName, subjectId);
}
/// <summary>

View file

@ -62,6 +62,7 @@ public static class WatchCaption
private static Image _sexIcon;
private static Text _nameText;
private static Text _reasonText;
private static long _reasonOtherId;
private static Image _levelIcon;
private static Text _levelValue;
@ -103,6 +104,7 @@ public static class WatchCaption
public GameObject Root;
public Image Icon;
public Text Label;
public DossierChipTip Tip;
}
private sealed class HistorySlot
@ -110,10 +112,16 @@ public static class WatchCaption
public GameObject Root;
public Image Icon;
public Text Label;
public Button Button;
public Image Hit;
public long OtherId;
}
public static UnitDossier Current => _current;
/// <summary>Living actor currently bound to the dossier (null for fallen archive).</summary>
public static Actor BoundActor => _boundActor;
public static string LastHeadline { get; private set; } = "";
public static string LastDetail { get; private set; } = "";
@ -209,6 +217,45 @@ public static class WatchCaption
return DossierAvatar.ContainsScreenPoint(Input.mousePosition);
}
/// <summary>True if the mouse is over a trait/status chip (vanilla tip host).</summary>
public static bool IsPointerOverAssetChip()
{
if (!_visible || _root == null || !_root.activeInHierarchy)
{
return false;
}
Vector2 mouse = Input.mousePosition;
for (int i = 0; i < _traitSlots.Length; i++)
{
if (IsPointerOverChipRoot(_traitSlots[i]?.Root, mouse))
{
return true;
}
}
for (int i = 0; i < _statusSlots.Length; i++)
{
if (IsPointerOverChipRoot(_statusSlots[i]?.Root, mouse))
{
return true;
}
}
return false;
}
private static bool IsPointerOverChipRoot(GameObject root, Vector2 mouse)
{
if (root == null || !root.activeInHierarchy)
{
return false;
}
RectTransform rt = root.GetComponent<RectTransform>();
return rt != null && RectTransformUtility.RectangleContainsScreenPoint(rt, mouse, null);
}
/// <summary>
/// Favorite control (and the panel chrome around it).
/// Used so idle does not treat those clicks as "manual pause".
@ -402,6 +449,7 @@ public static class WatchCaption
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.gameObject.SetActive(true);
WireReasonClick(0);
}
LastCaptionText = (LastHeadline ?? "Idle Spectator") + " | " + message;
@ -731,7 +779,30 @@ public static class WatchCaption
return;
}
string next = UnitDossier.OwnedEventReason(actor, _current) ?? "";
// Honor harness reason override the same way FromActor/BuildStoryBeat does.
string next;
if (!string.IsNullOrEmpty(UnitDossier.HarnessReasonOverride))
{
string subject = _current.Name ?? "";
if (string.IsNullOrEmpty(subject))
{
subject = EventFeedUtil.SafeName(actor);
}
string relatedName = UnitDossier.HarnessReasonRelatedName ?? "";
_current.ReasonRelatedId = UnitDossier.HarnessReasonRelatedId != 0
? UnitDossier.HarnessReasonRelatedId
: ActivityLog.ResolveLivingUnitIdByName(relatedName, _current.UnitId);
next = ActivityProse.ColorizePersonNames(
UnitDossier.HarnessReasonOverride.Trim(),
subject,
relatedName);
}
else
{
next = UnitDossier.OwnedEventReason(actor, _current) ?? "";
}
string prev = _current.ReasonLine ?? "";
if (next == prev)
{
@ -745,8 +816,7 @@ public static class WatchCaption
bool hasReason = !string.IsNullOrEmpty(next);
if (_reasonText != null)
{
_reasonText.text = hasReason ? next : "";
_reasonText.gameObject.SetActive(hasReason);
ApplyReasonText(hasReason ? next : "", hasReason ? _current.ReasonRelatedId : 0);
}
bool hasTask = !string.IsNullOrEmpty(_current.TaskText);
@ -1122,6 +1192,11 @@ public static class WatchCaption
{
_lastHistoryCount = -1;
_lastLiveHistFingerprint = int.MinValue;
if (_current == null && MoveCamera.hasFocusUnit() && MoveCamera._focus_unit != null)
{
SetFromActor(MoveCamera._focus_unit);
}
if (!_visible || _current == null)
{
return;
@ -1133,7 +1208,11 @@ public static class WatchCaption
/// <summary>
/// Harness: force JobLabel onto the nametag identity tag (and optional orange reason beat).
/// </summary>
public static bool ForceJobLabelOnFocus(string jobLabel, string reasonOverride = "")
public static bool ForceJobLabelOnFocus(
string jobLabel,
string reasonOverride = "",
long reasonRelatedId = 0,
string reasonRelatedName = "")
{
if (!MoveCamera.hasFocusUnit() || MoveCamera._focus_unit == null)
{
@ -1142,6 +1221,8 @@ public static class WatchCaption
UnitDossier.HarnessJobLabelOverride = jobLabel ?? "";
UnitDossier.HarnessReasonOverride = reasonOverride ?? "";
UnitDossier.HarnessReasonRelatedId = reasonRelatedId;
UnitDossier.HarnessReasonRelatedName = reasonRelatedName ?? "";
SetFromActor(MoveCamera._focus_unit);
return _current != null
&& !string.IsNullOrEmpty(_current.JobLabel)
@ -1303,17 +1384,82 @@ public static class WatchCaption
bool hasReason = dossier != null && !string.IsNullOrEmpty(dossier.ReasonLine);
if (_reasonText != null)
{
_reasonText.text = hasReason ? dossier.ReasonLine : "";
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.resizeTextForBestFit = false;
_reasonText.gameObject.SetActive(hasReason);
ApplyReasonText(
hasReason ? dossier.ReasonLine : "",
hasReason && dossier != null ? dossier.ReasonRelatedId : 0);
}
Relayout(hasBody, traitCount, statusCount, hasTask, hasReason, historyCount);
RefreshFavoriteVisual(hasLive ? actor : null);
}
/// <summary>Orange story beat with rich-text gold names; empty hides the row.</summary>
private static void ApplyReasonText(string reasonRichOrPlain, long otherId = 0)
{
if (_reasonText == null)
{
return;
}
bool hasReason = !string.IsNullOrEmpty(reasonRichOrPlain);
_reasonText.supportRichText = true;
_reasonText.color = ReasonColor;
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.resizeTextForBestFit = false;
_reasonText.text = hasReason ? reasonRichOrPlain : "";
_reasonText.gameObject.SetActive(hasReason);
WireReasonClick(hasReason ? otherId : 0);
}
private static void WireReasonClick(long otherId)
{
long subjectId = CurrentUnitId;
_reasonOtherId = otherId != 0 && otherId != subjectId ? otherId : 0;
if (_reasonText == null)
{
return;
}
var entry = _reasonText.gameObject.GetComponent<UnityEngine.EventSystems.EventTrigger>();
if (entry == null)
{
entry = _reasonText.gameObject.AddComponent<UnityEngine.EventSystems.EventTrigger>();
}
entry.triggers.Clear();
bool clickable = _reasonOtherId != 0 && _reasonText.gameObject.activeSelf;
_reasonText.raycastTarget = clickable;
if (!clickable)
{
return;
}
long captured = _reasonOtherId;
var down = new UnityEngine.EventSystems.EventTrigger.Entry
{
eventID = UnityEngine.EventSystems.EventTriggerType.PointerDown
};
down.callback.AddListener(_ => OpenHistoryOtherInLore(captured));
entry.triggers.Add(down);
}
/// <summary>Harness: orange reason names another living unit.</summary>
public static bool ReasonOtherClickable => _reasonOtherId != 0;
/// <summary>Harness: click the orange reason to open the related unit in Lore.</summary>
public static bool ClickReasonOther(out long otherId)
{
otherId = _reasonOtherId;
if (otherId == 0)
{
return false;
}
OpenHistoryOtherInLore(otherId);
return true;
}
private static int ApplyTraitChips(UnitDossier dossier)
{
int traitCount = 0;
@ -1356,6 +1502,20 @@ public static class WatchCaption
slot.Label.text = name;
}
if (slot.Tip != null)
{
if (chip != null && (chip.Trait != null || !string.IsNullOrEmpty(chip.Id)))
{
slot.Tip.BindTrait(chip.Trait, chip.Id);
slot.Tip.enabled = true;
}
else
{
slot.Tip.Clear();
slot.Tip.enabled = false;
}
}
if (traitPreview.Length > 0)
{
traitPreview.Append(", ");
@ -1366,6 +1526,12 @@ public static class WatchCaption
}
else
{
if (slot.Tip != null)
{
slot.Tip.Clear();
slot.Tip.enabled = false;
}
slot.Root.SetActive(false);
}
}
@ -1422,6 +1588,20 @@ public static class WatchCaption
slot.Label.color = StatusNameColor;
}
if (slot.Tip != null)
{
if (chip != null && (chip.Status != null || !string.IsNullOrEmpty(chip.Id)))
{
slot.Tip.BindStatus(chip.Status, chip.Id);
slot.Tip.enabled = true;
}
else
{
slot.Tip.Clear();
slot.Tip.enabled = false;
}
}
if (preview.Length > 0)
{
preview.Append(", ");
@ -1432,6 +1612,12 @@ public static class WatchCaption
}
else
{
if (slot.Tip != null)
{
slot.Tip.Clear();
slot.Tip.enabled = false;
}
slot.Root.SetActive(false);
}
}
@ -1559,7 +1745,8 @@ public static class WatchCaption
_historyCol.SetActive(true);
var lines = new List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey)>(HistoryPeekMax);
var lines = new List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey, long otherId)>(
HistoryPeekMax);
if (activity != null)
{
for (int i = 0; i < activity.Count && lines.Count < HistoryPeekMax; i++)
@ -1577,7 +1764,8 @@ public static class WatchCaption
}
string rich = !string.IsNullOrEmpty(e.DisplayLineRich) ? e.DisplayLineRich : plain;
lines.Add((rich, plain, true, null, e.TaskId ?? ""));
long otherId = ResolveHistoryOtherId(unitId, e.RelatedId, e.TargetLabel);
lines.Add((rich, plain, true, null, e.TaskId ?? "", otherId));
}
}
@ -1598,7 +1786,8 @@ public static class WatchCaption
continue;
}
lines.Add((rich, plain, false, e.Kind, ""));
long otherId = e.OtherId != 0 && e.OtherId != unitId ? e.OtherId : 0;
lines.Add((rich, plain, false, e.Kind, "", otherId));
}
}
@ -1639,7 +1828,7 @@ public static class WatchCaption
}
private static void ApplyCombinedSlotContents(
List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey)> lines,
List<(string rich, string plain, bool isActivity, ChronicleKind? kind, string activityKey, long otherId)> lines,
float colW)
{
float labelW = Mathf.Max(24f, colW - HistoryIcon - 2f);
@ -1660,6 +1849,8 @@ public static class WatchCaption
if (i >= lines.Count)
{
slot.Root.SetActive(false);
slot.OtherId = 0;
WireHistoryRowClick(slot, 0);
_historySlotHeights[i] = 0f;
_historySlotIsActivity[i] = false;
continue;
@ -1668,6 +1859,7 @@ public static class WatchCaption
var row = lines[i];
_historySlotIsActivity[i] = row.isActivity;
slot.Root.SetActive(true);
slot.OtherId = row.otherId;
Sprite icon = row.isActivity
? (HudIcons.ForActivityKey(row.activityKey)
?? HudIcons.FromUiIcon("iconClock")
@ -1707,6 +1899,8 @@ public static class WatchCaption
_historySlotHeights[i] = HistoryLineMinH;
}
WireHistoryRowClick(slot, row.otherId);
if (row.isActivity)
{
LastActivityShown++;
@ -1727,6 +1921,132 @@ public static class WatchCaption
LastHistoryJoined = string.Join(" | ", joined);
}
private static void WireHistoryRowClick(HistorySlot slot, long otherId)
{
if (slot?.Root == null)
{
return;
}
slot.OtherId = otherId;
if (slot.Hit == null)
{
slot.Hit = slot.Root.GetComponent<Image>();
if (slot.Hit == null)
{
slot.Hit = slot.Root.AddComponent<Image>();
}
slot.Hit.color = new Color(1f, 1f, 1f, 0.001f);
}
if (slot.Button == null)
{
slot.Button = slot.Root.GetComponent<Button>();
if (slot.Button == null)
{
slot.Button = slot.Root.AddComponent<Button>();
}
ColorBlock colors = slot.Button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1.15f, 1.15f, 1.2f, 1f);
colors.pressedColor = new Color(0.85f, 0.85f, 0.9f, 1f);
slot.Button.colors = colors;
slot.Button.transition = Selectable.Transition.ColorTint;
slot.Button.targetGraphic = slot.Hit;
}
slot.Button.onClick.RemoveAllListeners();
bool clickable = otherId != 0;
slot.Hit.raycastTarget = clickable;
slot.Button.interactable = clickable;
var entry = slot.Root.GetComponent<UnityEngine.EventSystems.EventTrigger>();
if (entry == null)
{
entry = slot.Root.AddComponent<UnityEngine.EventSystems.EventTrigger>();
}
entry.triggers.Clear();
if (!clickable)
{
return;
}
// PointerDown (not Button release) so Lore opens on the same frame as the press.
long captured = otherId;
var down = new UnityEngine.EventSystems.EventTrigger.Entry
{
eventID = UnityEngine.EventSystems.EventTriggerType.PointerDown
};
down.callback.AddListener(_ => OpenHistoryOtherInLore(captured));
entry.triggers.Add(down);
}
/// <summary>
/// Prefer stored related id; if missing (older happiness rings), resolve by living name.
/// </summary>
private static long ResolveHistoryOtherId(long subjectId, long relatedId, string relatedName)
{
if (relatedId != 0 && relatedId != subjectId)
{
return relatedId;
}
if (string.IsNullOrEmpty(relatedName))
{
return 0;
}
return ActivityLog.ResolveLivingUnitIdByName(relatedName, subjectId);
}
/// <summary>Whole-row click: focus other unit (or fallen path) and open Lore biography.</summary>
public static void OpenHistoryOtherInLore(long otherId)
{
if (otherId == 0)
{
return;
}
ChronicleHud.OpenUnitHistory(otherId, pauseIdle: true, followFocus: true);
}
/// <summary>Harness: click first peek row that names another unit.</summary>
public static bool ClickFirstHistoryOtherRow(out long otherId)
{
otherId = 0;
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Root == null || !slot.Root.activeSelf || slot.OtherId == 0)
{
continue;
}
otherId = slot.OtherId;
OpenHistoryOtherInLore(otherId);
return true;
}
return false;
}
public static int CountHistoryOtherRows()
{
int n = 0;
for (int i = 0; i < _historySlots.Length; i++)
{
HistorySlot slot = _historySlots[i];
if (slot?.Root != null && slot.Root.activeSelf && slot.OtherId != 0)
{
n++;
}
}
return n;
}
private static void RemeasureHistoryForWidth(float colW)
{
_historyColW = Mathf.Clamp(colW, HistoryColMinW, HistoryColMaxW);
@ -1838,16 +2158,19 @@ public static class WatchCaption
int historyCount)
{
float headerW = MeasureHeaderWidth(hasTask);
float traitsW = 0f;
// Traits beside avatar only when history and statuses leave the body-right slot free.
bool traitsBeside = hasBody && historyCount <= 0 && statusCount <= 0 && traitCount > 0;
if (traitCount > 0 && !traitsBeside)
float traitsRowW = traitCount > 0 && !traitsBeside ? MeasureTraitsRowWidth() : 0f;
float traitsBesideW = traitsBeside ? MeasureTraitsBesideWidth() : 0f;
float statusesRowW = statusCount > 0 ? MeasureStatusesRowWidth() : 0f;
float traitsW = 0f;
if (traitsBeside)
{
traitsW = HistoryColMinW;
traitsW = Mathf.Max(HistoryColMinW, traitsBesideW);
}
else if (traitsBeside)
else if (traitCount > 0)
{
traitsW = HistoryColMinW;
traitsW = Mathf.Max(HistoryColMinW, traitsRowW);
}
float bodyW = 0f;
@ -1867,8 +2190,9 @@ public static class WatchCaption
}
}
// Chip rows must widen the panel - PlaceTraits/PlaceStatuses lay out natural widths.
_panelWidth = Mathf.Clamp(
PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, LiveMax),
PadX * 2f + Mathf.Max(headerW, bodyW, traitsW, statusesRowW, LiveMax),
LiveMax + PadX * 2f,
PanelWidthMax);
@ -1913,13 +2237,15 @@ public static class WatchCaption
if (statusCount > 0)
{
PlaceStatuses(y, statusCount);
float statusesUsed = PlaceStatuses(y, statusCount);
WidenPanelForChipRow(statusesUsed);
y += StatusesH + Gap;
}
if (traitCount > 0 && !traitsBeside)
{
PlaceTraits(y, traitCount);
float traitsUsed = PlaceTraits(y, traitCount);
WidenPanelForChipRow(traitsUsed);
y += TraitsH + Gap;
}
@ -2340,6 +2666,93 @@ public static class WatchCaption
}
}
private static float MeasureChipRowWidth(TraitSlot[] slots, float fallbackLabelW)
{
if (slots == null)
{
return 0f;
}
try
{
Canvas.ForceUpdateCanvases();
}
catch
{
// ignore
}
float x = 0f;
int n = 0;
for (int i = 0; i < slots.Length; i++)
{
TraitSlot slot = slots[i];
if (slot == null || slot.Root == null || !slot.Root.activeSelf)
{
continue;
}
float labelW = slot.Label != null
? Mathf.Max(MeasureTextWidth(slot.Label, fallbackLabelW), 18f)
: fallbackLabelW;
// Prefer preferredWidth; fall back to character estimate so long labels
// still widen the panel before Unity finishes a layout pass.
if (slot.Label != null && !string.IsNullOrEmpty(slot.Label.text)
&& labelW <= fallbackLabelW + 0.5f)
{
labelW = Mathf.Max(labelW, slot.Label.text.Length * 6.2f);
}
float slotW = TraitIcon + 2f + labelW;
x += slotW + 3f;
n++;
}
return n > 0 ? x - 3f : 0f;
}
private static float MeasureStatusesRowWidth() => MeasureChipRowWidth(_statusSlots, 28f);
private static float MeasureTraitsRowWidth() => MeasureChipRowWidth(_traitSlots, 28f);
/// <summary>Grow the dossier when chip rows need more width than the pre-pass estimate.</summary>
private static void WidenPanelForChipRow(float rowContentW)
{
if (rowContentW < 1f)
{
return;
}
float need = PadX * 2f + rowContentW;
if (need > _panelWidth + 0.5f)
{
_panelWidth = Mathf.Clamp(need, LiveMax + PadX * 2f, PanelWidthMax);
}
}
private static float MeasureTraitsBesideWidth()
{
float colW = 0f;
if (_traitSlots == null)
{
return 56f;
}
for (int i = 0; i < _traitSlots.Length; i++)
{
TraitSlot slot = _traitSlots[i];
if (slot == null || slot.Root == null || !slot.Root.activeSelf || slot.Label == null)
{
continue;
}
float labelW = Mathf.Max(MeasureTextWidth(slot.Label, 36f), 24f);
colW = Mathf.Max(colW, TraitIcon + 2f + labelW);
}
return colW > 1f ? colW : 56f;
}
private static float PlaceStatuses(float yFromTop, int statusCount)
{
if (_statusesRow == null)
@ -2416,23 +2829,7 @@ public static class WatchCaption
return 0f;
}
float colW = 0f;
for (int i = 0; i < _traitSlots.Length; i++)
{
TraitSlot slot = _traitSlots[i];
if (slot == null || slot.Root == null || !slot.Root.activeSelf || slot.Label == null)
{
continue;
}
float labelW = Mathf.Max(MeasureTextWidth(slot.Label, 36f), 24f);
colW = Mathf.Max(colW, TraitIcon + 2f + labelW);
}
if (colW < 1f)
{
colW = 56f;
}
float colW = MeasureTraitsBesideWidth();
RectTransform row = _traitsRow.GetComponent<RectTransform>();
row.anchorMin = new Vector2(0f, 1f);
@ -2569,6 +2966,7 @@ public static class WatchCaption
_sexIcon = null;
_nameText = null;
_reasonText = null;
_reasonOtherId = 0;
_levelIcon = null;
_levelValue = null;
_taskIcon = null;
@ -2633,8 +3031,15 @@ public static class WatchCaption
for (int i = 0; i < _historySlots.Length; i++)
{
GameObject slotGo = new GameObject("Hist" + i, typeof(RectTransform));
GameObject slotGo = new GameObject("Hist" + i, typeof(RectTransform), typeof(Image), typeof(Button));
slotGo.transform.SetParent(_historyCol.transform, false);
Image hit = slotGo.GetComponent<Image>();
hit.color = new Color(1f, 1f, 1f, 0.001f);
hit.raycastTarget = false;
Button button = slotGo.GetComponent<Button>();
button.transition = Selectable.Transition.ColorTint;
button.targetGraphic = hit;
button.interactable = false;
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", HistoryIcon);
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
label.color = HistoryTextColor;
@ -2643,7 +3048,15 @@ public static class WatchCaption
label.supportRichText = true;
label.resizeTextMinSize = 6;
label.resizeTextMaxSize = 8;
_historySlots[i] = new HistorySlot { Root = slotGo, Icon = icon, Label = label };
_historySlots[i] = new HistorySlot
{
Root = slotGo,
Icon = icon,
Label = label,
Button = button,
Hit = hit,
OtherId = 0
};
slotGo.SetActive(false);
}
@ -2651,8 +3064,11 @@ public static class WatchCaption
_statusesRow.transform.SetParent(_root.transform, false);
for (int i = 0; i < _statusSlots.Length; i++)
{
GameObject slotGo = new GameObject("Status" + i, typeof(RectTransform));
GameObject slotGo = new GameObject("Status" + i, typeof(RectTransform), typeof(Image));
slotGo.transform.SetParent(_statusesRow.transform, false);
Image hit = slotGo.GetComponent<Image>();
hit.color = new Color(1f, 1f, 1f, 0.001f);
hit.raycastTarget = true;
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon);
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
label.color = StatusNameColor;
@ -2660,7 +3076,9 @@ public static class WatchCaption
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.resizeTextMinSize = 6;
label.resizeTextMaxSize = 8;
_statusSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label };
DossierChipTip tip = slotGo.AddComponent<DossierChipTip>();
tip.enabled = false;
_statusSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label, Tip = tip };
slotGo.SetActive(false);
}
@ -2668,8 +3086,11 @@ public static class WatchCaption
_traitsRow.transform.SetParent(_root.transform, false);
for (int i = 0; i < _traitSlots.Length; i++)
{
GameObject slotGo = new GameObject("Trait" + i, typeof(RectTransform));
GameObject slotGo = new GameObject("Trait" + i, typeof(RectTransform), typeof(Image));
slotGo.transform.SetParent(_traitsRow.transform, false);
Image hit = slotGo.GetComponent<Image>();
hit.color = new Color(1f, 1f, 1f, 0.001f);
hit.raycastTarget = true;
Image icon = HudCanvas.MakeIcon(slotGo.transform, "Icon", TraitIcon);
Text label = HudCanvas.MakeText(slotGo.transform, "Label", "", 8);
label.color = TraitNameColor;
@ -2677,16 +3098,20 @@ public static class WatchCaption
label.horizontalOverflow = HorizontalWrapMode.Overflow;
label.resizeTextMinSize = 6;
label.resizeTextMaxSize = 8;
_traitSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label };
DossierChipTip tip = slotGo.AddComponent<DossierChipTip>();
tip.enabled = false;
_traitSlots[i] = new TraitSlot { Root = slotGo, Icon = icon, Label = label, Tip = tip };
slotGo.SetActive(false);
}
_reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 8);
_reasonText.color = ReasonColor;
_reasonText.supportRichText = true;
_reasonText.alignment = TextAnchor.UpperLeft;
_reasonText.horizontalOverflow = HorizontalWrapMode.Wrap;
_reasonText.verticalOverflow = VerticalWrapMode.Overflow;
_reasonText.resizeTextForBestFit = false;
WireReasonClick(0);
_speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize);
_sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize);

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.25.93",
"description": "AFK Idle Spectator (I) + Lore (L). Citizen job labels from live library discovery.",
"version": "0.26.1",
"description": "AFK Idle Spectator (I) + Lore (L). Fix Lore click camera follow (pause before focus).",
"GUID": "com.dazed.idlespectator"
}