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);
+ }
+}