From 6cb9c009c967f089801c7d2885860d105d7b2741 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 2 Nov 2026 13:51:02 -0500 Subject: [PATCH] fix: the practice HUD comes back after a reconnect or a respawned layout --- apps/utility-sw/src/Hud/HudKit.cs | 2 +- apps/utility-sw/src/Hud/PracticeHud.cs | 3 +- apps/utility-sw/src/UtilityPractice.csproj | 1 + apps/utility-sw/src/UtilityPracticePlugin.cs | 16 ++- apps/utility-sw/test/FiveStack.Tests.csproj | 1 + apps/utility-sw/test/HudSlotResetTests.cs | 117 ++++++++++++++++++ .../FiveStack.Utilities/HudLayoutIdentity.cs | 12 ++ 7 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 apps/utility-sw/test/HudSlotResetTests.cs create mode 100644 shared/dotnet/FiveStack.Utilities/HudLayoutIdentity.cs diff --git a/apps/utility-sw/src/Hud/HudKit.cs b/apps/utility-sw/src/Hud/HudKit.cs index aef99f3b..e9af15cf 100644 --- a/apps/utility-sw/src/Hud/HudKit.cs +++ b/apps/utility-sw/src/Hud/HudKit.cs @@ -254,7 +254,7 @@ private static uint EntityIndex(uint indexOrHandle) if (layouts.TryGetValue(slots.Layout, out Open? open)) { - if (open.EntityIndex == entity.Index) + if (HudLayoutIdentity.Same(open.Surface.Layout, open.EntityIndex, entity, entity.Index)) { return open; } diff --git a/apps/utility-sw/src/Hud/PracticeHud.cs b/apps/utility-sw/src/Hud/PracticeHud.cs index 716ddb2c..57688244 100644 --- a/apps/utility-sw/src/Hud/PracticeHud.cs +++ b/apps/utility-sw/src/Hud/PracticeHud.cs @@ -690,7 +690,7 @@ private string RowValue(LineupRecord lineup, Vec3? at) return from.Length > 0 ? $"{from} ยท {distance}" : distance; } - private void ForgetHud(ulong steamId, int playerId) + private void ForgetHud(ulong steamId) { _hudOff.Remove(steamId); _menus.Remove(steamId); @@ -702,7 +702,6 @@ private void ForgetHud(ulong steamId, int playerId) _mapChoices.Remove(steamId); ForgetVerdicts(steamId); ClearFilters(steamId); - _hud?.Forget(playerId); } [Command("menu", registerRaw: false, permission: "")] diff --git a/apps/utility-sw/src/UtilityPractice.csproj b/apps/utility-sw/src/UtilityPractice.csproj index 6da9c069..624ffad5 100644 --- a/apps/utility-sw/src/UtilityPractice.csproj +++ b/apps/utility-sw/src/UtilityPractice.csproj @@ -85,6 +85,7 @@ Link="resources/gamedata/fivestack.gamedata.json" CopyToOutputDirectory="PreserveNewest" /> + diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index f7bc9721..4f8ba34b 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -87,6 +87,7 @@ private sealed class LibraryLoad private EventDelegates.OnClientDisconnected? _disconnectHandler; private EventDelegates.OnPrecacheResource? _precacheHandler; private EventDelegates.OnClientSteamAuthorize? _authorizeHandler; + private EventDelegates.OnClientPutInServer? _putInServerHandler; private EventDelegates.OnCustomHudClicked? _hudClickHandler; public UtilityPracticePlugin(ISwiftlyCore core) @@ -265,6 +266,7 @@ public override void Load(bool hotReload) _occupancyDirty = true; _prompt?.Cancel(@event.PlayerId); _replay.ForgetViewer(@event.PlayerId); + _hud?.Forget(@event.PlayerId); ForPlayer( @event.PlayerId, @@ -275,7 +277,7 @@ public override void Load(bool hotReload) // invalidated never reaches this at all, which is why the // drain prunes off the roster as well. _libraryLoads.Remove(steamId); - ForgetHud(steamId, @event.PlayerId); + ForgetHud(steamId); OnPlayerGone(steamId); } ); @@ -292,6 +294,13 @@ public override void Load(bool hotReload) }; Core.Event.OnClientSteamAuthorize += _authorizeHandler; + // Only the slot: ForgetHud also clears settings that outlive a map change. + _putInServerHandler = @event => + { + _hud?.Forget(@event.PlayerId); + }; + Core.Event.OnClientPutInServer += _putInServerHandler; + InitializeConnectClientHook(); InitializeTerminateRoundHook(); @@ -501,6 +510,11 @@ private void TearDown() Core.Event.OnClientSteamAuthorize -= _authorizeHandler; } + if (_putInServerHandler != null) + { + Core.Event.OnClientPutInServer -= _putInServerHandler; + } + UninstallConnectClientHook(); UninstallTerminateRoundHook(); diff --git a/apps/utility-sw/test/FiveStack.Tests.csproj b/apps/utility-sw/test/FiveStack.Tests.csproj index 32f30849..dcebc6ff 100644 --- a/apps/utility-sw/test/FiveStack.Tests.csproj +++ b/apps/utility-sw/test/FiveStack.Tests.csproj @@ -60,6 +60,7 @@ + diff --git a/apps/utility-sw/test/HudSlotResetTests.cs b/apps/utility-sw/test/HudSlotResetTests.cs new file mode 100644 index 00000000..c6c42806 --- /dev/null +++ b/apps/utility-sw/test/HudSlotResetTests.cs @@ -0,0 +1,117 @@ +using System.Text.RegularExpressions; +using FiveStack.Utilities; +using Xunit; + +public class HudSlotResetTests +{ + private const uint Index = 412; + + // Comments out first: a call that has been commented out is not a call. + private static string Code(params string[] path) + { + DirectoryInfo? directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory != null) + { + string candidate = Path.Combine( + new[] { directory.FullName, "apps", "utility-sw", "src" }.Concat(path).ToArray() + ); + + if (File.Exists(candidate)) + { + return Regex.Replace(File.ReadAllText(candidate), @"//[^\n]*", ""); + } + + directory = directory.Parent; + } + + throw new FileNotFoundException($"{path[^1]} was not found above the test output"); + } + + private static string Hook(string start, string end) + { + string source = Code("UtilityPracticePlugin.cs"); + int from = source.IndexOf(start, StringComparison.Ordinal); + + Assert.True(from >= 0, $"{start} is gone"); + + int to = source.IndexOf(end, from, StringComparison.Ordinal); + + Assert.True(to >= 0, $"{end} is gone"); + + return source[from..to]; + } + + [Fact] + public void TheSameEntityKeepsItsSurface() + { + object layout = new(); + + Assert.True(HudLayoutIdentity.Same(layout, Index, layout, Index)); + } + + [Fact] + public void ALayoutRespawnedOnTheFreedIndexIsANewLayout() + { + Assert.False(HudLayoutIdentity.Same(new object(), Index, new object(), Index)); + } + + [Fact] + public void ALayoutOnAnotherIndexIsANewLayout() + { + object layout = new(); + + Assert.False(HudLayoutIdentity.Same(layout, Index, layout, Index + 1)); + } + + [Fact] + public void ADisconnectForgetsTheSlotWithoutResolvingTheLeaver() + { + string beforeResolving = Hook("_disconnectHandler = @event =>", "ForPlayer("); + + Assert.Contains("_hud?.Forget(@event.PlayerId);", beforeResolving); + Assert.Contains( + "Core.Event.OnClientDisconnected += _disconnectHandler;", + Code("UtilityPracticePlugin.cs") + ); + } + + [Fact] + public void AnArrivalForgetsWhateverTheSlotWasHolding() + { + string hook = Hook("_putInServerHandler = @event =>", "};"); + + Assert.Contains("_hud?.Forget(@event.PlayerId);", hook); + Assert.Contains( + "Core.Event.OnClientPutInServer += _putInServerHandler;", + Code("UtilityPracticePlugin.cs") + ); + } + + [Fact] + public void TheArrivalHookIsLetGoOnUnload() + { + Assert.Contains( + "Core.Event.OnClientPutInServer -= _putInServerHandler;", + Code("UtilityPracticePlugin.cs") + ); + } + + [Fact] + public void AnArrivalLeavesAPlayersOwnHudSettingsAlone() + { + Assert.DoesNotContain("ForgetHud", Hook("_putInServerHandler = @event =>", "};")); + } + + [Fact] + public void TheKitAsksTheIdentityRuleBeforeKeepingASurface() + { + string kit = Code("Hud", "HudKit.cs"); + + Assert.Contains( + "HudLayoutIdentity.Same(open.Surface.Layout, open.EntityIndex, entity, entity.Index)", + kit + ); + Assert.DoesNotContain("open.EntityIndex == entity.Index", kit); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/HudLayoutIdentity.cs b/shared/dotnet/FiveStack.Utilities/HudLayoutIdentity.cs new file mode 100644 index 00000000..8ca3757a --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/HudLayoutIdentity.cs @@ -0,0 +1,12 @@ +namespace FiveStack.Utilities; + +// Whether a surface can still be used on the layout entity in hand. A respawned +// entity can be given the index the old one freed, and what was sent to the old +// one is gone with it, so the index alone does not say the client holds anything. +public static class HudLayoutIdentity +{ + public static bool Same(object held, uint heldIndex, object current, uint currentIndex) + { + return heldIndex == currentIndex && ReferenceEquals(held, current); + } +}