diff --git a/Content.Server/Afk/AfkManager.cs b/Content.Server/Afk/AfkManager.cs
new file mode 100644
index 0000000000..ae93e81254
--- /dev/null
+++ b/Content.Server/Afk/AfkManager.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections.Generic;
+using Content.Shared.CCVar;
+using JetBrains.Annotations;
+using Robust.Server.Player;
+using Robust.Shared.Configuration;
+using Robust.Shared.Console;
+using Robust.Shared.Enums;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Input;
+using Robust.Shared.IoC;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Afk
+{
+ ///
+ /// Tracks AFK (away from keyboard) status for players.
+ ///
+ ///
+ public interface IAfkManager
+ {
+ ///
+ /// Check whether this player is currently AFK.
+ ///
+ /// The player to check.
+ /// True if the player is AFK, false otherwise.
+ bool IsAfk(IPlayerSession player);
+
+ ///
+ /// Resets AFK status for the player as if they just did an action and are definitely not AFK.
+ ///
+ /// The player to set AFK status for.
+ void PlayerDidAction(IPlayerSession player);
+
+ void Initialize();
+ }
+
+ [UsedImplicitly]
+ public class AfkManager : IAfkManager, IEntityEventSubscriber
+ {
+ [Dependency] private readonly IEntityManager _entityManager = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly IConsoleHost _consoleHost = default!;
+
+ private readonly Dictionary _lastActionTimes = new();
+
+ public void Initialize()
+ {
+ // Connecting, console commands and input commands all reset AFK status.
+
+ _playerManager.PlayerStatusChanged += PlayerStatusChanged;
+ _consoleHost.AnyCommandExecuted += ConsoleHostOnAnyCommandExecuted;
+
+ _entityManager.EventBus.SubscribeSessionEvent(
+ EventSource.Network,
+ this,
+ HandleInputCmd);
+ }
+
+ public void PlayerDidAction(IPlayerSession player)
+ {
+ if (player.Status == SessionStatus.Disconnected)
+ // Make sure we don't re-add to the dictionary if the player is disconnected now.
+ return;
+
+ _lastActionTimes[player] = _gameTiming.RealTime;
+ }
+
+ public bool IsAfk(IPlayerSession player)
+ {
+ if (!_lastActionTimes.TryGetValue(player, out var time))
+ // Some weird edge case like disconnected clients. Just say true I guess.
+ return true;
+
+ var timeOut = TimeSpan.FromSeconds(_cfg.GetCVar(CCVars.AfkTime));
+ return _gameTiming.RealTime - time > timeOut;
+ }
+
+ private void PlayerStatusChanged(object? sender, SessionStatusEventArgs e)
+ {
+ if (e.NewStatus == SessionStatus.Disconnected)
+ {
+ _lastActionTimes.Remove(e.Session);
+ return;
+ }
+
+ PlayerDidAction(e.Session);
+ }
+
+ private void ConsoleHostOnAnyCommandExecuted(IConsoleShell shell, string commandname, string argstr, string[] args)
+ {
+ if (shell.Player is IPlayerSession player)
+ PlayerDidAction(player);
+ }
+
+ private void HandleInputCmd(FullInputCmdMessage msg, EntitySessionEventArgs args)
+ {
+ PlayerDidAction((IPlayerSession) args.SenderSession);
+ }
+ }
+}
diff --git a/Content.Server/Afk/IsAfkCommand.cs b/Content.Server/Afk/IsAfkCommand.cs
new file mode 100644
index 0000000000..b166851b50
--- /dev/null
+++ b/Content.Server/Afk/IsAfkCommand.cs
@@ -0,0 +1,36 @@
+using Content.Server.Administration;
+using Content.Shared.Administration;
+using Robust.Server.Player;
+using Robust.Shared.Console;
+using Robust.Shared.IoC;
+
+namespace Content.Server.Afk
+{
+ [AdminCommand(AdminFlags.Admin)]
+ public sealed class IsAfkCommand : IConsoleCommand
+ {
+ public string Command => "isafk";
+ public string Description => "Checks if a specified player is AFK";
+ public string Help => "Usage: isafk ";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ var playerManager = IoCManager.Resolve();
+ var afkManager = IoCManager.Resolve();
+
+ if (args.Length == 0)
+ {
+ shell.WriteError("Need one argument");
+ return;
+ }
+
+ if (!playerManager.TryGetSessionByUsername(args[0], out var player))
+ {
+ shell.WriteError("Unable to find that player");
+ return;
+ }
+
+ shell.WriteLine(afkManager.IsAfk(player) ? "They are indeed AFK" : "They are not AFK");
+ }
+ }
+}
diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs
index 7f5e56b98e..621801dc93 100644
--- a/Content.Server/Entry/EntryPoint.cs
+++ b/Content.Server/Entry/EntryPoint.cs
@@ -1,4 +1,5 @@
using Content.Server.Administration.Managers;
+using Content.Server.Afk;
using Content.Server.AI.Utility;
using Content.Server.AI.Utility.Considerations;
using Content.Server.AI.WorldState;
@@ -92,6 +93,7 @@ namespace Content.Server.Entry
IoCManager.Resolve().Initialize();
IoCManager.Resolve().Initialize();
IoCManager.Resolve().Initialize();
+ IoCManager.Resolve().Initialize();
_euiManager.Initialize();
IoCManager.Resolve().GetEntitySystem().PostInitialize();
diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs
index e6f5d5238a..4e1fabb0bf 100644
--- a/Content.Server/IoC/ServerContentIoC.cs
+++ b/Content.Server/IoC/ServerContentIoC.cs
@@ -1,5 +1,6 @@
using Content.Server.Administration;
using Content.Server.Administration.Managers;
+using Content.Server.Afk;
using Content.Server.AI.Utility;
using Content.Server.AI.Utility.Considerations;
using Content.Server.AI.WorldState;
@@ -62,6 +63,7 @@ namespace Content.Server.IoC
IoCManager.Register();
IoCManager.Register();
IoCManager.Register();
+ IoCManager.Register();
}
}
}
diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs
index a3ae9c1bad..e9d333244d 100644
--- a/Content.Shared/CCVar/CCVars.cs
+++ b/Content.Shared/CCVar/CCVars.cs
@@ -382,5 +382,15 @@ namespace Content.Shared.CCVar
public static readonly CVarDef ChatMaxMessageLength =
CVarDef.Create("chat.max_message_length", 1000, CVar.SERVER | CVar.REPLICATED);
+
+ /*
+ * AFK
+ */
+
+ ///
+ /// How long a client can go without any input before being considered AFK.
+ ///
+ public static readonly CVarDef AfkTime =
+ CVarDef.Create("afk.time", 60f, CVar.SERVERONLY);
}
}