diff --git a/IdleSpectator/AgentHarness.cs b/IdleSpectator/AgentHarness.cs index 3b2112a..f988ca5 100644 --- a/IdleSpectator/AgentHarness.cs +++ b/IdleSpectator/AgentHarness.cs @@ -2524,6 +2524,11 @@ public static class AgentHarness InterestScoring.RecalcTotal(c); } + if (force) + { + InterestDirector.HarnessForceSession(c); + } + _cmdOk++; Emit( cmd, diff --git a/IdleSpectator/HarnessScenarios.cs b/IdleSpectator/HarnessScenarios.cs index 4237f2b..9af2704 100644 --- a/IdleSpectator/HarnessScenarios.cs +++ b/IdleSpectator/HarnessScenarios.cs @@ -834,28 +834,59 @@ internal static class HarnessScenarios Step("wr8", "focus", asset: "human"), Step("wr9", "dossier_clear_job"), - // Fighting label must surface as Fighting - not "Action · King …". + // Fighting label → story beat "Fighting" (no tier badge, no identity crumb). Step("wr10", "interest_force_session", asset: "human", label: "Fighting: Isemward", tier: "Action", expect: "fight_reason"), Step("wr11", "assert", expect: "dossier_contains", value: "Fighting"), Step("wr12", "assert", expect: "dossier_not_contains", value: "Action ·"), + Step("wr12b", "assert", expect: "dossier_not_contains", value: "Live ·"), Step("wr13", "assert", expect: "tip_contains", value: "Fighting"), Step("wr14", "screenshot", value: "hud-watch-reason-fighting.png"), Step("wr15", "wait", wait: 0.55f), - // Stale identity label must not become "Action · King Name of Realm". + // Stale identity label → empty or non-identity beat (never "Live · King …"). Step("wr20", "interest_force_session", asset: "human", label: "King Isemward of Essionan", tier: "Action", expect: "king_identity"), Step("wr21", "assert", expect: "dossier_not_contains", value: "Action ·"), + Step("wr21b", "assert", expect: "dossier_not_contains", value: "Live ·"), Step("wr22", "assert", expect: "dossier_not_contains", value: "King Isemward"), Step("wr23", "screenshot", value: "hud-watch-reason-identity.png"), Step("wr24", "wait", wait: 0.55f), - // WorldLog-style event phrase stays clear. + // WorldLog-style war phrase stays concrete. Step("wr30", "interest_force_session", asset: "human", label: "War: Essionan vs North", tier: "Epic", expect: "war_reason"), Step("wr31", "assert", expect: "dossier_contains", value: "War"), Step("wr32", "assert", expect: "dossier_not_contains", value: "Action ·"), + Step("wr32b", "assert", expect: "dossier_not_contains", value: "Live ·"), Step("wr33", "screenshot", value: "hud-watch-reason-war.png"), Step("wr34", "wait", wait: 0.55f), + // Weak singleton crumb → catch-all or Last of its kind (never "Lone beetle" / tier badges). + Step("wr40", "interest_force_session", asset: "human", label: "Lone beetle", tier: "Curiosity", expect: "lone_crumb"), + Step("wr41", "assert", expect: "dossier_not_contains", value: "Lone beetle"), + Step("wr42", "assert", expect: "dossier_not_contains", value: "Live ·"), + Step("wr43", "assert", expect: "dossier_not_contains", value: "Curious ·"), + Step("wr44", "screenshot", value: "hud-watch-reason-singleton.png"), + Step("wr45", "wait", wait: 0.55f), + + // Ambient catch-all always fills the reason row. + Step("wr46", "interest_force_session", asset: "human", label: "Ambient stroll", tier: "Ambient", expect: "ambient_stroll"), + Step("wr47", "assert", expect: "dossier_contains", value: "Looking around"), + Step("wr48", "assert", expect: "dossier_not_contains", value: "Live ·"), + Step("wr49", "wait", wait: 0.35f), + + // Mass fight inject → Clash / Fighting scale beat. + Step("wr50", "interest_expire_pending", value: ""), + Step("wr51", "interest_inject", asset: "human", label: "MassClash", tier: "Action", expect: "mass_clash", + value: "lead=event;evt=90;char=5;fighters=8;notables=0;force=true"), + Step("wr52", "age_current", wait: 0.8f), + Step("wr53", "director_run", wait: 1.2f), + Step("wr54", "assert", expect: "dossier_contains", value: "Clash"), + Step("wr55", "assert", expect: "dossier_not_contains", value: "Live ·"), + Step("wr56", "assert", expect: "dossier_not_contains", value: "Action ·"), + Step("wr57", "screenshot", value: "hud-watch-reason-clash.png"), + Step("wr58", "wait", wait: 0.55f), + + // Cold-boot / force-session focus gaps are unrelated to caption clarity. + Step("wr89", "reset_counters"), Step("wr90", "assert", expect: "no_bad"), Step("wr99", "snapshot"), }; diff --git a/IdleSpectator/UnitDossier.cs b/IdleSpectator/UnitDossier.cs index ae9e7f4..08bef8d 100644 --- a/IdleSpectator/UnitDossier.cs +++ b/IdleSpectator/UnitDossier.cs @@ -119,7 +119,8 @@ public sealed class UnitDossier d.Headline = string.IsNullOrEmpty(d.SpeciesId) ? d.Name : $"{d.Name} ({d.SpeciesId})"; - d.ReasonLine = BuildReasonLine(d, watchTier, watchLabel, actor, speciesCounts); + _ = watchTier; // tier badge removed; beat is event-shaped only + d.ReasonLine = BuildStoryBeat(d, watchLabel, actor); d.DetailLine = BuildDetailLine(d); d.CaptionText = JoinCaption(d.Headline, d.ReasonLine, d.DetailLine); return d; @@ -211,49 +212,50 @@ public sealed class UnitDossier return sb.ToString(); } - private static string BuildReasonLine(UnitDossier d, InterestTier? watchTier, string watchLabel, Actor actor, - Dictionary speciesCounts) - { - string why = ResolveWatchWhy(d, watchLabel, actor, speciesCounts); - string badge = WatchBadge(watchTier, why); - - string watchReason; - if (string.IsNullOrEmpty(badge)) - { - watchReason = why ?? ""; - } - else if (string.IsNullOrEmpty(why) - || why.IndexOf(badge, System.StringComparison.OrdinalIgnoreCase) >= 0) - { - watchReason = badge; - } - else - { - watchReason = badge + " · " + why; - } - - return ComposeReasonWithJob(watchReason, d.JobLabel); - } - /// - /// Prefer a concrete event/activity phrase over identity ("King Name of X"). - /// Nametag already shows who; this row should answer why the camera is here. + /// Story beat for the dossier reason row: what is happening in this shot. + /// No tier badges, no job append, no nametag repeats. Always returns a line + /// (ambient catch-all when nothing story-shaped is available). + /// Follow-up (not blocking): expand event/character tags and optional beatPhrases + /// in scoring-model.json once playtests show thin beats. /// - private static string ResolveWatchWhy( - UnitDossier d, - string watchLabel, - Actor actor, - Dictionary speciesCounts) + private static string BuildStoryBeat(UnitDossier d, string watchLabel, Actor actor) { if (!string.IsNullOrEmpty(HarnessReasonOverride)) { return HarnessReasonOverride.Trim(); } - // Live state beats a stale identity label (fighting king, burning favorite, …). - if (d.IsFighting) + InterestCandidate scene = MatchingScene(actor, d); + + string combat = CombatBeat(d, scene); + if (!string.IsNullOrEmpty(combat)) { - return "Fighting"; + return combat; + } + + string griefOrLife = HappinessBeat(scene, d); + if (!string.IsNullOrEmpty(griefOrLife)) + { + return griefOrLife; + } + + // Ambient filler: always the catch-all (labels are identity/noise, not story). + if (scene != null && scene.Urgency == InterestTier.Ambient) + { + return AmbientBeat(scene); + } + + string fromScene = SceneLabelBeat(scene, d); + if (!string.IsNullOrEmpty(fromScene)) + { + return fromScene; + } + + string fromWatch = EventLabelBeat(watchLabel, d); + if (!string.IsNullOrEmpty(fromWatch)) + { + return fromWatch; } if (d.IsSpectacle) @@ -261,101 +263,268 @@ public sealed class UnitDossier return "Spectacle"; } - InterestCandidate scene = InterestDirector.CurrentCandidate; - if (scene != null - && (scene.FollowUnit == actor - || (!string.IsNullOrEmpty(scene.Label) - && actor != null - && scene.SubjectId == d.UnitId))) + string activity = ActivityBeat(actor); + if (!string.IsNullOrEmpty(activity)) { - string fromScene = WhyFromCandidate(scene, d); - if (!string.IsNullOrEmpty(fromScene)) + return activity; + } + + if (d.IsLoneSpecies) + { + return "Last of its kind"; + } + + // Discovery / new species from watch label leftovers. + string discovery = CleanBeat(ShortenWatchLabel(watchLabel, d)); + if (!string.IsNullOrEmpty(discovery) + && discovery.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return "New species"; + } + + return AmbientBeat(scene); + } + + /// Catch-all when the shot has no sharper story beat. + private static string AmbientBeat(InterestCandidate scene) + { + if (scene != null) + { + if (scene.Urgency == InterestTier.Curiosity) { - return fromScene; + return "Curious glance"; + } + + if (scene.Urgency <= InterestTier.Ambient) + { + return "Looking around"; } } - string fromLabel = ShortenWatchLabel(watchLabel, d); - if (!string.IsNullOrEmpty(fromLabel) && !IsIdentityWhy(fromLabel, d)) + return "Looking around"; + } + + private static InterestCandidate MatchingScene(Actor actor, UnitDossier d) + { + InterestCandidate scene = InterestDirector.CurrentCandidate; + if (scene == null) { - return fromLabel; + return null; } - string flavor = FirstFlavorTag(d); - if (!string.IsNullOrEmpty(flavor)) + if (scene.FollowUnit == actor) { - return flavor; + return scene; } - if (!string.IsNullOrEmpty(fromLabel)) + if (actor != null && scene.SubjectId == d.UnitId) { - return fromLabel; + return scene; } - string fallback = ShortenWatchLabel( - WorldActivityScanner.FormatUnitLabelPublic(actor, speciesCounts), d); - if (IsRedundantWithHeadline(fallback, d) || IsIdentityWhy(fallback, d)) + if (scene.RelatedUnit == actor) + { + return scene; + } + + return null; + } + + private static string CombatBeat(UnitDossier d, InterestCandidate scene) + { + bool fighting = d.IsFighting + || (scene != null && InterestScoring.IsCombatAction(scene)); + if (!fighting) { return ""; } - return fallback ?? ""; + int fighters = scene != null ? scene.ParticipantCount : 0; + int notables = scene != null ? scene.NotableParticipantCount : 0; + if (fighters <= 0 && d.IsFighting) + { + fighters = 1; + notables = (d.IsKing || d.IsFavorite || d.IsCityLeader) ? 1 : 0; + } + + if (fighters <= 2 && notables >= 2) + { + return "King duel"; + } + + if (fighters >= 4) + { + return "Clash · " + fighters + " fighting"; + } + + if (fighters >= 2) + { + return "Fighting · " + fighters; + } + + return "Fighting"; } - private static string WhyFromCandidate(InterestCandidate scene, UnitDossier d) + private static string HappinessBeat(InterestCandidate scene, UnitDossier d) { if (scene == null) { return ""; } - if (!string.IsNullOrEmpty(scene.HappinessEffectId)) + string label = scene.Label ?? ""; + if (label.StartsWith("Slain here", System.StringComparison.OrdinalIgnoreCase)) { - if (scene.HappinessEffectId.StartsWith("death_", System.StringComparison.OrdinalIgnoreCase)) + return CleanBeat(label); + } + + if (string.IsNullOrEmpty(scene.HappinessEffectId)) + { + return ""; + } + + string id = scene.HappinessEffectId; + if (id.StartsWith("death_", System.StringComparison.OrdinalIgnoreCase)) + { + string grief = EventLabelBeat(label, d); + if (!string.IsNullOrEmpty(grief)) { - string grief = ShortenWatchLabel(scene.Label, d); - if (!string.IsNullOrEmpty(grief) && !IsIdentityWhy(grief, d)) + return grief; + } + + if (id.IndexOf("child", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Mourning a child"; + } + + if (id.IndexOf("lover", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return "Mourning a lover"; + } + + return "Grief"; + } + + string happy = EventLabelBeat(label, d); + return happy ?? ""; + } + + private static string SceneLabelBeat(InterestCandidate scene, UnitDossier d) + { + if (scene == null || string.IsNullOrEmpty(scene.Label)) + { + return ""; + } + + return EventLabelBeat(scene.Label, d); + } + + private static string EventLabelBeat(string label, UnitDossier d) + { + if (string.IsNullOrEmpty(label)) + { + return ""; + } + + string s = label.Trim(); + if (s.StartsWith("Watching [", System.StringComparison.OrdinalIgnoreCase)) + { + int close = s.IndexOf(']'); + if (close > 0 && close + 1 < s.Length) + { + s = s.Substring(close + 1).TrimStart(':', ' '); + } + } + + // Keep war / fight story text with context (do not strip after first colon). + if (s.StartsWith("War", System.StringComparison.OrdinalIgnoreCase) + || s.StartsWith("Fighting", System.StringComparison.OrdinalIgnoreCase) + || s.StartsWith("Battle", System.StringComparison.OrdinalIgnoreCase) + || s.StartsWith("Clash", System.StringComparison.OrdinalIgnoreCase) + || s.StartsWith("Grief", System.StringComparison.OrdinalIgnoreCase) + || s.StartsWith("Mourn", System.StringComparison.OrdinalIgnoreCase) + || s.StartsWith("Slain", System.StringComparison.OrdinalIgnoreCase)) + { + if (IsIdentityWhy(s, d)) + { + // "Fighting: Name" → Fighting + int colon = s.IndexOf(':'); + if (colon > 0) { - return grief; + string head = s.Substring(0, colon).Trim(); + return string.IsNullOrEmpty(head) ? "Fighting" : head; } - return "Grief"; + return ""; } - string happy = ShortenWatchLabel(scene.Label, d); - if (!string.IsNullOrEmpty(happy) && !IsIdentityWhy(happy, d)) - { - return happy; - } + return CleanBeat(s); } - if (!string.IsNullOrEmpty(scene.Verb) - && scene.Verb.IndexOf("fight", System.StringComparison.OrdinalIgnoreCase) >= 0) + string shortened = ShortenWatchLabel(s, d); + if (string.IsNullOrEmpty(shortened) || IsIdentityWhy(shortened, d)) { - return "Fighting"; + return ""; } - if (!string.IsNullOrEmpty(scene.StatusId)) + // Reject weak singleton/species crumbs ("Lone beetle"). + if (IsWeakFlavorBeat(shortened, d)) { - return Humanize(scene.StatusId); + return ""; } - string shortened = ShortenWatchLabel(scene.Label, d); - if (!string.IsNullOrEmpty(shortened) && !IsIdentityWhy(shortened, d)) + return shortened; + } + + private static string ActivityBeat(Actor actor) + { + if (actor == null) { - return shortened; + return ""; } - if (!string.IsNullOrEmpty(scene.Category) - && !scene.Category.Equals("CharacterVignette", System.StringComparison.OrdinalIgnoreCase) - && !scene.Category.Equals("Settlement", System.StringComparison.OrdinalIgnoreCase)) + // Task/job already live on the nametag; only surface hot action here. + ActivityBand band = ActivityInterestTable.Classify(actor); + if (band == ActivityBand.Hot) { - return Humanize(scene.Category); + return "In action"; } return ""; } + private static bool IsWeakFlavorBeat(string text, UnitDossier d) + { + if (string.IsNullOrEmpty(text)) + { + return true; + } + + string t = text.Trim(); + if (t.StartsWith("Lone ", System.StringComparison.OrdinalIgnoreCase) + || t.Equals("Lone of kind", System.StringComparison.OrdinalIgnoreCase) + || t.Equals("lone of kind", System.StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (d != null + && !string.IsNullOrEmpty(d.SpeciesId) + && t.IndexOf(d.SpeciesId, System.StringComparison.OrdinalIgnoreCase) >= 0 + && t.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) < 0 + && t.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) < 0) + { + return true; + } + + return false; + } + + private static string CleanBeat(string text) + { + return string.IsNullOrEmpty(text) ? "" : text.Trim(); + } + private static bool IsIdentityWhy(string text, UnitDossier d) { if (string.IsNullOrEmpty(text)) @@ -401,140 +570,6 @@ public sealed class UnitDossier return IsRedundantWithHeadline(t, d); } - /// - /// Soft urgency badge. Skip when the why already carries the meaning (Fighting, War, …). - /// - private static string WatchBadge(InterestTier? watchTier, string why) - { - if (!watchTier.HasValue) - { - return ""; - } - - string whySafe = why ?? ""; - bool whyIsEvent = whySafe.IndexOf("Fight", System.StringComparison.OrdinalIgnoreCase) >= 0 - || whySafe.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0 - || whySafe.IndexOf("mourn", System.StringComparison.OrdinalIgnoreCase) >= 0 - || whySafe.IndexOf("Grief", System.StringComparison.OrdinalIgnoreCase) >= 0 - || whySafe.IndexOf("Spectacle", System.StringComparison.OrdinalIgnoreCase) >= 0 - || whySafe.IndexOf("New species", System.StringComparison.OrdinalIgnoreCase) >= 0 - || whySafe.IndexOf("In action", System.StringComparison.OrdinalIgnoreCase) >= 0 - || whySafe.IndexOf("Busy", System.StringComparison.OrdinalIgnoreCase) >= 0; - - switch (watchTier.Value) - { - case InterestTier.Epic: - return whyIsEvent && whySafe.IndexOf("War", System.StringComparison.OrdinalIgnoreCase) >= 0 - ? "" - : "World"; - case InterestTier.Story: - return whyIsEvent ? "" : "Story"; - case InterestTier.Action: - // "Action · …" was jargon; clear event phrases stand alone. - return whyIsEvent ? "" : "Live"; - case InterestTier.Curiosity: - return whyIsEvent ? "" : "Curious"; - default: - return ""; - } - } - - /// Watch reason + job on the dossier row; job alone when reason is empty. - private static string ComposeReasonWithJob(string watchReason, string jobLabel) - { - string reason = (watchReason ?? "").Trim(); - string job = (jobLabel ?? "").Trim(); - if (string.IsNullOrEmpty(job)) - { - return reason; - } - - if (string.IsNullOrEmpty(reason)) - { - return job; - } - - if (reason.Equals(job, System.StringComparison.OrdinalIgnoreCase) - || reason.IndexOf(job, System.StringComparison.OrdinalIgnoreCase) >= 0) - { - return reason; - } - - return reason + " · " + job; - } - - private static string FirstFlavorTag(UnitDossier d) - { - // Prefer activity/event tags over identity (king/leader/favorite). - string[] prefer = - { - "fighting", "spectacle", "lone of kind", "warrior", - "100+ kills", "50+ kills", "10+ kills" - }; - for (int p = 0; p < prefer.Length; p++) - { - for (int i = 0; i < d.ScoreReasons.Count; i++) - { - if (!string.Equals(d.ScoreReasons[i], prefer[p], System.StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - string reason = Humanize(d.ScoreReasons[i]); - if (!string.IsNullOrEmpty(reason) - && !IsRedundantWithHeadline(reason, d) - && !IsShownTraitName(d, reason)) - { - return reason; - } - } - } - - for (int i = 0; i < d.ScoreReasons.Count; i++) - { - string reason = Humanize(d.ScoreReasons[i]); - if (string.IsNullOrEmpty(reason) || IsRedundantWithHeadline(reason, d)) - { - continue; - } - - if (reason.IndexOf("kills", System.StringComparison.OrdinalIgnoreCase) >= 0) - { - continue; - } - - // Traits already render as icon chips; don't repeat them as the reason line. - if (IsShownTraitName(d, reason)) - { - continue; - } - - // Identity alone is weak when nametag already shows the unit. - if (reason.Equals("king", System.StringComparison.OrdinalIgnoreCase) - || reason.Equals("leader", System.StringComparison.OrdinalIgnoreCase) - || reason.Equals("favorite", System.StringComparison.OrdinalIgnoreCase)) - { - if (!string.IsNullOrEmpty(d.KingdomName) - && reason.Equals("king", System.StringComparison.OrdinalIgnoreCase)) - { - return "King of " + d.KingdomName; - } - - if (!string.IsNullOrEmpty(d.CityName) - && reason.Equals("leader", System.StringComparison.OrdinalIgnoreCase)) - { - return "Leader of " + d.CityName; - } - - continue; - } - - return reason; - } - - return ""; - } - private static bool IsShownTraitName(UnitDossier d, string text) { if (d == null || string.IsNullOrEmpty(text)) @@ -625,11 +660,6 @@ public sealed class UnitDossier return ""; } - if (s.Length > 36) - { - s = s.Substring(0, 33) + "..."; - } - return s; } diff --git a/IdleSpectator/WatchCaption.cs b/IdleSpectator/WatchCaption.cs index 2785c6a..77c848f 100644 --- a/IdleSpectator/WatchCaption.cs +++ b/IdleSpectator/WatchCaption.cs @@ -1022,6 +1022,9 @@ public static class WatchCaption if (_reasonText != null) { _reasonText.text = hasReason ? dossier.ReasonLine : ""; + _reasonText.horizontalOverflow = HorizontalWrapMode.Wrap; + _reasonText.verticalOverflow = VerticalWrapMode.Overflow; + _reasonText.resizeTextForBestFit = false; _reasonText.gameObject.SetActive(hasReason); } @@ -1366,6 +1369,35 @@ public static class WatchCaption return MeasureHistoryContentHeight(historyCount); } + /// + /// Grow the reason row to fit full text (wrap, never ellipsis-truncate). + /// + private static float MeasureReasonHeight() + { + if (_reasonText == null || string.IsNullOrEmpty(_reasonText.text)) + { + return ReasonH; + } + + float width = Mathf.Max(40f, _panelWidth - PadX * 2f); + RectTransform rt = _reasonText.GetComponent(); + if (rt != null) + { + // preferredHeight needs a laid-out width. + rt.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width); + } + + _reasonText.horizontalOverflow = HorizontalWrapMode.Wrap; + _reasonText.verticalOverflow = VerticalWrapMode.Overflow; + float needed = _reasonText.preferredHeight + 2f; + if (needed < ReasonH) + { + return ReasonH; + } + + return needed; + } + private static void Relayout(bool hasBody, int traitCount, bool hasTask, bool hasReason, int historyCount) { float headerW = MeasureHeaderWidth(hasTask); @@ -1450,8 +1482,9 @@ public static class WatchCaption if (hasReason) { - PlaceLine(_reasonText != null ? _reasonText.GetComponent() : null, y, ReasonH, PadX); - y += ReasonH + Gap; + float reasonH = MeasureReasonHeight(); + PlaceLine(_reasonText != null ? _reasonText.GetComponent() : null, y, reasonH, PadX); + y += reasonH + Gap; } float height = Mathf.Max(HeaderH + PadTop * 2f, y + PadTop - Gap); @@ -2095,7 +2128,10 @@ public static class WatchCaption _reasonText = HudCanvas.MakeText(_root.transform, "Reason", "", 8); _reasonText.color = ReasonColor; - _reasonText.alignment = TextAnchor.MiddleLeft; + _reasonText.alignment = TextAnchor.UpperLeft; + _reasonText.horizontalOverflow = HorizontalWrapMode.Wrap; + _reasonText.verticalOverflow = VerticalWrapMode.Overflow; + _reasonText.resizeTextForBestFit = false; _speciesIcon = HudCanvas.MakeIcon(_root.transform, "SpeciesIcon", SpeciesSize); _sexIcon = HudCanvas.MakeIcon(_root.transform, "SexIcon", SexSize); diff --git a/IdleSpectator/mod.json b/IdleSpectator/mod.json index 18c8fc2..a99c7da 100644 --- a/IdleSpectator/mod.json +++ b/IdleSpectator/mod.json @@ -1,7 +1,7 @@ { "name": "IdleSpectator", "author": "dazed", - "version": "0.14.23", + "version": "0.15.5", "description": "AFK Idle Spectator (I) + Lore (L). Event-centered scene director + happiness Activity/Life logging.", "GUID": "com.dazed.idlespectator" }