Implement favorites filtering for Fallen characters in Chronicle and HUD. Added functionality to track and manage favorite subjects across death events, enhancing user experience. Updated test scenarios to validate new favorite handling and interactions.

This commit is contained in:
DazedAnon 2026-07-14 22:36:06 -05:00
parent 0b23a15618
commit 94de8a3b5b
6 changed files with 266 additions and 54 deletions

View file

@ -453,12 +453,20 @@ public static class AgentHarness
case "lore_favorites":
{
Chronicle.ShowHud();
ChronicleHud.SetTab(ChronicleHud.LoreTab.Living);
string v = (cmd.value ?? "true").Trim().ToLowerInvariant();
bool on = v != "false" && v != "0" && v != "off";
ChronicleHud.SetFavoritesOnly(on);
// Filter is a list view - leave detail if we were in one.
if (ChronicleHud.DetailUnitId != 0)
{
ChronicleHud.ClearUnitDetail();
}
_cmdOk++;
Emit(cmd, ok: true, detail: $"favoritesOnly={ChronicleHud.FavoritesOnly}");
Emit(
cmd,
ok: true,
detail: $"favoritesOnly={ChronicleHud.FavoritesOnly} tab={ChronicleHud.ActiveTab}");
break;
}
@ -549,34 +557,44 @@ public static class AgentHarness
long killerId = 0;
Vector3 deathPos = Vector3.zero;
string mode = (cmd.tier ?? "").Trim().ToLowerInvariant();
bool favorite = mode.IndexOf("fav", System.StringComparison.Ordinal) >= 0;
Actor focus = MoveCamera.hasFocusUnit() ? MoveCamera._focus_unit : null;
if (mode == "killer" && focus != null && focus.isAlive())
if (mode.IndexOf("killer", System.StringComparison.Ordinal) >= 0
&& focus != null
&& focus.isAlive())
{
killerId = focus.getID();
deathPos = focus.current_position;
}
else if (mode == "place")
else if (mode.IndexOf("place", System.StringComparison.Ordinal) >= 0
|| favorite
|| string.IsNullOrEmpty(mode))
{
if (focus != null)
if (mode.IndexOf("place", System.StringComparison.Ordinal) >= 0 || favorite)
{
deathPos = focus.current_position;
}
else if (MoveCamera.instance != null)
{
deathPos = MoveCamera.instance.transform.position;
deathPos.z = 0f;
}
if (focus != null)
{
deathPos = focus.current_position;
}
else if (MoveCamera.instance != null)
{
deathPos = MoveCamera.instance.transform.position;
deathPos.z = 0f;
}
if (deathPos == Vector3.zero)
{
deathPos = new Vector3(48f, 48f, 0f);
if (deathPos == Vector3.zero)
{
deathPos = new Vector3(48f, 48f, 0f);
}
}
}
long id = Chronicle.ForceOrphanHistory(
name, species, n, prefix, attackType, killerId, deathPos);
name, species, n, prefix, attackType, killerId, deathPos, favorite);
DeathManner manner = Chronicle.LatestDeathMannerFor(id);
bool ok = id != 0 && Chronicle.HistoryCountFor(id) >= n;
bool ok = id != 0
&& Chronicle.HistoryCountFor(id) >= n
&& (!favorite || Chronicle.IsFavoriteSubject(id));
if (ok)
{
_cmdOk++;
@ -590,7 +608,7 @@ public static class AgentHarness
cmd,
ok,
detail:
$"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n} manner={manner} killer={killerId} mode={mode}");
$"orphanId={id} hist={Chronicle.HistoryCountFor(id)} want={n} manner={manner} killer={killerId} mode={mode} fav={Chronicle.IsFavoriteSubject(id)}");
break;
}
@ -1742,13 +1760,36 @@ public static class AgentHarness
bool fav = false;
try
{
fav = unit != null && unit.isFavorite();
fav = unit != null && unit.isAlive() && unit.isFavorite();
}
catch
{
// ignore
}
if (!fav)
{
long id = WatchCaption.CurrentUnitId;
if (id == 0)
{
id = ChronicleHud.DetailUnitId;
}
if (id == 0 && unit != null)
{
try
{
id = unit.getID();
}
catch
{
id = 0;
}
}
fav = Chronicle.IsFavoriteSubject(id);
}
string wantRaw = (cmd.value ?? "true").Trim().ToLowerInvariant();
bool want = wantRaw != "false" && wantRaw != "0" && wantRaw != "off";
pass = fav == want;

