diff --git a/src/Aetherphone.Tests/RaceWatchTests.cs b/src/Aetherphone.Tests/RaceWatchTests.cs new file mode 100644 index 000000000..a3d8928fc --- /dev/null +++ b/src/Aetherphone.Tests/RaceWatchTests.cs @@ -0,0 +1,137 @@ +using Aetherphone.Core.Game; +using Xunit; + +namespace Aetherphone.Tests; + +public sealed class RaceWatchTests +{ + private const byte HyurRace = 1; + private const byte Unpopulated = 0; + private const int CustomizeLength = 26; + + [Fact] + public void StartsUnknown_SoNothingIsReportedBeforeACharacterLoads() + { + var watch = new RaceWatch(); + + Assert.Null(watch.Race); + Assert.Null(watch.IsLalafell); + } + + [Fact] + public void Observe_RecordsTheRaceItRead() + { + var watch = new RaceWatch(); + + watch.Observe(Customize(HyurRace)); + + Assert.Equal(HyurRace, watch.Race); + } + + [Fact] + public void Observe_RecordsALalafell() + { + var watch = new RaceWatch(); + + watch.Observe(Customize(RaceWatch.LalafellRaceId)); + + Assert.Equal(RaceWatch.LalafellRaceId, watch.Race); + Assert.True(watch.IsLalafell); + } + + [Fact] + public void Observe_RecordsANonLalafell() + { + var watch = new RaceWatch(); + + watch.Observe(Customize(HyurRace)); + + Assert.False(watch.IsLalafell); + } + + [Fact] + public void Observe_LeavesTheAnswerUnknownWhenThereIsNoCustomizeData() + { + var watch = new RaceWatch(); + + watch.Observe([]); + + Assert.Null(watch.Race); + Assert.Null(watch.IsLalafell); + } + + [Fact] + public void Observe_TreatsAnUnpopulatedRaceByteAsNoAnswer() + { + var watch = new RaceWatch(); + + watch.Observe(Customize(Unpopulated)); + + Assert.Null(watch.Race); + Assert.Null(watch.IsLalafell); + } + + [Fact] + public void AnUnpopulatedReadDoesNotOverwriteAKnownAnswer() + { + var watch = new RaceWatch(); + watch.Observe(Customize(RaceWatch.LalafellRaceId)); + + watch.Observe(Customize(Unpopulated)); + + Assert.True(watch.IsLalafell); + } + + [Fact] + public void AFailedReadDoesNotEraseAnAnswerWeAlreadyHave() + { + var watch = new RaceWatch(); + watch.Observe(Customize(RaceWatch.LalafellRaceId)); + + watch.Observe([]); + + Assert.True(watch.IsLalafell); + } + + [Fact] + public void Observe_TracksARaceChangeRatherThanLatchingTheFirstRead() + { + var watch = new RaceWatch(); + watch.Observe(Customize(HyurRace)); + + watch.Observe(Customize(RaceWatch.LalafellRaceId)); + + Assert.True(watch.IsLalafell); + } + + [Fact] + public void Forget_ReturnsToUnknownWhenTheCharacterGoesAway() + { + var watch = new RaceWatch(); + watch.Observe(Customize(RaceWatch.LalafellRaceId)); + + watch.Forget(); + + Assert.Null(watch.Race); + Assert.Null(watch.IsLalafell); + } + + [Fact] + public void ObserveAfterForget_DoesNotKeepThePreviousCharactersAnswer() + { + var watch = new RaceWatch(); + watch.Observe(Customize(RaceWatch.LalafellRaceId)); + watch.Forget(); + + watch.Observe(Customize(HyurRace)); + + Assert.False(watch.IsLalafell); + } + + private static byte[] Customize(byte race) + { + var customize = new byte[CustomizeLength]; + customize[RaceWatch.RaceIndex] = race; + return customize; + } +} diff --git a/src/Aetherphone/Apps/Velvet/VelvetShell.cs b/src/Aetherphone/Apps/Velvet/VelvetShell.cs index 7b1d7267c..735a8c649 100644 --- a/src/Aetherphone/Apps/Velvet/VelvetShell.cs +++ b/src/Aetherphone/Apps/Velvet/VelvetShell.cs @@ -22,7 +22,6 @@ using Aetherphone.Core.Wallpapers; using Aetherphone.Windows.Components; using Dalamud.Bindings.ImGui; -using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Interface; using Dalamud.Interface.Utility; @@ -31,6 +30,7 @@ namespace Aetherphone.Apps.Velvet; internal sealed partial class VelvetShell : IPhoneApp { private const float HeartbeatSeconds = 45f; + private const long RaceProbeIntervalMilliseconds = 1000; private readonly VelvetStore store; private readonly StoryPresenter stories; @@ -66,11 +66,13 @@ internal sealed partial class VelvetShell : IPhoneApp private readonly RouterDraw drawView; private readonly Action back; + private readonly RaceWatch race = new(); + private readonly FrameworkTicker raceTicker; + private PhoneTheme theme = PhoneTheme.Default; private INavigator navigation = null!; private VelvetPage activeTab = VelvetPage.Discover; private float sinceHeartbeat = HeartbeatSeconds; - private bool cachedLalafell; public VelvetShell(AethernetSession session, AethernetApi net, LodestoneService lodestone, Configuration configuration, PhotoLibrary library, HttpService http, RemoteImageCache images, @@ -109,6 +111,8 @@ public VelvetShell(AethernetSession session, AethernetApi net, LodestoneService drawView = DrawView; back = () => router.Pop(); threadView = new ThreadView(this); + raceTicker = new FrameworkTicker(Plugin.Framework, RaceProbeIntervalMilliseconds, ProbeRace, + installer.Gate(Id)); } public string Id => "velvet"; @@ -206,7 +210,7 @@ public void Draw(in PhoneContext context) return; } - if (IsLalafellCharacter() || store.AccessBlocked) + if (Unavailable) { TourHolds.Hold(Id); store.EnsureMe(); @@ -263,6 +267,7 @@ public void Draw(in PhoneContext context) public void Dispose() { + raceTicker.Dispose(); threadView.Dispose(); stories.Dispose(); store.Dispose(); @@ -272,34 +277,38 @@ public void Dispose() configuration.VelvetAcknowledgedGate && configuration.VelvetAcknowledgedGateVersion >= Configuration.VelvetGateVersion; + private bool Unavailable => race.IsLalafell is not false || store.AccessBlocked; + private void TickHeartbeat() { sinceHeartbeat += ImGui.GetIO().DeltaTime; - if (sinceHeartbeat >= HeartbeatSeconds) + if (sinceHeartbeat < HeartbeatSeconds) + { + return; + } + + if (race.IsLalafell is not { } isLalafell) { - sinceHeartbeat = 0f; - store.Heartbeat(SocialRegion.EffectiveCode(configuration, gameData), IsLalafellCharacter()); + return; } + + sinceHeartbeat = 0f; + store.Heartbeat(SocialRegion.EffectiveCode(configuration, gameData), isLalafell); } - private bool IsLalafellCharacter() + private void ProbeRace() { - const byte lalafellRaceId = 3; - if (!Plugin.Framework.IsInFrameworkUpdateThread) + if (!Plugin.ClientState.IsLoggedIn) { - return cachedLalafell; + race.Forget(); + return; } var local = gameData.LocalPlayer; - if (local is null) + if (local is not null) { - return cachedLalafell; + race.Observe(local.Customize); } - - var customize = local.Customize; - var raceIndex = (int)CustomizeIndex.Race; - cachedLalafell = customize.Length > raceIndex && customize[raceIndex] == lalafellRaceId; - return cachedLalafell; } private void DrawView(VelvetView view, Rect area, int depth) diff --git a/src/Aetherphone/Core/Game/RaceWatch.cs b/src/Aetherphone/Core/Game/RaceWatch.cs new file mode 100644 index 000000000..d57926b68 --- /dev/null +++ b/src/Aetherphone/Core/Game/RaceWatch.cs @@ -0,0 +1,35 @@ +using Dalamud.Game.ClientState.Objects.Enums; + +namespace Aetherphone.Core.Game; + +internal sealed class RaceWatch +{ + internal const int RaceIndex = (int)CustomizeIndex.Race; + internal const byte LalafellRaceId = 3; + + private const byte UnknownRaceId = 0; + + private volatile byte race = UnknownRaceId; + + public byte? Race => race == UnknownRaceId ? null : race; + + public bool? IsLalafell => Race is { } known ? known == LalafellRaceId : null; + + public void Forget() => race = UnknownRaceId; + + public void Observe(ReadOnlySpan customize) + { + if (customize.Length <= RaceIndex) + { + return; + } + + var observed = customize[RaceIndex]; + if (observed == UnknownRaceId) + { + return; + } + + race = observed; + } +}