/** * Author: Anton Tarasenko * Home repo: https://insultplayers.ru/git/AcediaFramework/AcediaCore/ * License: GPL * Copyright 2023 Anton Tarasenko *------------------------------------------------------------------------------ * This file is part of Acedia. * * Acedia is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, version 3 of the License, or * (at your option) any later version. * * Acedia is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Acedia. If not, see . */ class MapTool extends AcediaObject; //! 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; // Resulting full map list with pseudonim (with replaced prefixes) and real names of maps. var private array pseudonimMapList; var private array realMapList; // 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_ADMIN_MAP_CHANGE_COMMAND; var private const string ACEDIA_MAP_WON_COMMAND; var private LoggerAPI.Definition fatVotingHandlerMissing, warnMissingMapList; protected function Finalizer() { _server.unreal.broadcasts.OnHandleText(self).Disconnect(); _.memory.Free(votingHandlerReference); votingHandlerReference = none; pseudonimMapList.length = 0; realMapList.length = 0; usedMapSequences.length = 0; injectedMaps = false; } /// 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; initVotingHandlerReference.NewRef(); votingHandlerReference = initVotingHandlerReference; return true; } /// 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; nextRecord.bEnabled = true; gameModeMaps = GetAllGameModeMaps(gameMode); for (i = 0; i < gameModeMaps.GetLength(); i += 1) { mapNameReal = gameModeMaps.GetText(i); mapNamePseudonim = MakeMapPseudonim(mapNameReal, gameModePrefix); if (votingHandler.history != none) { nextMapInfo = votingHandler.history.GetMapHistory(mapNameReal.ToString()); nextRecord.playCount = nextMapInfo.p; nextRecord.sequence = nextMapInfo.s; } nextRecord.mapName = _.text.IntoString(mapNamePseudonim); newMapsPseudonim[newMapsPseudonim.length] = nextRecord; nextRecord.mapName = _.text.IntoString(mapNameReal); newMapReal[newMapReal.length] = nextRecord; } 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; local MutableText result; result = _.text.FromStringM(gameModePrefix); result.Append(P("-")); parser = realName.Parse(); parser.MUntil(prefix, _.text.GetCharacter("-")); parser.Match(P("-")); if (parser.Ok()) { nameBody = parser.GetRemainderM(); result.Append(nameBody); } else { result.Append(realName); } _.memory.Free(nameBody); _.memory.Free(prefix); _.memory.Free(parser); return result.IntoText(); } private function ArrayList GetAllGameModeMaps(GameMode gameMode) { local int i, j; local HashTable uniqueMapSet; local ArrayList result; local array usedMapLists; local array nextMapArray; local Text nextMapName, lowerMapName; uniqueMapSet = _.collections.EmptyHashTable(); // for testing map name uniqueness result = _.collections.EmptyArrayList(); usedMapLists = gameMode.GetIncludedMapLists(); for (i = 0; i < usedMapLists.length; i += 1) { 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); result.AddItem(nextMapName); } _.memory.Free(lowerMapName); _.memory.Free(nextMapName); } } _.memory.Free(uniqueMapSet); _.memory.FreeMany(usedMapLists); return result; } 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; local XVotingHandler votingHandler; votingHandler = GetVotingHandler(); if (votingHandler == none) { _.logger.Auto(fatVotingHandlerMissing); return; } for (i = 0; i < newMapsPseudonim.length; i += 1) { pseudonimMapList[pseudonimMapList.length] = newMapsPseudonim[i]; } for (i = 0; i < newMapsReal.length; i += 1) { realMapList[realMapList.length] = newMapsReal[i]; } } private function bool HandleMapChange( Actor sender, out string message, name type, bool teamMessage ) { local Parser parser; local XVotingHandler votingHandler; votingHandler = GetVotingHandler(); if (sender == none) return true; if (votingHandler != sender) return true; parser = _.text.ParseString(message); parser.Match(P(ACEDIA_MAP_WON_COMMAND)); parser.Match(P("::")); if (parser.Ok()) { message = Repl(backupMessageMapWon, "%mapname%", parser.GetRemainderS()); } else { parser.Match(P(ACEDIA_ADMIN_MAP_CHANGE_COMMAND)); parser.Match(P("::")); if (parser.Ok()) { message = Repl(backupMessageAdminMapChange, "%mapname%", parser.GetRemainderS()); } } if (parser.Ok()) { votingHandler.mapList = realMapList; votingHandler.mapCount = realMapList.length; } _.memory.Free(parser); return true; } private function XVotingHandler GetVotingHandler() { if (votingHandlerReference != none) { return XVotingHandler(votingHandlerReference.Get()); } return none; } defaultproperties { 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`.") }