View file

@ -151,6 +151,7 @@ public static class Chronicle
private static readonly HashSet<string> LoverPairKeys = new HashSet<string>();
private static readonly HashSet<string> FriendPairKeys = new HashSet<string>();
private static readonly HashSet<long> DeathLogged = new HashSet<long>();
private static readonly HashSet<long> FavoriteIds = new HashSet<long>();
private static readonly Dictionary<long, UnitDossier> FinalDossiers =
new Dictionary<long, UnitDossier>();
private static readonly HashSet<string> LandmarkDedupe = new HashSet<string>();
@ -244,6 +245,82 @@ public static class Chronicle
public static int FinalDossierCount => FinalDossiers.Count;
public static bool IsFavoriteSubject(long unitId)
{
return unitId != 0 && FavoriteIds.Contains(unitId);
}
/// <summary>Track favorites across death so Fallen can filter like Living.</summary>
public static void SetFavoriteSubject(long unitId, bool favorite)
{
if (unitId == 0)
{
return;
}
bool had = FavoriteIds.Contains(unitId);
if (favorite == had)
{
if (FinalDossiers.TryGetValue(unitId, out UnitDossier existing) && existing != null)
{
existing.IsFavorite = favorite;
}
return;
}
if (favorite)
{
FavoriteIds.Add(unitId);
}
else
{
FavoriteIds.Remove(unitId);
}
if (FinalDossiers.TryGetValue(unitId, out UnitDossier dossier) && dossier != null)
{
dossier.IsFavorite = favorite;
}
BumpRevision();
}
public static void SyncFavoriteFromActor(Actor actor)
{
if (actor == null)
{
return;
}
long id = 0;
try
{
id = actor.getID();
}
catch
{
return;
}
if (id == 0)
{
return;
}
bool fav = false;
try
{
fav = actor.isAlive() && actor.isFavorite();
}
catch
{
fav = false;
}
SetFavoriteSubject(id, fav);
}
/// <summary>Harness / tuning: change subject cap and prune immediately.</summary>
public static void SetMaxHistorySubjects(int max)
{
@ -534,6 +611,7 @@ public static class Chronicle
LoverPairKeys.Clear();
FriendPairKeys.Clear();
DeathLogged.Clear();
FavoriteIds.Clear();
FinalDossiers.Clear();
LandmarkDedupe.Clear();
LandmarksPerAge.Clear();
@ -587,7 +665,8 @@ public static class Chronicle
public static void ShowHud()
{
EnsureWindow();
if (ChronicleHud.DetailUnitId == 0)
// Default into Living only when opening from World with no detail pinned.
if (ChronicleHud.DetailUnitId == 0 && ChronicleHud.ActiveTab == ChronicleHud.LoreTab.World)
{
ChronicleHud.SetTab(ChronicleHud.LoreTab.Living);
}
@ -706,6 +785,7 @@ public static class Chronicle
}
NoteFocusId(id);
SyncFavoriteFromActor(actor);
}
private static void NoteFocusId(long id)
@ -867,6 +947,15 @@ public static class Chronicle
return;
}
if (fav)
{
FavoriteIds.Add(id);
}
else
{
FavoriteIds.Remove(id);
}
string name = SafeName(actor);
string species = SpeciesOf(actor);
if (!string.IsNullOrEmpty(needle))
@ -982,7 +1071,13 @@ public static class Chronicle
if (favoritesOnly)
{
continue;
if (!IsFavoriteSubject(id)
&& !(FinalDossiers.TryGetValue(id, out UnitDossier favDossier)
&& favDossier != null
&& favDossier.IsFavorite))
{
continue;
}
}
IReadOnlyList<ChronicleEntry> snap = SnapshotForSubject(id);
@ -1003,13 +1098,21 @@ public static class Chronicle
}
}
bool fallenFav = IsFavoriteSubject(id);
if (!fallenFav
&& FinalDossiers.TryGetValue(id, out UnitDossier archived)
&& archived != null)
{
fallenFav = archived.IsFavorite;
}
byId[id] = new ChronicleCharacterSummary
{
UnitId = id,
Name = name,
SpeciesId = species,
HistoryCount = snap.Count,
IsFavorite = false,
IsFavorite = fallenFav,
IsAlive = false,
IsRecent = false,
DeathManner = LatestDeathMannerFor(id),
@ -1021,6 +1124,12 @@ public static class Chronicle
var list = new List<ChronicleCharacterSummary>(byId.Values);
list.Sort((a, b) =>
{
// Favorites first on both Living and Fallen.
if (a.IsFavorite != b.IsFavorite)
{
return a.IsFavorite ? -1 : 1;
}
if (wantFallen || (!a.IsAlive && !b.IsAlive))
{
// Fallen: most recent event first.
@ -1037,12 +1146,7 @@ public static class Chronicle
return string.Compare(a.Title, b.Title, System.StringComparison.OrdinalIgnoreCase);
}
// Living: favorites, recently watched, event count, name.
if (a.IsFavorite != b.IsFavorite)
{
return a.IsFavorite ? -1 : 1;
}
// Living: recently watched, event count, name.
int ra = a.IsRecent && a.RecentIndex >= 0 ? a.RecentIndex : int.MaxValue;
int rb = b.IsRecent && b.RecentIndex >= 0 ? b.RecentIndex : int.MaxValue;
if (ra != rb)
@ -1270,7 +1374,8 @@ public static class Chronicle
string labelPrefix = "Fallen",
AttackType attackType = AttackType.Age,
long killerId = 0,
Vector3 deathPos = default)
Vector3 deathPos = default,
bool favorite = false)
{
count = Mathf.Clamp(count, 1, MaxHistoryPerUnit);
long id;
@ -1362,7 +1467,14 @@ public static class Chronicle
BumpRevision();
FinalDossiers[id] = UnitDossier.FromFallenArchive(id, who, sp, manner);
UnitDossier archived = UnitDossier.FromFallenArchive(id, who, sp, manner);
archived.IsFavorite = favorite;
FinalDossiers[id] = archived;
if (favorite)
{
FavoriteIds.Add(id);
}
PruneFinalDossiers();
return id;
}
@ -1615,6 +1727,11 @@ public static class Chronicle
}
FinalDossiers[id] = dossier;
if (dossier.IsFavorite)
{
FavoriteIds.Add(id);
}
PruneFinalDossiers();
}
@ -1946,6 +2063,7 @@ public static class Chronicle
Histories.Remove(id);
DeathLogged.Remove(id);
FinalDossiers.Remove(id);
FavoriteIds.Remove(id);
need--;
}
}

