diff --git a/sources/BaseAPI/API/Commands/Tools/CmdItemsTool.uc b/sources/BaseAPI/API/Commands/Tools/CmdItemsTool.uc new file mode 100644 index 0000000..e441249 --- /dev/null +++ b/sources/BaseAPI/API/Commands/Tools/CmdItemsTool.uc @@ -0,0 +1,306 @@ +/** + * Author: dkanus + * Home repo: https://www.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 CmdItemsTool extends AcediaObject + dependson(CommandAPI) + abstract; + +//! This is a base class for auxiliary objects that will be used for storing +//! named [`Command`] instances and [`Voting`] classes: they both have in common +//! the need to remember who was authorized to use them (i.e. which user group) +//! and with what permissions (i.e. name of the config that contains appropriate +//! permissions). +//! +//! Aside from trivial accessors to its data, it also provides a way to resolve +//! the best permissions available to the user by finding the most priviledged +//! group he belongs to. +//! +//! NOTE: child classes must implement `MakeCard()` method and can override +//! `DiscardCard()` method to catch events of removing items from storage. + +/// Allows to specify a base class requirement for this tool - only classes +/// that were derived from it can be stored inside. +var protected const class ruleBaseClass; + +/// Names of user groups that can decide permissions for items, +/// in order of importance: from most significant to the least significant. +/// This is used for resolving the best permissions for each user. +var private array permissionGroupOrder; + +/// Maps item names to their [`ItemCards`] with information about which groups +/// are authorized to use this particular item. +var private HashTable registeredCards; + +var LoggerAPI.Definition errItemInvalidName; +var LoggerAPI.Definition errItemDuplicate; + +protected function Constructor() { + registeredCards = _.collections.EmptyHashTable(); +} + +protected function Finalizer() { + _.memory.Free(registeredCards); + _.memory.FreeMany(permissionGroupOrder); + registeredCards = none; + permissionGroupOrder.length = 0; +} + +/// Registers given item class under the specified (case-insensitive) name. +/// +/// If name parameter is omitted (specified as `none`) or is an invalid name +/// (according to [`BaseText::IsValidName()`] method), then item class will not +/// be registered. +/// +/// Returns `true` if item was successfully registered and `false` otherwise`. +/// +/// # Errors +/// +/// If provided name that is invalid or already taken by a different item - +/// a warning will be logged and item class won't be registered. +public function bool AddItemClass(class itemClass, BaseText itemName) { + local Text itemKey; + local ItemCard newCard, existingCard; + + if (itemClass == none) return false; + if (itemName == none) return false; + if (registeredCards == none) return false; + + if (ruleBaseClass == none || !ClassIsChildOf(itemClass, ruleBaseClass)) { + return false; + } + // The item name is transformed into lowercase, immutable value. + // This facilitates the use of item names as keys in a [`HashTable`], + // enabling case-insensitive matching. + itemKey = itemName.LowerCopy(); + if (itemKey == none || !itemKey.IsValidName()) { + _.logger.Auto(errItemInvalidName).ArgClass(itemClass).Arg(itemKey); + return false; + } + // Guaranteed to only store cards + existingCard = ItemCard(registeredCards.GetItem(itemName)); + if (existingCard != none) { + _.logger.Auto(errItemDuplicate) + .ArgClass(existingCard.GetItemClass()) + .Arg(itemKey) + .ArgClass(itemClass); + _.memory.Free(existingCard); + return false; + } + newCard = MakeCard(itemClass, itemName); + registeredCards.SetItem(itemKey, newCard); + _.memory.Free2(itemKey, newCard); + return true; +} + +/// Removes item of given class from the list of registered items. +/// +/// Removing once registered item is not an action that is expected to +/// be performed under normal circumstances and does not have an efficient +/// implementation (it is linear on the current amount of items). +/// +/// Returns `true` if successfully removed registered item class and +/// `false` otherwise (either item wasn't registered or caller tool +/// initialized). +public function bool RemoveItemClass(class itemClass) { + local int i; + local CollectionIterator iter; + local ItemCard nextCard; + local array keysToRemove; + + if (itemClass == none) return false; + if (registeredCards == none) return false; + + // Removing items during iterator breaks an iterator, so first we find + // all the keys to remove + iter = registeredCards.Iterate(); + iter.LeaveOnlyNotNone(); + while (!iter.HasFinished()) { + // Guaranteed to only be `ItemCard` + nextCard = ItemCard(iter.Get()); + if (nextCard.GetItemClass() == itemClass) { + keysToRemove[keysToRemove.length] = Text(iter.GetKey()); + DiscardCard(nextCard); + } + _.memory.Free(nextCard); + iter.Next(); + } + iter.FreeSelf(); + // Actual clean up everything in `keysToRemove` + for (i = 0; i < keysToRemove.length; i += 1) { + registeredCards.RemoveItem(keysToRemove[i]); + } + _.memory.FreeMany(keysToRemove); + return (keysToRemove.length > 0); +} + +/// Allows to specify the order of the user group in terms of privilege for +/// accessing stored items. Only specified groups will be used when resolving +/// appropriate permissions config name for a user. +public final function SetPermissionGroupOrder(array groupOrder) { + local int i; + + _.memory.FreeMany(permissionGroupOrder); + permissionGroupOrder.length = 0; + for (i = 0; i < groupOrder.length; i += 1) { + if (groupOrder[i] != none) { + permissionGroupOrder[permissionGroupOrder.length] = groupOrder[i].Copy(); + } + } +} + +/// Specifies what permissions (given by the config name) given user group has +/// when using an item with a specified name. +/// +/// Method must be called after item with a given name is added. +/// +/// If this config name is specified as `none`, then "default" will be +/// used instead. For non-`none` values, only an invalid name (according to +/// [`BaseText::IsValidName()`] method) will prevent the group from being +/// registered. +/// +/// Method will return `true` if group was successfully authorized and `false` +/// otherwise (either group already authorized or no item with specified name +/// was added in the caller tool so far). +/// +/// # Errors +/// +/// If specified group was already authorized to use card's item, then it +/// will log a warning message about it. +public function bool AuthorizeUsage(BaseText itemName, BaseText groupName, BaseText configName) { + local bool result; + local ItemCard relevantCard; + + if (configName != none && !configName.IsValidName()) { + return false; + } + relevantCard = GetCard(itemName); + if (relevantCard != none) { + result = relevantCard.AuthorizeGroupWithConfig(groupName, configName); + _.memory.Free(relevantCard); + } + return result; +} + +/// Returns struct with item class (+ instance, if one was stored) for a given +/// case in-sensitive item name and name of the config with best permissions +/// available to the player with provided ID. +/// +/// Function only returns `none` for item class if item with a given name +/// wasn't found. +/// Config name being `none` with non-`none` item class in the result means +/// that user with provided ID doesn't have permissions for using the item at +/// all. +public final function CommandAPI.ItemConfigInfo ResolveItem(BaseText itemName, BaseText textID) { + local int i; + local ItemCard relevantCard; + local CommandAPI.ItemConfigInfo result; + + relevantCard = GetCard(itemName); + if (relevantCard == none) { + // At this point contains `none` for all values -> indicates a failure + // to find item in storage + return result; + } + result.instance = relevantCard.GetItem(); + result.class = relevantCard.GetItemClass(); + if (textID == none) { + return result; + } + // Look through all `permissionGroupOrder` in order to find most priviledged + // group that user with `textID` belongs to + for (i = 0; i < permissionGroupOrder.length && result.configName == none; i += 1) { + if (_.users.IsSteamIDInGroup(textID, permissionGroupOrder[i])) { + result.configName = relevantCard.GetConfigNameForGroup(permissionGroupOrder[i]); + } + } + _.memory.Free(relevantCard); + return result; +} + +/// Returns all item classes that are stored inside caller tool. +/// +/// Doesn't check for duplicates (although with a normal usage, there shouldn't +/// be any). +public final function array< class > GetAllItemClasses() { + local array< class > result; + local ItemCard value; + local CollectionIterator iter; + + for (iter = registeredCards.Iterate(); !iter.HasFinished(); iter.Next()) { + value = ItemCard(iter.Get()); + if (value != none) { + result[result.length] = value.GetItemClass(); + } + _.memory.Free(value); + } + iter.FreeSelf(); + return result; +} + +/// Returns array of names of all available items. +public final function array GetItemsNames() { + local array emptyResult; + + if (registeredCards != none) { + return registeredCards.GetTextKeys(); + } + return emptyResult; +} + +/// Called each time a new card is to be created and stored. +/// +/// Must be reimplemented by child classes. +protected function ItemCard MakeCard(class itemClass, BaseText itemName) { + return none; +} + +/// Called each time a certain card is to be removed from storage. +/// +/// Must be reimplemented by child classes +/// (reimplementations SHOULD NOT DEALLOCATE `toDiscard`). +protected function DiscardCard(ItemCard toDiscard) { +} + +/// Find item card for the item that was stored with a specified +/// case-insensitive name +/// +/// Function only returns `none` if item with a given name wasn't found +/// (or `none` was provided as an argument). +protected final function ItemCard GetCard(BaseText itemName) { + local Text itemKey; + local ItemCard relevantCard; + + if (itemName == none) return none; + if (registeredCards == none) return none; + + /// The item name is transformed into lowercase, immutable value. + /// This facilitates the use of item names as keys in a [`HashTable`], + /// enabling case-insensitive matching. + itemKey = itemName.LowerCopy(); + relevantCard = ItemCard(registeredCards.GetItem(itemKey)); + _.memory.Free(itemKey); + return relevantCard; +} + +defaultproperties { + errItemInvalidName = (l=LOG_Error,m="Attempt at registering item with class `%1` under an invalid name \"%2\" will be ignored.") + errItemDuplicate = (l=LOG_Error,m="Command `%1` is already registered with name '%2'. Attempt at registering command `%3` with the same name will be ignored.") +} \ No newline at end of file diff --git a/sources/BaseAPI/API/Commands/Tools/CommandsTool.uc b/sources/BaseAPI/API/Commands/Tools/CommandsTool.uc new file mode 100644 index 0000000..5efd7c7 --- /dev/null +++ b/sources/BaseAPI/API/Commands/Tools/CommandsTool.uc @@ -0,0 +1,142 @@ +/** + * Author: dkanus + * Home repo: https://www.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 CommandsTool extends CmdItemsTool; + +//! This is a base class for auxiliary objects that will be used for storing +//! named [`Command`] instances. +//! +//! This storage class allows for efficient manipulation and retrieval of +//! [`Command`]s, along with information about what use groups were authorized +//! to use them. +//! +//! Additionally, this tool allows for efficient fetching of commands that +//! belong to a particular *command group*. + +/// [`HashTable`] that maps a command group name to a set of command names that +/// belong to it. +var private HashTable groupedCommands; + +protected function Constructor() { + super.Constructor(); + groupedCommands = _.collections.EmptyHashTable(); +} + +protected function Finalizer() { + super.Finalizer(); + _.memory.Free(groupedCommands); + groupedCommands = none; +} + +/// Returns all known command groups' names. +public final function array GetGroupsNames() { + local array emptyResult; + + if (groupedCommands != none) { + return groupedCommands.GetTextKeys(); + } + return emptyResult; +} + +/// Returns array of names of all available commands belonging to the specified +/// group. +public final function array GetCommandNamesInGroup(BaseText groupName) { + local int i; + local ArrayList commandNamesArray; + local array result; + + if (groupedCommands == none) return result; + commandNamesArray = groupedCommands.GetArrayList(groupName); + if (commandNamesArray == none) return result; + + for (i = 0; i < commandNamesArray.GetLength(); i += 1) { + result[result.length] = commandNamesArray.GetText(i); + } + _.memory.Free(commandNamesArray); + return result; +} + +protected function ItemCard MakeCard(class commandClass, BaseText itemName) { + local Command newCommandInstance; + local ItemCard newCard; + local Text commandGroup; + + if (class(commandClass) != none) { + newCommandInstance = Command(_.memory.Allocate(commandClass, true)); + newCommandInstance.Initialize(itemName); + newCard = ItemCard(_.memory.Allocate(class'ItemCard')); + newCard.InitializeWithInstance(newCommandInstance); + + // Guaranteed to be lower case (keys of [`HashTable`]) + if (itemName != none) { + itemName = itemName.LowerCopy(); + } else { + itemName = newCommandInstance.GetPreferredName(); + } + commandGroup = newCommandInstance.GetGroupName(); + AssociateGroupAndName(commandGroup, itemName); + _.memory.Free3(newCommandInstance, itemName, commandGroup); + } + return newCard; +} + +protected function DiscardCard(ItemCard toDiscard) { + local Text groupKey, commandName; + local Command storedCommand; + local ArrayList listOfCommands; + + if (toDiscard == none) return; + // Guaranteed to store a [`Command`] + storedCommand = Command(toDiscard.GetItem()); + if (storedCommand == none) return; + + // Guaranteed to be stored in a lower case + commandName = storedCommand.GetName(); + listOfCommands = groupedCommands.GetArrayList(groupKey); + if (listOfCommands != none && commandName != none) { + listOfCommands.RemoveItem(commandName); + } + _.memory.Free2(commandName, storedCommand); +} + +// Expect both arguments to be not `none`. +// Expect both arguments to be lower-case. +private final function AssociateGroupAndName(BaseText groupKey, BaseText commandName) { + local ArrayList listOfCommands; + + if (groupedCommands != none) { + listOfCommands = groupedCommands.GetArrayList(groupKey); + if (listOfCommands == none) { + listOfCommands = _.collections.EmptyArrayList(); + } + if (listOfCommands.Find(commandName) < 0) { + // `< 0` means not found + listOfCommands.AddItem(commandName); + } + // Set `listOfCommands` in case we've just created that array. + // Won't do anything if it is already recorded there. + groupedCommands.SetItem(groupKey, listOfCommands); + } +} + +defaultproperties { + ruleBaseClass = class'Command'; +} \ No newline at end of file diff --git a/sources/BaseAPI/API/Commands/Tools/ItemCard.uc b/sources/BaseAPI/API/Commands/Tools/ItemCard.uc new file mode 100644 index 0000000..e7cfea5 --- /dev/null +++ b/sources/BaseAPI/API/Commands/Tools/ItemCard.uc @@ -0,0 +1,177 @@ +/** + * Author: dkanus + * Home repo: https://www.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 ItemCard extends AcediaObject; + +//! Utility class designed for storing either class of an object +//! (possibly also a specific instance) along with authorization information: +//! which user groups are allowed to use stored entity and with what level of +//! permissions (defined by the name of a config with permissions). +//! +//! [`ItemCard`] has to be initialized with either [`InitializeWithClass()`] or +//! [`InitializeWithInstance()`] before it can be used. + +/// Class of object that this card describes. +var private class storedClass; +/// Instance of an object (can also *optionally* be stored in this card) +var private AcediaObject storedInstance; + +/// This [`HashTable`] maps authorized groups to their respective config names. +/// +/// Each key represents an authorized group, and its corresponding value +/// indicates the associated config name. If a key has a value of `none`, +/// the default config (named "default") should be used for that group. +var private HashTable groupToConfig; + +var LoggerAPI.Definition errGroupAlreadyHasConfig; + +protected function Finalizer() { + _.memory.Free2(storedInstance, groupToConfig); + storedInstance = none; + storedClass = none; + groupToConfig = none; +} + +/// Initializes the caller [`ItemCard`] object with class to be stored. +/// +/// Initialization can only be done once: once method returned `true`, +/// all future calls will fail. +/// +/// Returns `false` if caller was already initialized or `none` is provided as +/// an argument. Otherwise succeeds and returns `true`. +public function bool InitializeWithClass(class toStore) { + if (storedClass != none) return false; + if (toStore == none) return false; + + storedClass = toStore; + groupToConfig = _.collections.EmptyHashTable(); + return true; +} + +/// Initializes the caller [`ItemCard`] object with an object to be stored. +/// +/// Initialization can only be done once: once method returned `true`, +/// all future calls will fail. +/// +/// Returns `false` caller was already initialized or `none` is provided as +/// an argument. Otherwise succeeds and returns `true`. +public function bool InitializeWithInstance(AcediaObject toStore) { + if (storedClass != none) return false; + if (toStore == none) return false; + + storedClass = toStore.class; + storedInstance = toStore; + storedInstance.NewRef(); + groupToConfig = _.collections.EmptyHashTable(); + return true; +} + +/// Authorizes a new group to use the this card's item. +/// +/// This function allows to specify the config name for a particular user group. +/// If this config name is skipped (specified as `none`), then "default" will be +/// used instead. +/// +/// Function will return `true` if group was successfully authorized and +/// `false` otherwise (either group already authorized or caller [`ItemCard`] +/// isn't initialized). +/// +/// # Errors +/// +/// If specified group was already authorized to use card's item, then it +/// will log an error message about it. +public function bool AuthorizeGroupWithConfig(BaseText groupName, optional BaseText configName) { + local Text itemKey; + local Text storedConfigName; + + if (storedClass == none) return false; + if (groupToConfig == none) return false; + if (groupName == none) return false; + if (groupName.IsEmpty()) return false; + + /// Make group name immutable and have its characters have a uniform case to + /// be usable as case-insensitive keys for [`HashTable`]. + itemKey = groupName.LowerCopy(); + storedConfigName = groupToConfig.GetText(itemKey); + if (storedConfigName != none) { + _.logger.Auto(errGroupAlreadyHasConfig) + .ArgClass(storedClass) + .Arg(groupName.Copy()) + .Arg(storedConfigName) + .Arg(configName.Copy()); + _.memory.Free(itemKey); + return false; + } + // We don't actually record "default" value at this point, instead opting + // to return "default" in getter functions in case stored `configName` + // is `none`. + groupToConfig.SetItem(itemKey, configName); + _.memory.Free(itemKey); + return true; +} + +/// Returns item instance for the caller [`ItemCard`]. +/// +/// Returns `none` iff this card wasn't initialized with an instance. +public function AcediaObject GetItem() { + if (storedInstance != none) { + storedInstance.NewRef(); + } + return storedInstance; +} + +/// Returns item class for the caller [`ItemCard`]. +/// +/// Returns `none` iff this card wasn't initialized. +public function class GetItemClass() { + return storedClass; +} + +/// Returns the name of config that was authorized for the specified group. +/// +/// Returns `none` if group wasn't authorized, otherwise guaranteed to +/// return non-`none` and non-empty `Text` value. +public function Text GetConfigNameForGroup(BaseText groupName) { + local Text groupNameAsKey, result; + + if (storedClass == none) return none; + if (groupToConfig == none) return none; + if (groupName == none) return none; + + /// Make group name immutable and have its characters a uniform case to + /// be usable as case-insensitive keys for [`HashTable`] + groupNameAsKey = groupName.LowerCopy(); + if (groupToConfig.HasKey(groupNameAsKey)) { + result = groupToConfig.GetText(groupNameAsKey); + if (result == none) { + // If we do have specified group recorded as a key, then we must + // return non-`none` config name, defaulting to "default" value + // if none was provided + result = P("default").Copy(); + } + } + _.memory.Free(groupNameAsKey); + return result; +} + +defaultproperties { + errGroupAlreadyHasConfig = (l=LOG_Error,m="Item `%1` is already added to group '%2' with config '%3'. Attempt to add it with config '%4' is ignored.") +} \ No newline at end of file diff --git a/sources/BaseAPI/API/Commands/Tools/VotingsTool.uc b/sources/BaseAPI/API/Commands/Tools/VotingsTool.uc new file mode 100644 index 0000000..c0a6b1e --- /dev/null +++ b/sources/BaseAPI/API/Commands/Tools/VotingsTool.uc @@ -0,0 +1,119 @@ +/** + * Author: dkanus + * Home repo: https://www.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 VotingsTool extends CmdItemsTool + dependson(CommandAPI); + +//! This is a base class for auxiliary objects that will be used for storing +//! named [`Voting`] classes. +//! +//! This storage class allows for efficient manipulation and retrieval of +//! [`Voting`] classes, along with information about what use groups were +//! authorized to use them. +//! +//! Additionally this tool is used to keep track of the currently ongoing +//! voting, preventing [`CommandsAPI`] from starting several votings at once. + +/// Currently running voting process. +/// This tool doesn't actively track when voting ends, so reference can be +/// non-`none` even if voting has already ended. Instead `DropFinishedVoting()` +/// method is used as needed to figure out whether that voting has ended and +/// should be deallocated. +var private Voting currentVoting; + +protected function Finalizer() { + super.Finalizer(); + _.memory.Free(currentVoting); + currentVoting = none; +} + +/// Starts a voting process with a given name, returning its result. +public final function CommandAPI.StartVotingResult StartVoting( + CommandAPI.VotingConfigInfo votingData, + HashTable arguments +) { + local CommandAPI.StartVotingResult result; + DropFinishedVoting(); + if (currentVoting != none) { + return SVR_AlreadyInProgress; + } + if (votingData.votingClass == none) { + return SVR_UnknownVoting; + } + currentVoting = Voting(_.memory.Allocate(votingData.votingClass)); + result = currentVoting.Start(votingData.config, arguments); + if (result != SVR_Success) { + _.memory.Free(currentVoting); + currentVoting = none; + } + return result; +} + +/// Returns `true` iff some voting is currently active. +public final function bool IsVotingRunning() { + DropFinishedVoting(); + return (currentVoting != none); +} + +/// Returns instance of the active voting. +/// +/// `none` iff no voting is currently active. +public final function Voting GetCurrentVoting() { + DropFinishedVoting(); + if (currentVoting != none) { + currentVoting.NewRef(); + } + return currentVoting; +} + +protected function ItemCard MakeCard(class votingClass, BaseText itemName) { + local ItemCard newCard; + + if (class(votingClass) != none) { + newCard = ItemCard(_.memory.Allocate(class'ItemCard')); + newCard.InitializeWithClass(votingClass); + } + return newCard; +} + +private final function class GetVoting(BaseText itemName) { + local ItemCard relevantCard; + local class result; + + relevantCard = GetCard(itemName); + if (relevantCard != none) { + result = class(relevantCard.GetItemClass()); + } + _.memory.Free(relevantCard); + return result; +} + +// Clears `currentVoting` if it has already finished +private final function DropFinishedVoting() { + if (currentVoting != none && currentVoting.HasEnded()) { + _.memory.Free(currentVoting); + currentVoting = none; + } +} + +defaultproperties { + ruleBaseClass = class'Voting' +} \ No newline at end of file