diff --git a/src/Matchmaking/Modules/PlayerArenaPreference.cs b/src/Matchmaking/Modules/PlayerArenaPreference.cs
new file mode 100644
index 00000000..8c54f07d
--- /dev/null
+++ b/src/Matchmaking/Modules/PlayerArenaPreference.cs
@@ -0,0 +1,235 @@
+using Microsoft.Extensions.ObjectPool;
+using SS.Core;
+using SS.Core.ComponentCallbacks;
+using SS.Core.ComponentInterfaces;
+using SS.Matchmaking.Persist;
+using System.Text;
+
+namespace SS.Matchmaking.Modules
+{
+ ///
+ /// Module that manages a per-player preferred arena.
+ /// Provides the ?arena command and persists the preference globally.
+ /// On first arena entry each session, players with a preference set are automatically sent there.
+ ///
+ [ModuleInfo("Manages per-player arena preference, automatically routing players to their preferred arena on first entry.")]
+ public sealed class PlayerArenaPreference : IAsyncModule
+ {
+ private readonly IArenaManager _arenaManager;
+ private readonly IChat _chat;
+ private readonly ICommandManager _commandManager;
+ private readonly ILogManager _logManager;
+ private readonly IPlayerData _playerData;
+
+ private IPersist? _persist;
+
+ private PlayerDataKey _pdKey;
+ private DelegatePersistentData? _persistRegistration;
+
+ private IComponentBroker? _broker;
+
+ private const string CommandName = "defaultarena";
+
+ public PlayerArenaPreference(
+ IArenaManager arenaManager,
+ IChat chat,
+ ICommandManager commandManager,
+ ILogManager logManager,
+ IPlayerData playerData)
+ {
+ _arenaManager = arenaManager ?? throw new ArgumentNullException(nameof(arenaManager));
+ _chat = chat ?? throw new ArgumentNullException(nameof(chat));
+ _commandManager = commandManager ?? throw new ArgumentNullException(nameof(commandManager));
+ _logManager = logManager ?? throw new ArgumentNullException(nameof(logManager));
+ _playerData = playerData ?? throw new ArgumentNullException(nameof(playerData));
+ }
+
+ async Task IAsyncModule.LoadAsync(IComponentBroker broker, CancellationToken cancellationToken)
+ {
+ _broker = broker;
+ _persist = broker.GetInterface();
+
+ if (_persist is null)
+ _logManager.LogM(LogLevel.Warn, nameof(PlayerArenaPreference), "IPersist not available — arena preference will not be saved across sessions.");
+
+ _pdKey = _playerData.AllocatePlayerData();
+
+ if (_persist is not null)
+ {
+ _persistRegistration = new DelegatePersistentData(
+ (int)Persist.PersistKey.PlayerArenaPreference,
+ PersistInterval.Forever,
+ PersistScope.Global,
+ Persist_GetData,
+ Persist_SetData,
+ Persist_ClearData);
+
+ await _persist.RegisterPersistentDataAsync(_persistRegistration);
+ }
+
+ PlayerActionCallback.Register(broker, Callback_PlayerAction);
+ _commandManager.AddCommand(CommandName, Command_Arena);
+ return true;
+ }
+
+ async Task IAsyncModule.UnloadAsync(IComponentBroker broker, CancellationToken cancellationToken)
+ {
+ _commandManager.RemoveCommand(CommandName, Command_Arena);
+ PlayerActionCallback.Unregister(broker, Callback_PlayerAction);
+
+ if (_persist is not null && _persistRegistration is not null)
+ {
+ await _persist.UnregisterPersistentDataAsync(_persistRegistration);
+ _persistRegistration = null;
+ }
+
+ if (_persist is not null)
+ broker.ReleaseInterface(ref _persist);
+
+ _playerData.FreePlayerData(ref _pdKey);
+ _broker = null;
+ return true;
+ }
+
+ private void Callback_PlayerAction(Player player, PlayerAction action, Arena? arena)
+ {
+ if (action != PlayerAction.EnterArena)
+ return;
+
+ if (!player.TryGetExtraData(_pdKey, out PlayerPreferenceData? data))
+ return;
+
+ if (data.HasBeenRedirected)
+ return;
+
+ // Mark as handled for this session regardless of whether a redirect is needed.
+ data.HasBeenRedirected = true;
+
+ if (string.IsNullOrEmpty(data.PreferredArena))
+ return;
+
+ // Don't redirect if the player is already in their preferred arena.
+ if (arena is not null && arena.Name.Equals(data.PreferredArena, StringComparison.OrdinalIgnoreCase))
+ return;
+
+ _arenaManager.SendToArena(player, data.PreferredArena, 0, 0);
+ }
+
+ #region Persist
+
+ private void Persist_GetData(Player? player, Stream outStream)
+ {
+ if (player is null || !player.TryGetExtraData(_pdKey, out PlayerPreferenceData? data))
+ return;
+
+ if (string.IsNullOrEmpty(data.PreferredArena))
+ return;
+
+ outStream.Write(Encoding.ASCII.GetBytes(data.PreferredArena));
+ }
+
+ private void Persist_SetData(Player? player, Stream inStream)
+ {
+ if (player is null || !player.TryGetExtraData(_pdKey, out PlayerPreferenceData? data))
+ return;
+
+ Span buffer = stackalloc byte[Constants.MaxArenaNameLength];
+ int bytesRead = inStream.Read(buffer);
+ if (bytesRead <= 0)
+ return;
+
+ string arenaName = Encoding.ASCII.GetString(buffer[..bytesRead]);
+ if (arenaName.Length > Constants.MaxArenaNameLength)
+ {
+ _logManager.LogP(LogLevel.Warn, nameof(PlayerArenaPreference), player, $"Persist_SetData: stored arena name '{arenaName}' exceeds max length, ignoring.");
+ return;
+ }
+
+ data.PreferredArena = arenaName;
+ }
+
+ private void Persist_ClearData(Player? player)
+ {
+ if (player is null || !player.TryGetExtraData(_pdKey, out PlayerPreferenceData? data))
+ return;
+
+ data.PreferredArena = null;
+ }
+
+ #endregion
+
+ #region Command
+
+ [CommandHelp(
+ Targets = CommandTarget.None,
+ Args = "[-clear|-c | ]",
+ Description = """
+ Controls which arena you are automatically sent to when you first enter the zone.
+ - Use with no argument to see your current setting.
+ - Use -clear (or -c) to remove the preference.
+ - Use an arena name to set the preference.
+ """)]
+ private void Command_Arena(ReadOnlySpan commandName, ReadOnlySpan parameters, Player player, ITarget target)
+ {
+ if (!player.TryGetExtraData(_pdKey, out PlayerPreferenceData? data))
+ return;
+
+ if (parameters.IsEmpty)
+ {
+ if (string.IsNullOrEmpty(data.PreferredArena))
+ _chat.SendMessage(player, "Your arena preference is not set.");
+ else
+ _chat.SendMessage(player, $"Your arena preference is: {data.PreferredArena}");
+ return;
+ }
+
+ if (parameters.Equals("-clear", StringComparison.OrdinalIgnoreCase) || parameters.Equals("-c", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrEmpty(data.PreferredArena))
+ {
+ _chat.SendMessage(player, "Your arena preference is already not set.");
+ return;
+ }
+
+ data.PreferredArena = null;
+ _chat.SendMessage(player, "Arena preference cleared.");
+ return;
+ }
+
+ if (parameters.Length > Constants.MaxArenaNameLength)
+ {
+ _chat.SendMessage(player, $"Arena name too long (max {Constants.MaxArenaNameLength} characters).");
+ return;
+ }
+
+ string newArena = parameters.ToString();
+
+ if (newArena.Equals(data.PreferredArena, StringComparison.OrdinalIgnoreCase))
+ {
+ _chat.SendMessage(player, $"Your arena preference is already set to: {data.PreferredArena}");
+ return;
+ }
+
+ data.PreferredArena = newArena;
+ _chat.SendMessage(player, $"Arena preference set to: {newArena}");
+ }
+
+ #endregion
+
+ private sealed class PlayerPreferenceData : IResettable
+ {
+ /// The player's preferred arena name, or if not set.
+ public string? PreferredArena;
+
+ /// Session flag: whether the player has already been redirected (or checked) this session.
+ public bool HasBeenRedirected;
+
+ bool IResettable.TryReset()
+ {
+ PreferredArena = null;
+ HasBeenRedirected = false;
+ return true;
+ }
+ }
+ }
+}
diff --git a/src/Matchmaking/Persist/PersistKeys.cs b/src/Matchmaking/Persist/PersistKeys.cs
index db95c9d4..25be1c28 100644
--- a/src/Matchmaking/Persist/PersistKeys.cs
+++ b/src/Matchmaking/Persist/PersistKeys.cs
@@ -11,5 +11,6 @@ public enum PersistKey
{
MatchmakingQueuesPlayerData = 10000,
PlayerStatboxPreference = 10001,
+ PlayerArenaPreference = 10002,
}
}
diff --git a/src/SubspaceServer/Zone/conf/Modules.config b/src/SubspaceServer/Zone/conf/Modules.config
index b28a859b..8238350b 100644
--- a/src/SubspaceServer/Zone/conf/Modules.config
+++ b/src/SubspaceServer/Zone/conf/Modules.config
@@ -201,6 +201,7 @@ For plug-in modules (e.g. custom modules that you build):
+
diff --git a/src/SubspaceServer/Zone/conf/groupdef.dir/default b/src/SubspaceServer/Zone/conf/groupdef.dir/default
index c23b8942..76fcaa09 100644
--- a/src/SubspaceServer/Zone/conf/groupdef.dir/default
+++ b/src/SubspaceServer/Zone/conf/groupdef.dir/default
@@ -94,6 +94,9 @@ cmd_speedstats
cmd_best
privcmd_best
+; Player Zone Preferences
+cmd_defaultarena
+
; Matchmaking (General)
cmd_statbox