View file

@ -848,20 +848,17 @@ public static class ChronicleHud
if (_favFilterBtn != null)
{
// Favorites filter is Living-only.
_favFilterBtn.gameObject.SetActive(_tab == LoreTab.Living && !detail);
_favFilterBtn.gameObject.SetActive(chars && !detail);
}
if (_refreshBtn != null)
{
_refreshBtn.gameObject.SetActive(chars && !detail);
// On Fallen (no fav), park refresh on the far right.
RectTransform refreshRt = _refreshBtn.GetComponent<RectTransform>();
if (refreshRt != null)
{
refreshRt.anchoredPosition = _tab == LoreTab.Living
? new Vector2(-19f, 0f)
: Vector2.zero;
// Search | Refresh | Fav on both Living and Fallen.
refreshRt.anchoredPosition = new Vector2(-19f, 0f);
}
}
@ -1165,7 +1162,7 @@ public static class ChronicleHud
Chronicle.CharacterListScope scope = _tab == LoreTab.Fallen
? Chronicle.CharacterListScope.Fallen
: Chronicle.CharacterListScope.Living;
bool favOnly = scope == Chronicle.CharacterListScope.Living && _favoritesOnly;
bool favOnly = _favoritesOnly;
List<ChronicleCharacterSummary> list = Chronicle.ListCharacters(_searchText, favOnly, scope: scope);
_lastCharFingerprint = CharacterFingerprint();
_builtAtRevision = Chronicle.HistoryRevision;
@ -1175,7 +1172,7 @@ public static class ChronicleHud
{
if (scope == Chronicle.CharacterListScope.Fallen)
{
AddEmpty("No fallen yet");
AddEmpty(favOnly ? "No favorite fallen" : "No fallen yet");
}
else
{
@ -1197,9 +1194,7 @@ public static class ChronicleHud
continue;
}
if (scope == Chronicle.CharacterListScope.Living
&& (favOnly || row.IsFavorite)
&& !wroteFavoritesHeader)
if ((favOnly || row.IsFavorite) && !wroteFavoritesHeader)
{
AddSection("Favorites");
wroteFavoritesHeader = true;

View file

@ -689,6 +689,21 @@ internal static class HarnessScenarios
Step("ch18ft5f", "lore_refresh"),
Step("ch18ft5g", "assert", expect: "lore_list_stale", value: "false"),
Step("ch18ft5h", "assert", expect: "fallen_list_top", value: "FreshFallen"),
// Favorite filter on Fallen (same star control as Living).
Step(
"ch18ft5i",
"chronicle_orphan",
label: "FavFallen",
asset: "human",
value: "Fav",
count: 1,
expect: "Age",
tier: "place+fav"),
Step("ch18ft5j", "lore_favorites", value: "true"),
Step("ch18ft5k", "lore_refresh"),
Step("ch18ft5l", "assert", expect: "fallen_list_top", value: "FavFallen"),
Step("ch18ft5m", "lore_favorites", value: "false"),
Step("ch18ft5n", "lore_refresh"),
Step("ch18ft6", "screenshot", value: "hud-lore-fallen-tab.png"),
Step("ch18ft7", "wait", wait: 0.55f),
Step("ch18q12", "lore_tab", value: "world"),

View file

@ -224,23 +224,45 @@ public static class WatchCaption
}
}
if (actor == null || !actor.isAlive())
if (actor != null && actor.isAlive())
{
try
{
actor.switchFavorite();
}
catch
{
return;
}
Chronicle.SyncFavoriteFromActor(actor);
RefreshFavoriteVisual(actor);
LogService.LogInfo(
$"[IdleSpectator] Favorite toggled name={SafeActorName(actor)} favorite={actor.isFavorite()}");
return;
}
// Fallen / archive: track in Chronicle so the Fallen filter stays consistent.
long id = _current != null ? _current.UnitId : 0;
if (id == 0)
{
id = Chronicle.CurrentHistorySubjectId();
}
if (id == 0)
{
return;
}
try
bool next = !Chronicle.IsFavoriteSubject(id);
Chronicle.SetFavoriteSubject(id, next);
if (_current != null && _current.UnitId == id)
{
actor.switchFavorite();
}
catch
{
return;
_current.IsFavorite = next;
}
RefreshFavoriteVisual(actor);
LogService.LogInfo(
$"[IdleSpectator] Favorite toggled name={SafeActorName(actor)} favorite={actor.isFavorite()}");
RefreshFavoriteVisual(null);
LogService.LogInfo($"[IdleSpectator] Favorite toggled fallen id={id} favorite={next}");
}
public static void PinWhilePaused()
@ -1266,13 +1288,34 @@ public static class WatchCaption
bool fav = false;
try
{
fav = actor != null && actor.isAlive() && actor.isFavorite();
if (actor != null && actor.isAlive() && actor.isFavorite())
{
fav = true;
}
}
catch
{
fav = false;
}
if (!fav)
{
long id = _current != null ? _current.UnitId : 0;
if (id == 0 && actor != null)
{
try
{
id = actor.getID();
}
catch
{
id = 0;
}
}
fav = Chronicle.IsFavoriteSubject(id) || (_current != null && _current.IsFavorite);
}
HudIcons.Apply(_favoriteIcon, HudIcons.Favorite());
_favoriteIcon.color = fav
? new Color(1f, 0.85f, 0.25f, 1f)

View file

@ -1,7 +1,7 @@
{
"name": "IdleSpectator",
"author": "dazed",
"version": "0.12.22",
"description": "AFK Idle Spectator (I) + Lore (L). Subject soft cap 100k.",
"version": "0.12.24",
"description": "AFK Idle Spectator (I) + Lore (L). Fallen favorites filter.",
"GUID": "com.dazed.idlespectator"
}