diff --git a/README.md b/README.md index d97cb90a..d67bc824 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Name | Description `map_ext` | extend round `map_rest` | restart current level `map_next` | load next level +`map_rand` | load random level from rotation `map_prev` | load previous level `kill_limit value` | set kill limit `time_limit value` | set time limit @@ -152,6 +153,8 @@ Configuration example: $DF Vote Restart: true // Enable vote next $DF Vote Next: true + // Enable vote random + $DF Vote Random: true // Enable vote previous $DF Vote Previous: true // Duration of player invulnerability after respawn in ms (default is the same as in stock RF - 1500) @@ -196,6 +199,8 @@ Configuration example: $DF Force Player Character: "enviro_parker" // Maximal horizontal FOV that clients can use for level rendering (unlimited by default) //$DF Max FOV: 125 + // Shuffle the list of levels when the server starts and at the end of each full rotation + $DF Random Rotation: false // If enabled a private message with player statistics is sent after each round. //$DF Send Player Stats Message: true // Send a chat message to players when they join the server ($PLAYER is replaced by player name) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9e37aecc..d1c56b82 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -68,6 +68,9 @@ Version 1.9.0 (not released yet) - Add `spectate_mode_follow_killer` command (when player you are spectating dies, spectate their killer) - Remove level editor popups that stop user from navigating between modes until rebuilding - Add `muzzle_flash` command +- Add `map_rand` command to change to a random level on the rotation +- Add `vote rand` vote type to initiate a level change to a random level on the rotation +- Add `$DF Random Rotation` option in dedicated server config [@is-this-c](https://github.com/is-this-c) - Support `©` and `•` in TrueType fonts diff --git a/game_patch/main/main.cpp b/game_patch/main/main.cpp index a164944c..029c3e45 100644 --- a/game_patch/main/main.cpp +++ b/game_patch/main/main.cpp @@ -43,6 +43,7 @@ GameConfig g_game_config; HMODULE g_hmodule; +std::mt19937 g_rng; CallHook rf_init_hook{ 0x004B27CD, @@ -269,6 +270,13 @@ void init_logging() xlog::info("Command line: {}", GetCommandLineA()); } +void initialize_random_generator() +{ + // seed rng with the current time + auto seed = std::chrono::steady_clock::now().time_since_epoch().count(); + g_rng.seed(static_cast(seed)); +} + void log_system_info() { try { @@ -338,6 +346,9 @@ extern "C" DWORD __declspec(dllexport) Init([[maybe_unused]] void* unused) init_logging(); init_crash_handler(); + // Init random number generator + initialize_random_generator(); + // Enable Data Execution Prevention if (!SetProcessDEPPolicy(PROCESS_DEP_ENABLE)) xlog::warn("SetProcessDEPPolicy failed (error {})", GetLastError()); diff --git a/game_patch/main/main.h b/game_patch/main/main.h index 2e8ed07e..ef9d1c28 100644 --- a/game_patch/main/main.h +++ b/game_patch/main/main.h @@ -1,8 +1,13 @@ #pragma once +#include #include extern GameConfig g_game_config; + +// random number generator +extern std::mt19937 g_rng; + #ifdef _WINDOWS_ extern HMODULE g_hmodule; #endif diff --git a/game_patch/multi/commands.cpp b/game_patch/multi/commands.cpp index c673a4c6..6d2eca7a 100644 --- a/game_patch/multi/commands.cpp +++ b/game_patch/multi/commands.cpp @@ -5,6 +5,7 @@ #include "../rf/level.h" #include "../rf/player/player.h" #include "../rf/crt.h" +#include "server.h" #include "multi.h" #include #include @@ -43,6 +44,11 @@ void load_prev_level() } } +void load_rand_level() +{ + rf::multi_change_level(get_rand_level_filename()); +} + bool validate_is_server() { if (!rf::is_server) { @@ -97,6 +103,17 @@ ConsoleCommand2 map_next_cmd{ "Load next level", }; +ConsoleCommand2 map_rand_cmd{ + "map_rand", + []() { + if (validate_is_server() && validate_not_limbo()) { + rf::multi_chat_say("\xA6 Loading random level from rotation", false); + load_rand_level(); + } + }, + "Load random level from rotation", +}; + ConsoleCommand2 map_prev_cmd{ "map_prev", []() { @@ -194,6 +211,7 @@ void init_server_commands() map_ext_cmd.register_cmd(); map_rest_cmd.register_cmd(); map_next_cmd.register_cmd(); + map_rand_cmd.register_cmd(); map_prev_cmd.register_cmd(); AsmWriter(0x0047B6F0).jmp(ban_cmd_handler_hook); diff --git a/game_patch/multi/server.cpp b/game_patch/multi/server.cpp index 56ce6ca5..30e13ad0 100644 --- a/game_patch/multi/server.cpp +++ b/game_patch/multi/server.cpp @@ -40,6 +40,7 @@ const char* g_rcon_cmd_whitelist[] = { "map_ext", "map_rest", "map_next", + "map_rand", "map_prev", }; @@ -74,6 +75,7 @@ void load_additional_server_config(rf::Parser& parser) parse_vote_config("Vote Extend", g_additional_server_config.vote_extend, parser); parse_vote_config("Vote Restart", g_additional_server_config.vote_restart, parser); parse_vote_config("Vote Next", g_additional_server_config.vote_next, parser); + parse_vote_config("Vote Random", g_additional_server_config.vote_rand, parser); parse_vote_config("Vote Previous", g_additional_server_config.vote_previous, parser); if (parser.parse_optional("$DF Spawn Protection Duration:")) { g_additional_server_config.spawn_protection_duration_ms = parser.parse_uint(); @@ -169,6 +171,10 @@ void load_additional_server_config(rf::Parser& parser) } } + if (parser.parse_optional("$DF Random Rotation:")) { + g_additional_server_config.random_rotation = parser.parse_bool(); + } + if (parser.parse_optional("$DF Send Player Stats Message:")) { g_additional_server_config.stats_message_enabled = parser.parse_bool(); } @@ -208,6 +214,11 @@ CodeInjection dedicated_server_load_config_patch{ auto& parser = *reinterpret_cast(regs.esp - 4 + 0x4C0 - 0x470); load_additional_server_config(parser); + // if random rotation is on, shuffle rotation on server launch + if (g_additional_server_config.random_rotation) { + shuffle_level_array(); + } + // Insert server name in window title when hosting dedicated server std::string wnd_name; wnd_name.append(rf::netgame.name.c_str()); @@ -310,6 +321,32 @@ static void send_private_message_with_stats(rf::Player* player) send_chat_line_packet(str.c_str(), player); } +void shuffle_level_array() +{ + std::ranges::shuffle(rf::netgame.levels, g_rng); + xlog::info("Shuffled level rotation"); +} + +const char* get_rand_level_filename() +{ + const std::size_t num_levels = rf::netgame.levels.size(); + + if (num_levels <= 1) { + // nowhere else to go, we're staying here! + return rf::level_filename_to_load.c_str(); + } + + std::uniform_int_distribution dist_levels(0, num_levels - 1); + std::size_t rand_level_index = dist_levels(g_rng); + + // avoid selecting current level filename (unless it appears more than once on map list) + if (rf::netgame.levels[rand_level_index] == rf::level_filename_to_load) { + rand_level_index = (rand_level_index + 1) % num_levels; + } + + return rf::netgame.levels[rand_level_index].c_str(); +} + bool handle_server_chat_command(std::string_view server_command, rf::Player* sender) { auto [cmd_name, cmd_arg] = strip_by_space(server_command); @@ -664,6 +701,18 @@ CodeInjection multi_limbo_init_injection{ }, }; +CodeInjection multi_level_init_injection{ + 0x0046E450, + []() { + if (g_additional_server_config.random_rotation && rf::netgame.current_level_index == + rf::netgame.levels.size() - 1 && rf::netgame.levels.size() > 1) { + // if this is the last level in the list and dynamic rotation is on, shuffle + xlog::info("Reached end of level rotation, shuffling"); + shuffle_level_array(); + } + }, +}; + void server_init() { // Override rcon command whitelist @@ -729,6 +778,9 @@ void server_init() // Reduce limbo duration if server is empty multi_limbo_init_injection.install(); + + // Shuffle rotation when the last map in the list is loaded + multi_level_init_injection.install(); } void server_do_frame() diff --git a/game_patch/multi/server.h b/game_patch/multi/server.h index f3ec019f..5025e83f 100644 --- a/game_patch/multi/server.h +++ b/game_patch/multi/server.h @@ -12,3 +12,5 @@ bool check_server_chat_command(const char* msg, rf::Player* sender); bool server_is_saving_enabled(); void server_reliable_socket_ready(rf::Player* player); bool server_weapon_items_give_full_ammo(); +const char* get_rand_level_filename(); +void shuffle_level_array(); diff --git a/game_patch/multi/server_internal.h b/game_patch/multi/server_internal.h index 161eb770..9e26ab15 100644 --- a/game_patch/multi/server_internal.h +++ b/game_patch/multi/server_internal.h @@ -33,6 +33,7 @@ struct ServerAdditionalConfig VoteConfig vote_extend; VoteConfig vote_restart; VoteConfig vote_next; + VoteConfig vote_rand; VoteConfig vote_previous; int spawn_protection_duration_ms = 1500; std::optional spawn_life; @@ -50,6 +51,7 @@ struct ServerAdditionalConfig std::optional max_fov; int anticheat_level = 0; bool stats_message_enabled = true; + bool random_rotation = false; std::string welcome_message; bool weapon_items_give_full_ammo = false; float kill_reward_health = 0.0f; @@ -69,6 +71,7 @@ void init_server_commands(); void extend_round_time(int minutes); void restart_current_level(); void load_next_level(); +void load_rand_level(); void load_prev_level(); void server_vote_on_limbo_state_enter(); void process_delayed_kicks(); diff --git a/game_patch/multi/votes.cpp b/game_patch/multi/votes.cpp index 469c11d1..d24fef7c 100644 --- a/game_patch/multi/votes.cpp +++ b/game_patch/multi/votes.cpp @@ -323,6 +323,30 @@ struct VoteNext : public Vote } }; +struct VoteRandom : public Vote +{ + [[nodiscard]] std::string get_title() const override + { + return "LOAD RANDOM LEVEL"; + } + + void on_accepted() override + { + send_chat_line_packet("\xA6 Vote passed: loading random level from rotation", nullptr); + load_rand_level(); + } + + [[nodiscard]] bool is_allowed_in_limbo_state() const override + { + return false; + } + + [[nodiscard]] const VoteConfig& get_config() const override + { + return g_additional_server_config.vote_rand; + } +}; + struct VotePrevious : public Vote { [[nodiscard]] std::string get_title() const override @@ -449,6 +473,8 @@ void handle_vote_command(std::string_view vote_name, std::string_view vote_arg, g_vote_mgr.StartVote(vote_arg, sender); else if (vote_name == "next") g_vote_mgr.StartVote(vote_arg, sender); + else if (vote_name == "random" || vote_name == "rand") + g_vote_mgr.StartVote(vote_arg, sender); else if (vote_name == "previous" || vote_name == "prev") g_vote_mgr.StartVote(vote_arg, sender); else if (vote_name == "yes" || vote_name == "y")