From 46cf65df0a63d4cddb0cb6aae861e9437bcbc9d3 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Thu, 8 Oct 2026 22:25:17 -0500 Subject: [PATCH] Server: remove players who have been away too long Server: remove players who have been away too long A dedicated server can remove players who are away from their game. - "afk_kick_minutes" in the config's "players" section: 1 to 1440, or 0 (the default) for never. "afk-kick |off" from the console or an admin; a change is saved and applies at once. - Away is doing nothing a player at their game does: not moving or turning, not speaking, not sending chat, and not placing, moving or removing an object. - A minute before the limit the player is told in chat; at the limit they are removed with a message saying why and that they can join again. The log has an "[afk]" line. - Admins are never removed for it. The time starts once a player's game has loaded the map, and starts over after a map change and when the setting is changed. --- Server/README.txt | 6 +++++ Server/Test/server_config_tests.cpp | 12 ++++++++++ Server/server_commands.cpp | 13 ++++++++++- Server/server_config.cpp | 3 +++ Server/server_config.h | 3 +++ Server/server_host.cpp | 35 ++++++++++++++++++++++++++++- Server/server_host.h | 10 +++++++++ 7 files changed, 80 insertions(+), 2 deletions(-) diff --git a/Server/README.txt b/Server/README.txt index 45677ac..59da72b 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -115,6 +115,11 @@ allow_parties Let players form parties (default true): invite each other members join each other's coop challenges, see each other on the map and talk with /p . party_size Most players in one party, 2-8 (default 8). +afk_kick_minutes Remove a player who has been away this many minutes, 1-1440 + (default 0: never). Away is not moving, speaking, typing + in chat or changing their objects. They are warned in chat + a minute before and can join again at once. Admins are + never removed for it. Console: afk-kick |off. allow_voice_chat Allow voice chat. voice_range How far proximity voice reaches, 50-1000 m. object_placement everyone, admins (only admins can build), or nobody. @@ -289,6 +294,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu. announce-throwdowns on|off Chat message when a throwdown is placed. parties [on|off] List the parties, or allow them (off ends them all). party-size <2-8> Most players in one party. + afk-kick |off Remove players who have been away that long. speed-check off|warn|kick What happens to players whose game runs fast. score-check [off|warn|kick] What happens to players whose mods change scoring or physics; with no argument, every player's result. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 7caa465..7fc59ca 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -192,6 +192,18 @@ int run() { "A config in sections did not read back as it was written"); std::filesystem::remove_all(folder / "data"); } + // The away timer: off unless set, and no more than a day. + { + ServerConfig away; + check(away.afk_kick == 0 && config_error(away).find("afk_kick_minutes") == std::string::npos, "The away timer is not off by default"); + away.file = folder / "away.json"; + away.afk_kick = 15; + save_config(away); + check(load_config(away.file).afk_kick == 15, "The away timer was not kept"); + away.afk_kick = 1441; + check(config_error(away).find("afk_kick_minutes") != std::string::npos, "An away timer over a day was accepted"); + std::filesystem::remove_all(folder / "data"); + } // Object scaling: allowed unless turned off, and kept in the file. { ServerConfig sized; diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index d90f955..8885381 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -20,7 +20,7 @@ constexpr std::string_view help_text = "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" "map-pool [add|remove |clear] | rotation [|off]\n" "park | park random | layer-sync on|off | layer default|on|off | tod \n" - "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | speed-check off|warn|kick\n" + "activity-log on|off | announce-throwdowns on|off | parties [on|off] | party-size <2-8> | afk-kick |off | speed-check off|warn|kick\n" "score-check [off|warn|kick] | score-allow [|remove ]\n" "reserved [slots | add|remove ] | admin add|remove | admins | update | quit"; } // namespace @@ -363,6 +363,17 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { std::to_string(*value / config_.tps) + " players near them at the full rate." : std::string("No crowd limit: every player near is sent at the full rate.")); } + if (name == "afk-kick") { + const auto value = lower(argument) == "off" ? std::optional(0) : number(argument); + if (!value || *value > 1440) + return "afk-kick |off (now " + (config_.afk_kick ? std::to_string(config_.afk_kick) + " min" : std::string("off")) + ")"; + config_.afk_kick = static_cast(*value); + // Nobody is removed for time away before the rule was set. + for (auto &[id, guest] : guests_) + if (guest->active_at) active(*guest); + return changed(config_.afk_kick ? "Players away for " + std::to_string(config_.afk_kick) + " min are removed. Admins are not." + : std::string("Players are no longer removed for being away.")); + } if (name == "party-size") { const auto value = number(argument); if (!value || *value < 2 || *value > 8) return "party-size <2-8> (now " + std::to_string(config_.party_size) + ")"; diff --git a/Server/server_config.cpp b/Server/server_config.cpp index b89310c..c7535a6 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -99,6 +99,7 @@ Layout layout(const ServerConfig &c) { players.set("allow_noclip", c.noclip); players.set("allow_parties", c.parties); players.set("party_size", c.party_size); + players.set("afk_kick_minutes", c.afk_kick); players.set("allow_voice_chat", c.voice_chat); players.set("voice_range", static_cast(c.voice_range)); players.set("object_placement", placement_text(c.object_placement)); @@ -250,6 +251,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector parse_colour(std::string_view text) noexcept { std::string config_error(const ServerConfig &c) { using namespace multiplayer; if (!parse_colour(c.chat_color)) return "chat_color must be a colour like #8E5CFF."; + if (c.afk_kick > 1440) return "afk_kick_minutes must be 0 (never) to 1440."; if (!parse_colour(c.chat_text_color)) return "chat_text_color must be a colour like #D9C8FF."; if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + "."; for (const auto id : c.reserved) diff --git a/Server/server_config.h b/Server/server_config.h index 5dc0d2f..5551790 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -78,6 +78,9 @@ struct ServerConfig { // panel has eight rows). Off, nobody can be in one. bool parties = true; unsigned party_size = 8; + // Minutes a player may be away (not moving, talking, typing or building) before the server + // removes them, 1 to 1440; 0: never. Admins are never removed for it. + unsigned afk_kick = 0; // Players whose game runs fast (a speedhack; Server/speed_check.h): "warn" takes them out of // throwdowns and coop challenges and tells the admins, "kick" also removes them, "off" does not check. std::string speed_check = "warn"; diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 77850ed..6ab0e8d 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -763,6 +763,7 @@ bool Host::accept_data(Guest &source, const Packet &p) { if (!source.moved_at || moved > 0.05f * 0.05f || std::abs(facing) < 0.9995f) { source.still_at = root; source.moved_at = now_; + active(source); } } return check_speed(source, p.time_us); // last: a speed-check kick frees `source` @@ -890,7 +891,10 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: log_("[map] " + guest_name(*link) + " finished loading (" + std::to_string((now_ - since) / 1000000) + " s)"); link->world_ready = p.world_ready; if (p.world_ready) link->travel_since = link->loading_since = 0; - if (arrived) meet_later(*link); + if (arrived) { + meet_later(*link); + active(*link); // the time away starts once they are in + } return; } case PacketKind::map_request: { @@ -1007,6 +1011,7 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: // A listen host's; the server's physics are the game's own. if (p.kind == PacketKind::physics_tuning || p.kind == PacketKind::physics_extras) return; if (p.kind == PacketKind::chat) { + active(*link); if (!routed_source(p, link->member, peer, true, id_) || link->chat_rate.accept(now_, p.text, 1) != ChatRate::Verdict::accepted) return; @@ -1062,8 +1067,10 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: if (!routed_source(p, link->member, peer, true, id_)) return; if (p.kind == PacketKind::away) return drop(peer, "A player ended their session."); if (p.kind == PacketKind::objects) { + const auto before = link->objects.revision(); if (link->objects.receive(p.objects) == ObjectState::Result::invalid) return drop(peer, "Invalid shared object revision or layout."); + if (link->objects.revision() != before) active(*link); link->last_packet = now_; return; } @@ -1074,6 +1081,7 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: link->received_voice = true; link->voice_sequence = p.sequence; link->last_packet = now_; + active(*link); broadcast(p, false, true, p.source); return; } @@ -1100,6 +1108,30 @@ void Host::receive_cosmetics() { } } +// Players who have been away longer than the server allows ("afk_kick_minutes") are removed, +// with a warning a minute before. Away is doing nothing a player at their game does: not +// moving, speaking, typing in chat or changing their objects. Admins stay, and so does anyone +// whose game is still loading the map. +void Host::remove_away() { + if (!config_.afk_kick) return; + const auto limit = static_cast(config_.afk_kick) * 60000000; + std::vector away; + for (auto &[id, guest] : guests_) { + auto &g = *guest; + if (!g.handshaken || !g.world_ready || !g.active_at || is_admin(id)) continue; + const auto idle = now_ - g.active_at; + if (idle >= limit) away.push_back(id); + else if (!g.away_warned && limit > 60000000 && idle >= limit - 60000000) { + g.away_warned = true; + reply(g, "You have been away a while: move or say something within a minute to stay on the server."); + } + } + for (const auto id : away) { + if (const auto *g = find(id)) log_("[afk] " + guest_name(*g) + " was removed after " + std::to_string(config_.afk_kick) + " min away."); + drop(id, "You were removed from the server for being away too long. You can join again."); + } +} + // ---- Objects --------------------------------------------------------------------------------- void Host::sync_objects() { if (now_ < next_object_update_) return; @@ -1519,6 +1551,7 @@ void Host::tick(std::uint64_t now) { roster_dirty_ = true; } tick_rotation(); + remove_away(); std::erase_if(vote_cooldowns_, [&](const auto &entry) { return now_ >= entry.second; }); join_backoff_.prune(now_); } diff --git a/Server/server_host.h b/Server/server_host.h index db84065..125dc77 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -128,6 +128,11 @@ class Host { std::unordered_map sound_in; Transform still_at; std::uint64_t moved_at{}; + // When they last did something a player at their game does: moved, spoke, typed in chat + // or changed their objects (0 until their game has loaded the map). And whether they have + // been told they are about to be removed for being away. + std::uint64_t active_at{}; + bool away_warned{}; std::uint64_t pose_arrival{}; std::array pose_delivery; CrowdLimits crowd; // how far the full and half rates reach for them in a crowd @@ -192,6 +197,11 @@ class Host { std::uint64_t vote_shown_until_{}; std::uint32_t vote_ids_{}; void show_vote(const Vote &vote, std::uint8_t outcome, unsigned yes, unsigned no, unsigned needed); + void active(Guest &guest) { + guest.active_at = now_; + guest.away_warned = false; + } + void remove_away(); bool vote_recount_{}; // a player left: recount in tick(), never while guests_ is being walked std::map vote_cooldowns_; std::uint64_t map_since_{}; // rotation clock start: the last map change, or while nobody is on