diff --git a/sources/GameModes/BaseGameMode.uc b/sources/GameModes/BaseGameMode.uc index 533fe51..144dede 100644 --- a/sources/GameModes/BaseGameMode.uc +++ b/sources/GameModes/BaseGameMode.uc @@ -10,7 +10,7 @@ * be used based on game info's settings; * 3. `Report...()` methods that perform various validation checks * (and log them) on config data. - * Copyright 2021-2022 Anton Tarasenko + * Copyright 2021-2023 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -436,6 +436,11 @@ public function array GetIncludedMapLists() return StringToTextArray(includeMaps); } +public function array GetIncludedMapLists_S() +{ + return includeMaps; +} + defaultproperties { configName = "AcediaGameModes" diff --git a/sources/GameModes/GameMode.uc b/sources/GameModes/GameMode.uc index 1e78bb2..72310db 100644 --- a/sources/GameModes/GameMode.uc +++ b/sources/GameModes/GameMode.uc @@ -1,7 +1,7 @@ /** * The only implementation for `BaseGameMode` suitable for standard * killing floor game types. - * Copyright 2021-2022 Anton Tarasenko + * Copyright 2021-2023 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -35,9 +35,6 @@ var protected config string gameTypeClass; // Short version of the name of the game mode players will see in // voting handler messages sometimes (plain string) var protected config string acronym; -// Map prefix - only maps that start with specified prefix will be voteable for -// this game mode (plain string) -var protected config string mapPrefix; // Aliases are an unnecessary overkill for difficulty names, so just define // them in special `string` arrays. @@ -57,7 +54,6 @@ protected function DefaultIt() difficulty = "Hell On Earth"; gameTypeClass = "KFMod.KFGameType"; acronym = ""; - mapPrefix = "KF"; includeFeature.length = 0; excludeFeature.length = 0; includeMutator.length = 0; @@ -76,7 +72,6 @@ protected function HashTable ToData() } result.SetString(P("gameTypeClass"), gameTypeClass); result.SetString(P("acronym"), acronym); - result.SetString(P("mapPrefix"), mapPrefix); nextArray = _.collections.EmptyArrayList(); for (i = 0; i < option.length; i += 1) @@ -105,7 +100,6 @@ protected function FromData(HashTable source) } gameTypeClass = source.GetString(P("gameTypeClass")); acronym = source.GetString(P("acronym")); - mapPrefix = source.GetString(P("mapPrefix")); nextArray = source.GetArrayList(P("option")); if (nextArray == none) { @@ -146,16 +140,6 @@ public function Text GetAcronym() } } -public function Text GetMapPrefix() -{ - if (mapPrefix == "") { - return _.text.FromString("KF-"); - } - else { - return _.text.FromString(mapPrefix); - } -} - /** * Checks option-related settings (`option`) for correctness and reports * any issues. diff --git a/sources/MapList/MapTool.uc b/sources/MapList/MapTool.uc index ba5ec86..b736b70 100644 --- a/sources/MapList/MapTool.uc +++ b/sources/MapList/MapTool.uc @@ -1,5 +1,8 @@ /** - * Copyright 2023 Anton Tarasenko + * Author: Anton Tarasenko + * Home repo: https://insultplayers.ru/git/AcediaFramework/AcediaCore/ + * License: GPL + * Copyright 2023 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * @@ -18,35 +21,74 @@ */ class MapTool extends AcediaObject; -// Finding voting handler is not cheap, so only do it once and then store it. +//! Tool for adapting AcediaLauncher's map lists for [`xVotingHandler`]. +//! +//! This class is responsible for making [`xVotingHandler`] use specific maps (defined in +//! AcediaLauncer's configs) for specific game modes. +//! To achieve that it abuses [`xVotingHandler`]'s ability to filter maps by +//! game mode-specific prefix. +//! Normally prefix filtering for [`xVotingHandler`] is of limited use, because most Killing Floor +//! maps start with the same prefix `KF-` (and some objective ones starting with `KFO-`). +//! However we swap that prefix for something unique for each game mode: `MapSet0-`, `MapSet1-`, +//! etc, allowing us to pick the precise map set we want. +//! +//! There's two main challenges: +//! +//! 1. *Altered map names break voting* - since [`xVotingHandler`] expects to be provided real map +//! names and our mangled ones. We deal with it by catching a map change message broadcasted +//! right before actual map change occurs and swap our names with real ones. +//! 2. *Increased amount of maps to replicate* - if we implement this name mangling by using +//! a naive approach, in which we separately add maps for every game mode, then it will lead to +//! drastic increase in replication time of the complete map list to players. +//! Consider, for example, that you have 10 different game modes with exactly the same maps: +//! we will be needlessly replicating the exact same thing 10 times! +//! To solve this issue we specifically track map lists other game modes use, along with +//! prefixes assigned to them, and reuse already added maps in case two game modes are defined +//! to use the exactly same ones. + +/// For storing which map sequences have which prefixes. Storage order is important. +struct MapSequenceRecord { + var public array sequence; + var public string prefix; +}; + +// To avoid doing excesive work when injecting maps for a second time +var private bool injectedMaps; + +// Finding voting handler is not cheap, so only do it once and then store it. var private NativeActorRef votingHandlerReference; -// Maps map pseudonims we've used in voting handler to real map names -var private HashTable pseudonimToMap; +// Resulting full map list with pseudonim (with replaced prefixes) and real names of maps. var private array pseudonimMapList; var private array realMapList; -var private int gameModesSeen; +// Map sequences used by game modes we've seen so far. +var private array usedMapSequences; + +// To more easily detect broadcasted message about map change we replace it with our own that is +// both unlikely to occur and is easy to get voted map name from. var private string backupMessageMapWon; var private string backupMessageAdminMapChange; -var private const string ACEDIA_MAP_FORCED_COMMAND; +var private const string ACEDIA_ADMIN_MAP_CHANGE_COMMAND; var private const string ACEDIA_MAP_WON_COMMAND; -protected function Constructor() { - pseudonimToMap = _.collections.EmptyHashTable(); -} +var private LoggerAPI.Definition fatVotingHandlerMissing, warnMissingMapList; protected function Finalizer() { _server.unreal.broadcasts.OnHandleText(self).Disconnect(); _.memory.Free(votingHandlerReference); - _.memory.Free(pseudonimToMap); votingHandlerReference = none; - pseudonimToMap = none; pseudonimMapList.length = 0; realMapList.length = 0; - gameModesSeen = 0; + usedMapSequences.length = 0; + injectedMaps = false; } -public function bool Initialize(NativeActorRef initVotingHandlerReference) { +/// Initializes [`MapTool`] by associating it with an [`XVotingHandler`]. +/// +/// Initialization fails if [`initVotingHandlerReference`] doesn't provide reference to +/// [`XVotingHandler`] or caller [`MapTool`] already was initialized. +/// Returns `true` iff initialization was successful. +public final function bool Initialize(NativeActorRef initVotingHandlerReference) { if (initVotingHandlerReference == none) return false; if (XVotingHandler(initVotingHandlerReference.Get()) == none) return false; @@ -55,47 +97,121 @@ public function bool Initialize(NativeActorRef initVotingHandlerReference) { return true; } -public function string LoadGameModeMaps(GameMode gameMode) { - local int i; +/// Adds map information from the new game mode. +/// +/// Returns prefix that given game mode must use to display maps configured for it. +public final function string AddGameMode(GameMode gameMode) { + local XVotingHandler votingHandler; + local string gameModePrefix; + + votingHandler = GetVotingHandler(); + if (votingHandler == none) { + _.logger.Auto(fatVotingHandlerMissing); + return "KF"; + } + if (CheckNeedToLoadMaps(gameMode, gameModePrefix)) { + LoadGameModeMaps(gameMode, gameModePrefix, votingHandler); + } + return gameModePrefix; +} + +/// Injects final map list into [`XVotingHandler`]. +/// +/// Call this after all game modes have been added. +/// Shouldn't be called more than once. +public final function Inject() { local XVotingHandler votingHandler; + + votingHandler = GetVotingHandler(); + if (votingHandler == none) { + _.logger.Auto(fatVotingHandlerMissing); + return; + } + votingHandler.mapList = pseudonimMapList; + votingHandler.mapCount = pseudonimMapList.length; + // Replace map change messages with our commands and make sure it is done only once, + // in case we mess up somewhere else and call this method second time + if (!injectedMaps) { + backupMessageMapWon = votingHandler.lmsgMapWon; + backupMessageAdminMapChange = votingHandler.lmsgAdminMapChange; + votingHandler.lmsgMapWon = ACEDIA_MAP_WON_COMMAND $ "::%mapname%"; + votingHandler.lmsgAdminMapChange = ACEDIA_ADMIN_MAP_CHANGE_COMMAND $ "::%mapname%"; + _server.unreal.broadcasts.OnHandleText(self).connect = HandleMapChange; + } + injectedMaps = true; +} + +/// Builds arrays of [`VotingHandler::MapVoteMapList`] (each such item describes a map + +/// its meta data in a way [`XVotingHandler`] understands). +private function string LoadGameModeMaps( + GameMode gameMode, + string gameModePrefix, + XVotingHandler votingHandler +) { + local int i; local ArrayList gameModeMaps; local Text mapNameReal, mapNamePseudonim; local VotingHandler.MapHistoryInfo nextMapInfo; local VotingHandler.MapVoteMapList nextRecord; local array newMapsPseudonim, newMapReal; - local string gameModePrefix; - votingHandler = GetVotingHandler(); - if (votingHandler == none) { - return "!!!"; - } - gameModePrefix = ("KF" $ gameModesSeen); nextRecord.bEnabled = true; gameModeMaps = GetAllGameModeMaps(gameMode); for (i = 0; i < gameModeMaps.GetLength(); i += 1) { - // Make a pseudonim to map connection mapNameReal = gameModeMaps.GetText(i); mapNamePseudonim = MakeMapPseudonim(mapNameReal, gameModePrefix); - pseudonimToMap.SetItem(mapNamePseudonim, mapNameReal); - // Setup `VotingHandler.MapVoteMapList` struct for next map if (votingHandler.history != none) { nextMapInfo = votingHandler.history.GetMapHistory(mapNameReal.ToString()); nextRecord.playCount = nextMapInfo.p; nextRecord.sequence = nextMapInfo.s; - } else { - nextRecord.playCount = 0; - nextRecord.sequence = 0; } - nextRecord.mapName = mapNamePseudonim.ToString(); + nextRecord.mapName = _.text.IntoString(mapNamePseudonim); newMapsPseudonim[newMapsPseudonim.length] = nextRecord; - nextRecord.mapName = mapNameReal.ToString(); + nextRecord.mapName = _.text.IntoString(mapNameReal); newMapReal[newMapReal.length] = nextRecord; } - AppendMaps(newMapsPseudonim, newMapReal); - gameModesSeen += 1; + AppendMapsIntoVotingHandler(newMapsPseudonim, newMapReal); + _.memory.Free(gameModeMaps); return gameModePrefix; } +private function bool CheckNeedToLoadMaps(GameMode gameMode, out string prefix) { + local int mapSequenceIndex, mapListIndex; + local bool sameMapList, foundMatch; + local array existingMapSequence, newMapSequence; + local MapSequenceRecord newRecord; + + // We don't need to load maps for the `gameMode` only when we've already added the exactly same + // map sequence, order being important + newMapSequence = gameMode.GetIncludedMapLists_S(); + for (mapSequenceIndex = 0; mapSequenceIndex < usedMapSequences.length; mapSequenceIndex += 1) { + existingMapSequence = usedMapSequences[mapSequenceIndex].sequence; + if (existingMapSequence.length != newMapSequence.length) { + continue; + } + foundMatch = true; + for (mapListIndex = 0; mapListIndex < newMapSequence.length; mapListIndex += 1) { + // Map lists are ASCII config names, so we can compare them with case-ignoring + // built-in `~=` operator works (it can only handle properly ASCII input) + sameMapList = (existingMapSequence[mapListIndex] ~= newMapSequence[mapListIndex]); + if (!sameMapList) { + foundMatch = false; + break; + } + } + if (foundMatch) { + prefix = usedMapSequences[mapSequenceIndex].prefix; + return false; + } + } + newRecord.sequence = newMapSequence; + newRecord.prefix = "MapSet" $ usedMapSequences.length; + usedMapSequences[usedMapSequences.length] = newRecord; + prefix = newRecord.prefix; + return true; +} + +// Replaces prefixes like "KF-", "KFO-" or "KFS-" with "{gameModePrefix}-". private function Text MakeMapPseudonim(Text realName, string gameModePrefix) { local Parser parser; local MutableText prefix, nameBody; @@ -124,26 +240,17 @@ private function ArrayList GetAllGameModeMaps(GameMode gameMode) { local HashTable uniqueMapSet; local ArrayList result; local array usedMapLists; - local MapList nextMapConfig; local array nextMapArray; local Text nextMapName, lowerMapName; - uniqueMapSet = _.collections.EmptyHashTable(); // to quickly make sure we add each map only once + uniqueMapSet = _.collections.EmptyHashTable(); // for testing map name uniqueness result = _.collections.EmptyArrayList(); usedMapLists = gameMode.GetIncludedMapLists(); for (i = 0; i < usedMapLists.length; i += 1) { - // Get maps from `MapList` config - nextMapConfig = MapList(class'MapList'.static.GetConfigInstance(usedMapLists[i])); - nextMapArray.length = 0; - if (nextMapConfig != none) { - nextMapArray = nextMapConfig.map; - } else { - //_.logger.Auto(warnMissingMapList).Arg(usedMapLists[i].Copy()); - } - _.memory.Free(nextMapConfig); - // Add maps we haven't yet added from other lists + nextMapArray = GetMapNameFromConfig(usedMapLists[i]); for (j = 0; j < nextMapArray.length; j += 1) { nextMapName = _.text.FromString(nextMapArray[j]); + // Use lower case version of map name for uniqueness testing to ignore characters' case lowerMapName = nextMapName.LowerCopy(); if (!uniqueMapSet.HasKey(lowerMapName)) { uniqueMapSet.SetItem(lowerMapName, none); @@ -158,7 +265,21 @@ private function ArrayList GetAllGameModeMaps(GameMode gameMode) { return result; } -private function AppendMaps( +private function array GetMapNameFromConfig(Text configName) { + local MapList mapConfig; + local array result; + + mapConfig = MapList(class'MapList'.static.GetConfigInstance(configName)); + if (mapConfig == none) { + _.logger.Auto(warnMissingMapList).Arg(configName.Copy()); + } else { + result = mapConfig.map; + _.memory.Free(mapConfig); + } + return result; +} + +private function AppendMapsIntoVotingHandler( array newMapsPseudonim, array newMapsReal) { local int i; @@ -166,7 +287,7 @@ private function AppendMaps( votingHandler = GetVotingHandler(); if (votingHandler == none) { - warn("votingHandler is none!"); + _.logger.Auto(fatVotingHandlerMissing); return; } for (i = 0; i < newMapsPseudonim.length; i += 1) { @@ -177,21 +298,6 @@ private function AppendMaps( } } -public final function InjectMaps() { - local XVotingHandler votingHandler; - - votingHandler = GetVotingHandler(); - if (votingHandler != none) { - votingHandler.mapList = pseudonimMapList; - votingHandler.mapCount = pseudonimMapList.length; - backupMessageMapWon = votingHandler.lmsgMapWon; - backupMessageAdminMapChange = votingHandler.lmsgAdminMapChange; - votingHandler.lmsgMapWon = ACEDIA_MAP_WON_COMMAND $ "::%mapname%"; - votingHandler.lmsgAdminMapChange = ACEDIA_MAP_FORCED_COMMAND $ "::%mapname%"; - _server.unreal.broadcasts.OnHandleText(self).connect = HandleMapChange; - } -} - private function bool HandleMapChange( Actor sender, out string message, @@ -211,7 +317,7 @@ private function bool HandleMapChange( if (parser.Ok()) { message = Repl(backupMessageMapWon, "%mapname%", parser.GetRemainderS()); } else { - parser.Match(P(ACEDIA_MAP_FORCED_COMMAND)); + parser.Match(P(ACEDIA_ADMIN_MAP_CHANGE_COMMAND)); parser.Match(P("::")); if (parser.Ok()) { message = Repl(backupMessageAdminMapChange, "%mapname%", parser.GetRemainderS()); @@ -233,6 +339,8 @@ private function XVotingHandler GetVotingHandler() { } defaultproperties { - ACEDIA_MAP_FORCED_COMMAND = "ACEDIA_LAUNCHER:MAP_FORCED:DEADBEEF" + ACEDIA_ADMIN_MAP_CHANGE_COMMAND = "ACEDIA_LAUNCHER:ADMIN_MAP_CHANGE:DEADBEEF" ACEDIA_MAP_WON_COMMAND = "ACEDIA_LAUNCHER:MAP_WON:DEADBEEF" + fatVotingHandlerMissing = (l=LOG_Fatal,m="No voting `XVotingHandler` available. This is unexpected at this stage. Report this issue.") + warnMissingMapList = (l=LOG_Warning,m="Cannot find map list `%1`.") } \ No newline at end of file diff --git a/sources/VotingHandlerAdapter.uc b/sources/VotingHandlerAdapter.uc index d6e6216..fcd8a3f 100644 --- a/sources/VotingHandlerAdapter.uc +++ b/sources/VotingHandlerAdapter.uc @@ -60,25 +60,28 @@ class VotingHandlerAdapter extends AcediaObject // the same index. var private array availableGameModes; -// Finding voting handler is not cheap, so only do it once and then store it. -var private NativeActorRef votingHandlerReference; -// Save `VotingHandler`'s config to restore it before server travel - -// otherwise Acedia will alter its config -var private array backupVotingHandlerConfig; +// Finding voting handler is not cheap, so only do it once and then store it. +var private NativeActorRef votingHandlerReference; +// Save `VotingHandler`'s config to restore it before server travel - otherwise Acedia will alter +// its config +var private array backupVotingHandlerConfig; +// Map list management logic +var private MapTool mapTool; // Setting value of this flag to `true` indicates that map switching just // occurred and we need to recover some information from the previous map. -var private config bool isServerTraveling; +var private config bool isServerTraveling; // We should not rely on "VotingHandler" to inform us from which game mode its // selected config option originated after server travel, so we need to // remember it in this config variable before switching maps. -var private config string targetGameMode; +var private config string targetGameMode; // Acedia's game modes intend on supporting difficulty switching, but // `KFGameType` does not support appropriate flags, so we enforce default // difficulty by overwriting default value of its `gameDifficulty` variable. // But to not affect game's configs we must restore old value after new map is // loaded. Store it in config variable for that. -var private config int storedGameLength; +var private config int storedGameLength; + // Aliases are an unnecessary overkill for difficulty names, so just define // them in special `string` arrays. @@ -90,12 +93,17 @@ var private const array longSynonyms; var private LoggerAPI.Definition fatNoXVotingHandler, fatBadGameConfigIndexVH; var private LoggerAPI.Definition fatBadGameConfigIndexAdapter, warnMissingMapList; -protected function Finalizer() -{ +protected function Constructor() { + mapTool = MapTool(_.memory.Allocate(class'MapTool')); +} + +protected function Finalizer() { + _.memory.Free(mapTool); _.memory.Free(votingHandlerReference); _.memory.FreeMany(availableGameModes); - votingHandlerReference = none; - availableGameModes.length = 0; + mapTool = none; + votingHandlerReference = none; + availableGameModes.length = 0; } /** @@ -105,33 +113,30 @@ protected function Finalizer() */ public final function InjectIntoVotingHandler() { - local int i; - local string nextGameModePrefix; - local GameMode nextGameMode; - local XVotingHandler votingHandler; - local array newVotingHandlerConfig; - local MapTool mapTool; + local int i; + local string nextGameModePrefix; + local GameMode nextGameMode; + local XVotingHandler votingHandler; + local array newVotingHandlerConfig; + // `votingHandlerReference != none` means that we've already injected into voting handler if (votingHandlerReference != none) { return; } votingHandler = XVotingHandler(_server.unreal.FindActorInstance( - _server.unreal.GetGameType().VotingHandlerClass)); - if (votingHandler == none) - { + _server.unreal.GetGameType().votingHandlerClass)); + if (votingHandler == none) { _.logger.Auto(fatNoXVotingHandler); return; } votingHandlerReference = _server.unreal.ActorRef(votingHandler); - mapTool = MapTool(_.memory.Allocate(class'MapTool')); - mapTool.Initialize(votingHandlerReference); //TODO check return value + // This cannot actuall fail at this point - we have valid `votingHandler` reference and + // `mapTool` is only initialized here (which can be executed only once) + mapTool.Initialize(votingHandlerReference); availableGameModes = class'GameMode'.static.AvailableConfigs(); - votingHandler.mapCount = 0; - votingHandler.mapList.length = 0; for (i = 0; i < availableGameModes.length; i += 1) { - nextGameMode = GameMode(class'GameMode'.static - .GetConfigInstance(availableGameModes[i])); - nextGameModePrefix = mapTool.LoadGameModeMaps(nextGameMode); + nextGameMode = GameMode(class'GameMode'.static.GetConfigInstance(availableGameModes[i])); + nextGameModePrefix = mapTool.AddGameMode(nextGameMode); newVotingHandlerConfig[i] = BuildVotingHandlerConfig(nextGameMode, nextGameModePrefix); // Setup proper game mode index if (availableGameModes[i].ToString() == targetGameMode) { @@ -142,10 +147,9 @@ public final function InjectIntoVotingHandler() nextGameMode.ReportBadOptions(); _.memory.Free(nextGameMode); } - backupVotingHandlerConfig = votingHandler.gameConfig; - votingHandler.gameConfig = newVotingHandlerConfig; - mapTool.InjectMaps(); - //_.memory.Free(mapTool); + backupVotingHandlerConfig = votingHandler.gameConfig; + votingHandler.gameConfig = newVotingHandlerConfig; + mapTool.Inject(); } private function VotingHandler.MapVoteGameConfig BuildVotingHandlerConfig( @@ -370,7 +374,6 @@ defaultproperties normalSynonyms(1) = "medium" normalSynonyms(2) = "regular" longSynonyms(0) = "long" - warnMissingMapList = (l=LOG_Warning,m="Cannot find map list `%1`.") fatNoXVotingHandler = (l=LOG_Fatal,m="`XVotingHandler` class is missing. Make sure your server setup supports Acedia's game modes (by used voting handler derived from `XVotingHandler`).") fatBadGameConfigIndexVH = (l=LOG_Fatal,m="`XVotingHandler`'s `currentGameConfig` variable value of %1 is out-of-bounds for `XVotingHandler.gameConfig` of length %2. Report this issue.") fatBadGameConfigIndexAdapter = (l=LOG_Fatal,m="`XVotingHandler`'s `currentGameConfig` variable value of %1 is out-of-bounds for `VHAdapter` of length %2. Report this issue.")