diff --git a/config/AcediaSystem.ini b/config/AcediaSystem.ini
index ac5dbc6..5bfd2d6 100644
--- a/config/AcediaSystem.ini
+++ b/config/AcediaSystem.ini
@@ -140,6 +140,10 @@ requiredGroup=""
maxVisibleLineWidth=80
maxTotalLineWidth=108
+[AcediaCore.PlayerNotificationQueue]
+; Maximum time that a notification is allowed to be displayed on the player's screen
+maximumNotifyTime=20
+
[AcediaCore.ColorAPI]
; Changing these values will alter color's definitions in `ColorAPI`,
; changing how Acedia behaves
diff --git a/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc
new file mode 100644
index 0000000..d992449
--- /dev/null
+++ b/sources/BaseAPI/API/Commands/BuiltInCommands/ACommandNotify.uc
@@ -0,0 +1,53 @@
+/**
+ * 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 ACommandNotify extends Command;
+
+protected function BuildData(CommandDataBuilder builder) {
+ builder.Name(P("notify"));
+ builder.Group(P("core"));
+ builder.Summary(P("Notifies players with provided message."));
+ builder.ParamText(P("message"));
+ builder.OptionalParams();
+ builder.ParamNumber(P("duration"));
+ builder.Describe(P("Notify to players message with distinct header and body."));
+ builder.RequireTarget();
+
+ builder.Option(P("title"));
+ builder.Describe(P("Specify the optional title of the notification."));
+ builder.ParamText(P("title"));
+}
+
+protected function ExecutedFor(EPlayer target, CallData arguments, EPlayer instigator) {
+ local Text title, message, plainTitle, plainMessage;
+
+ plainMessage = arguments.parameters.GetText(P("message"));
+ if (arguments.options.HasKey(P("title"))) {
+ plainTitle = arguments.options.GetTextBy(P("/title/title"));
+ }
+ title = _.text.FromFormatted(plainTitle);
+ message = _.text.FromFormatted(plainMessage);
+ target.Notify(title, message, arguments.parameters.GetFloat(P("duration")));
+ _.memory.Free4(title, message, plainTitle, plainMessage);
+}
+
+defaultproperties {
+}
\ No newline at end of file
diff --git a/sources/LevelAPI/API/Time/Timer.uc b/sources/LevelAPI/API/Time/Timer.uc
index f9dd79e..136f249 100644
--- a/sources/LevelAPI/API/Time/Timer.uc
+++ b/sources/LevelAPI/API/Time/Timer.uc
@@ -78,6 +78,7 @@ public function float GetInterval();
*
* Setting this value while the caller `Timer` is running resets it (same as
* calling `StopMe().Start()`).
+ * But the already inactive timer won't start anew.
*
* @param newInterval How many seconds should separate two `OnElapsed()`
* signals (or starting a timer and next `OnElapsed()` event)?
diff --git a/sources/LevelAPI/Features/Commands/Commands_Feature.uc b/sources/LevelAPI/Features/Commands/Commands_Feature.uc
new file mode 100644
index 0000000..2f9008c
--- /dev/null
+++ b/sources/LevelAPI/Features/Commands/Commands_Feature.uc
@@ -0,0 +1,627 @@
+/**
+ * This feature provides a mechanism to define commands that automatically
+ * parse their arguments into standard Acedia collection. It also allows to
+ * manage them (and specify limitation on how they can be called) in a
+ * centralized manner.
+ * Copyright 2021-2022 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 Commands_Feature extends Feature;
+
+/**
+ * # `Commands_Feature`
+ *
+ * This feature provides a mechanism to define commands that automatically
+ * parse their arguments into standard Acedia collection. It also allows to
+ * manage them (and specify limitation on how they can be called) in a
+ * centralized manner.
+ * Support command input from chat and "mutate" command.
+ *
+ * ## Usage
+ *
+ * Should be enabled like any other feature. Additionally support
+ * `EmergencyEnable()` enabling method that bypasses regular settings to allow
+ * admins to start this feature while forcefully enabling "mutate" command
+ * input method.
+ * Available configuration:
+ *
+ * 1. Whether to use command input from chat and what prefix is used to
+ * denote a command (by default "!");
+ * 2. Whether to use command input from "mutate" command.
+ *
+ * To add new commands into the system - get enabled instance of this
+ * feature and call its `RegisterCommand()` method to add your custom
+ * `Command` class. `RemoveCommand()` can also be used to de-register
+ * a command, if you need this for some reason.
+ *
+ * ## Implementation
+ *
+ * Implementation is simple: calling a method `RegisterCommand()` adds
+ * command into two caches `registeredCommands` for obtaining registered
+ * commands by name and `groupedCommands` for obtaining arrays of commands by
+ * their group name. These arrays are used for providing methods for fetching
+ * arrays of commands and obtaining pre-allocated `Command` instances by their
+ * name.
+ * Depending on settings, this feature also connects to corresponding
+ * signals for catching "mutate"/chat input, then it checks user-specified name
+ * for being an alias and picks correct command from `registeredCommands`.
+ * Emergency enabling this feature sets `emergencyEnabledMutate` flag that
+ * enforces connecting to the "mutate" input.
+ */
+
+// Delimiters that always separate command name from it's parameters
+var private array commandDelimiters;
+// Registered commands, recorded as (, ) pairs.
+// Keys should be deallocated when their entry is removed.
+var private HashTable registeredCommands;
+// `HashTable` of "" <-> `ArrayList` of commands pairs
+// to allow quick fetch of commands belonging to a single group
+var private HashTable groupedCommands;
+
+// When this flag is set to true, mutate input becomes available
+// despite `useMutateInput` flag to allow to unlock server in case of an error
+var private bool emergencyEnabledMutate;
+
+// Setting this to `true` enables players to input commands right in the chat
+// by prepending them with `chatCommandPrefix`.
+// Default is `true`.
+var private /*config*/ bool useChatInput;
+// Setting this to `true` enables players to input commands with "mutate"
+// console command.
+// Default is `true`.
+var private /*config*/ bool useMutateInput;
+// Chat messages, prepended by this prefix will be treated as commands.
+// Default is "!". Empty values are also treated as "!".
+var private /*config*/ Text chatCommandPrefix;
+// List of steam IDs of players allowed to use commands.
+// Temporary measure until a better solution is finished.
+var private /*config*/ array allowedPlayers;
+
+// Contains name of the command to call plus, optionally,
+// additional sub-command name.
+// Normally sub-command name is parsed by the command itself, however
+// command aliases can try to enforce one.
+struct CommandCallPair
+{
+ var MutableText commandName;
+ // In case it is enforced by an alias
+ var MutableText subCommandName;
+};
+
+var LoggerAPI.Definition errCommandDuplicate;
+
+protected function OnEnabled()
+{
+ registeredCommands = _.collections.EmptyHashTable();
+ groupedCommands = _.collections.EmptyHashTable();
+ RegisterCommand(class'ACommandHelp');
+ RegisterCommand(class'ACommandNotify');
+ // Macro selector
+ commandDelimiters[0] = _.text.FromString("@");
+ // Key selector
+ commandDelimiters[1] = _.text.FromString("#");
+ // Player array (possibly JSON array)
+ commandDelimiters[2] = _.text.FromString("[");
+ // Negation of the selector
+ commandDelimiters[3] = _.text.FromString("!");
+}
+
+protected function OnDisabled()
+{
+ if (useChatInput) {
+ _.chat.OnMessage(self).Disconnect();
+ }
+ if (useMutateInput) {
+ _server.unreal.mutator.OnMutate(self).Disconnect();
+ }
+ useChatInput = false;
+ useMutateInput = false;
+ _.memory.Free(registeredCommands);
+ _.memory.Free(groupedCommands);
+ _.memory.Free(chatCommandPrefix);
+ _.memory.FreeMany(commandDelimiters);
+ registeredCommands = none;
+ groupedCommands = none;
+ chatCommandPrefix = none;
+ commandDelimiters.length = 0;
+}
+
+protected function SwapConfig(FeatureConfig config)
+{
+ local Commands newConfig;
+
+ newConfig = Commands(config);
+ if (newConfig == none) {
+ return;
+ }
+ _.memory.Free(chatCommandPrefix);
+ chatCommandPrefix = _.text.FromString(newConfig.chatCommandPrefix);
+ allowedPlayers = newConfig.allowedPlayers;
+ if (useChatInput != newConfig.useChatInput)
+ {
+ useChatInput = newConfig.useChatInput;
+ if (newConfig.useChatInput) {
+ _.chat.OnMessage(self).connect = HandleCommands;
+ }
+ else {
+ _.chat.OnMessage(self).Disconnect();
+ }
+ }
+ // Do not make any modifications here in case "mutate" was
+ // emergency-enabled
+ if (useMutateInput != newConfig.useMutateInput && !emergencyEnabledMutate)
+ {
+ useMutateInput = newConfig.useMutateInput;
+ if (newConfig.useMutateInput) {
+ _server.unreal.mutator.OnMutate(self).connect = HandleMutate;
+ }
+ else {
+ _server.unreal.mutator.OnMutate(self).Disconnect();
+ }
+ }
+}
+
+/**
+ * `Command_Feature` is a critical command to have running on your server and,
+ * if disabled by accident, there will be no way of starting it again without
+ * restarting the level or even editing configs.
+ *
+ * This method allows to enable it along with "mutate" input in case something
+ * goes wrong.
+ */
+public final static function EmergencyEnable()
+{
+ local Text autoConfig;
+ local Commands_Feature feature;
+
+ if (!IsEnabled())
+ {
+ autoConfig = GetAutoEnabledConfig();
+ EnableMe(autoConfig);
+ __().memory.Free(autoConfig);
+ }
+ feature = Commands_Feature(GetEnabledInstance());
+ if ( !feature.emergencyEnabledMutate
+ && !feature.IsUsingMutateInput() && !feature.IsUsingChatInput())
+ {
+ default.emergencyEnabledMutate = true;
+ feature.emergencyEnabledMutate = true;
+ __server().unreal.mutator.OnMutate(feature).connect = HandleMutate;
+ }
+}
+
+/**
+ * Checks if `Commands_Feature` currently uses chat as input.
+ * If `Commands_Feature` is not enabled, then it does not use anything
+ * as input.
+ *
+ * @return `true` if `Commands_Feature` is currently enabled and is using chat
+ * as input and `false` otherwise.
+ */
+public final static function bool IsUsingChatInput()
+{
+ local Commands_Feature instance;
+
+ instance = Commands_Feature(GetEnabledInstance());
+ if (instance != none) {
+ return instance.useChatInput;
+ }
+ return false;
+}
+
+/**
+ * Checks if `Commands_Feature` currently uses mutate command as input.
+ * If `Commands_Feature` is not enabled, then it does not use anything
+ * as input.
+ *
+ * @return `true` if `Commands_Feature` is currently enabled and is using
+ * mutate command as input and `false` otherwise.
+ */
+public final static function bool IsUsingMutateInput()
+{
+ local Commands_Feature instance;
+
+ instance = Commands_Feature(GetEnabledInstance());
+ if (instance != none) {
+ return instance.useMutateInput;
+ }
+ return false;
+}
+
+/**
+ * Returns prefix that will indicate that chat message is intended to be
+ * a command. By default "!".
+ *
+ * @return Prefix that indicates that chat message is intended to be a command.
+ * If `Commands_Feature` is disabled, always returns `false`.
+ */
+public final static function Text GetChatPrefix()
+{
+ local Commands_Feature instance;
+
+ instance = Commands_Feature(GetEnabledInstance());
+ if (instance != none && instance.chatCommandPrefix != none) {
+ return instance.chatCommandPrefix.Copy();
+ }
+ return none;
+}
+
+/**
+ * Registers given command class, making it available for usage.
+ *
+ * If `commandClass` provides command with a name that is already taken
+ * (comparison is case-insensitive) by a different command - a warning will be
+ * logged and newly passed `commandClass` discarded.
+ *
+ * @param commandClass New command class to register.
+ */
+public final function RegisterCommand(class commandClass)
+{
+ local Text commandName, groupName;
+ local ArrayList groupArray;
+ local Command newCommandInstance, existingCommandInstance;
+
+ if (commandClass == none) return;
+ if (registeredCommands == none) return;
+
+ newCommandInstance = Command(_.memory.Allocate(commandClass, true));
+ commandName = newCommandInstance.GetName();
+ groupName = newCommandInstance.GetGroupName();
+ // Check for duplicates and report them
+ existingCommandInstance = Command(registeredCommands.GetItem(commandName));
+ if (existingCommandInstance != none)
+ {
+ _.logger.Auto(errCommandDuplicate)
+ .ArgClass(existingCommandInstance.class)
+ .Arg(commandName)
+ .ArgClass(commandClass);
+ _.memory.Free(groupName);
+ _.memory.Free(newCommandInstance);
+ _.memory.Free(existingCommandInstance);
+ return;
+ }
+ // Otherwise record new command
+ // `commandName` used as a key, do not deallocate it
+ registeredCommands.SetItem(commandName, newCommandInstance);
+ // Add to grouped collection
+ groupArray = groupedCommands.GetArrayList(groupName);
+ if (groupArray == none) {
+ groupArray = _.collections.EmptyArrayList();
+ }
+ groupArray.AddItem(newCommandInstance);
+ groupedCommands.SetItem(groupName, groupArray);
+ _.memory.Free(groupArray);
+ _.memory.Free(groupName);
+ _.memory.Free(commandName);
+ _.memory.Free(newCommandInstance);
+}
+
+/**
+ * Removes command of class `commandClass` from the list of
+ * registered commands.
+ *
+ * WARNING: removing once registered commands is not an action that is expected
+ * to be performed under normal circumstances and it is not efficient.
+ * It is linear on the current amount of commands.
+ *
+ * @param commandClass Class of command to remove from being registered.
+ */
+public final function RemoveCommand(class commandClass)
+{
+ local int i;
+ local CollectionIterator iter;
+ local Command nextCommand;
+ local Text nextCommandName;
+ local array commandGroup;
+ local array keysToRemove;
+
+ if (commandClass == none) return;
+ if (registeredCommands == none) return;
+
+ for (iter = registeredCommands.Iterate(); !iter.HasFinished(); iter.Next())
+ {
+ nextCommand = Command(iter.Get());
+ nextCommandName = Text(iter.GetKey());
+ if ( nextCommand == none || nextCommandName == none
+ || nextCommand.class != commandClass)
+ {
+ _.memory.Free(nextCommand);
+ _.memory.Free(nextCommandName);
+ continue;
+ }
+ keysToRemove[keysToRemove.length] = nextCommandName;
+ commandGroup[commandGroup.length] = nextCommand.GetGroupName();
+ _.memory.Free(nextCommand);
+ }
+ iter.FreeSelf();
+ for (i = 0; i < keysToRemove.length; i += 1)
+ {
+ registeredCommands.RemoveItem(keysToRemove[i]);
+ _.memory.Free(keysToRemove[i]);
+ }
+
+ for (i = 0; i < commandGroup.length; i += 1) {
+ RemoveClassFromGroup(commandClass, commandGroup[i]);
+ }
+ _.memory.FreeMany(commandGroup);
+}
+
+private final function RemoveClassFromGroup(
+ class commandClass,
+ BaseText commandGroup)
+{
+ local int i;
+ local ArrayList groupArray;
+ local Command nextCommand;
+
+ groupArray = groupedCommands.GetArrayList(commandGroup);
+ if (groupArray == none) {
+ return;
+ }
+ while (i < groupArray.GetLength())
+ {
+ nextCommand = Command(groupArray.GetItem(i));
+ if (nextCommand != none && nextCommand.class == commandClass) {
+ groupArray.RemoveIndex(i);
+ }
+ else {
+ i += 1;
+ }
+ _.memory.Free(nextCommand);
+ }
+ if (groupArray.GetLength() == 0) {
+ groupedCommands.RemoveItem(commandGroup);
+ }
+ _.memory.Free(groupArray);
+}
+
+/**
+ * Returns command based on a given name.
+ *
+ * @param commandName Name of the registered `Command` to return.
+ * Case-insensitive.
+ * @return Command, registered with a given name `commandName`.
+ * If no command with such name was registered - returns `none`.
+ */
+public final function Command GetCommand(BaseText commandName)
+{
+ local Text commandNameLowerCase;
+ local Command commandInstance;
+
+ if (commandName == none) return none;
+ if (registeredCommands == none) return none;
+
+ commandNameLowerCase = commandName.LowerCopy();
+ commandInstance = Command(registeredCommands.GetItem(commandNameLowerCase));
+ commandNameLowerCase.FreeSelf();
+ return commandInstance;
+}
+
+/**
+ * Returns array of names of all available commands.
+ *
+ * @return Array of names of all available (registered) commands.
+ */
+public final function array GetCommandNames()
+{
+ local array emptyResult;
+
+ if (registeredCommands != none) {
+ return registeredCommands.GetTextKeys();
+ }
+ return emptyResult;
+}
+
+/**
+ * Returns array of names of all available commands belonging to the group
+ * `groupName`.
+ *
+ * @return Array of names of all available (registered) commands, belonging to
+ * the group `groupName`.
+ */
+public final function array GetCommandNamesInGroup(BaseText groupName)
+{
+ local int i;
+ local ArrayList groupArray;
+ local Command nextCommand;
+ local array result;
+
+ if (groupedCommands == none) return result;
+ groupArray = groupedCommands.GetArrayList(groupName);
+ if (groupArray == none) return result;
+
+ for (i = 0; i < groupArray.GetLength(); i += 1)
+ {
+ nextCommand = Command(groupArray.GetItem(i));
+ if (nextCommand != none) {
+ result[result.length] = nextCommand.GetName();
+ }
+ _.memory.Free(nextCommand);
+ }
+ return result;
+}
+
+/**
+ * Returns all available command groups' names.
+ *
+ * @return Array of all available command groups' names.
+ */
+public final function array GetGroupsNames()
+{
+ local array emptyResult;
+
+ if (groupedCommands != none) {
+ return groupedCommands.GetTextKeys();
+ }
+ return emptyResult;
+}
+
+/**
+ * Handles user input: finds appropriate command and passes the rest of
+ * the arguments to it for further processing.
+ *
+ * @param input Test that contains user's command input.
+ * @param callerPlayer Player that caused this command call.
+ */
+public final function HandleInput(BaseText input, EPlayer callerPlayer)
+{
+ local Parser wrapper;
+
+ if (input == none) {
+ return;
+ }
+ wrapper = input.Parse();
+ HandleInputWith(wrapper, callerPlayer);
+ wrapper.FreeSelf();
+}
+
+/**
+ * Handles user input: finds appropriate command and passes the rest of
+ * the arguments to it for further processing.
+ *
+ * @param parser Parser filled with user input that is expected to
+ * contain command's name and it's parameters.
+ * @param callerPlayer Player that caused this command call.
+ */
+public final function HandleInputWith(Parser parser, EPlayer callerPlayer)
+{
+ local int i;
+ local bool foundID;
+ local string steamID;
+ local PlayerController controller;
+ local Command commandInstance;
+ local Command.CallData callData;
+ local CommandCallPair callPair;
+
+ if (parser == none) return;
+ if (callerPlayer == none) return;
+ if (!parser.Ok()) return;
+ controller = callerPlayer.GetController();
+ if (controller == none) return;
+
+ steamID = controller.GetPlayerIDHash();
+ for (i = 0; i < allowedPlayers.length; i += 1)
+ {
+ if (allowedPlayers[i] == steamID)
+ {
+ foundID = true;
+ break;
+ }
+ }
+ if (!foundID) {
+ return;
+ }
+ callPair = ParseCommandCallPairWith(parser);
+ commandInstance = GetCommand(callPair.commandName);
+ if ( commandInstance == none
+ && callerPlayer != none && callerPlayer.IsExistent())
+ {
+ callerPlayer
+ .BorrowConsole()
+ .Flush()
+ .Say(F("{$TextFailure Command not found!}"));
+ }
+ if (parser.Ok() && commandInstance != none)
+ {
+ callData = commandInstance
+ .ParseInputWith(parser, callerPlayer, callPair.subCommandName);
+ commandInstance.Execute(callData, callerPlayer);
+ commandInstance.DeallocateCallData(callData);
+ }
+ _.memory.Free(callPair.commandName);
+ _.memory.Free(callPair.subCommandName);
+}
+
+// Parses command's name into `CommandCallPair` - sub-command is filled in case
+// specified name is an alias with specified sub-command name.
+private final function CommandCallPair ParseCommandCallPairWith(Parser parser)
+{
+ local Text resolvedValue;
+ local MutableText userSpecifiedName;
+ local CommandCallPair result;
+ local Text.Character dotCharacter;
+
+ if (parser == none) return result;
+ if (!parser.Ok()) return result;
+
+ parser.MUntilMany(userSpecifiedName, commandDelimiters, true, true);
+ resolvedValue = _.alias.ResolveCommand(userSpecifiedName);
+ // This isn't an alias
+ if (resolvedValue == none)
+ {
+ result.commandName = userSpecifiedName;
+ return result;
+ }
+ // It is an alias - parse it
+ dotCharacter = _.text.GetCharacter(".");
+ resolvedValue.Parse()
+ .MUntil(result.commandName, dotCharacter)
+ .MatchS(".")
+ .MUntil(result.subCommandName, dotCharacter)
+ .FreeSelf();
+ if (result.subCommandName.IsEmpty())
+ {
+ result.subCommandName.FreeSelf();
+ result.subCommandName = none;
+ }
+ resolvedValue.FreeSelf();
+ return result;
+}
+
+private function bool HandleCommands(
+ EPlayer sender,
+ MutableText message,
+ bool teamMessage)
+{
+ local Parser parser;
+
+ // We are only interested in messages that start with `chatCommandPrefix`
+ parser = _.text.Parse(message);
+ if (!parser.Match(chatCommandPrefix).Ok())
+ {
+ parser.FreeSelf();
+ return true;
+ }
+ // Pass input to command feature
+ HandleInputWith(parser, sender);
+ parser.FreeSelf();
+ return false;
+}
+
+private function HandleMutate(string command, PlayerController sendingPlayer)
+{
+ local Parser parser;
+ local EPlayer sender;
+
+ // A lot of other mutators use these commands
+ if (command ~= "help") return;
+ if (command ~= "version") return;
+ if (command ~= "status") return;
+ if (command ~= "credits") return;
+
+ parser = _.text.ParseString(command);
+ sender = _.players.FromController(sendingPlayer);
+ HandleInputWith(parser, sender);
+ sender.FreeSelf();
+ parser.FreeSelf();
+}
+
+defaultproperties
+{
+ configClass = class'Commands'
+ errCommandDuplicate = (l=LOG_Error,m="Command `%1` is already registered with name '%2'. Command `%3` with the same name will be ignored.")
+}
\ No newline at end of file
diff --git a/sources/Players/EPlayer.uc b/sources/Players/EPlayer.uc
index 40dabce..1dc7fef 100644
--- a/sources/Players/EPlayer.uc
+++ b/sources/Players/EPlayer.uc
@@ -53,6 +53,8 @@ struct PlayerSignals
// `PlayersAPI` and is expected to be allocated during the whole Acedia run.
var protected PlayerSignals signalsReferences;
+var private LoggerAPI.Definition errNoIdentity;
+
protected function Finalizer()
{
_.memory.Free(controller);
@@ -502,7 +504,31 @@ public final function /* borrow */ ConsoleWriter BorrowConsole()
return consoleInstance.ForPlayer(self);
}
-defaultproperties
-{
+/// Notifies player about something with a text message.
+///
+/// Header is allowed to be `none`, but it is recommended to set it to an actual value.
+/// Duration is more of a suggestion and might be clamped to some reasonable value that depends on
+/// implementation/server settings.
+public final function Notify(BaseText header, BaseText body, optional float duration) {
+ local HashTable sessionData;
+ local PlayerNotificationQueue messageQueue;
+
+ if (identity == none) {
+ _.logger.Auto(errNoIdentity);
+ return;
+ }
+ sessionData = identity.GetSessionData(P("Acedia"));
+ messageQueue = PlayerNotificationQueue(sessionData.GetItem(P("MessageQueue")));
+ if (messageQueue == none) {
+ messageQueue = PlayerNotificationQueue(_.memory.Allocate(class'PlayerNotificationQueue'));
+ sessionData.SetItem(P("MessageQueue"), messageQueue);
+ }
+ messageQueue.SetupController(controller);
+ messageQueue.AddNotification(header, body, duration);
+ _.memory.Free2(sessionData, messageQueue);
+}
+
+defaultproperties {
usesObjectPool = false
+ errNoIdentity = (l=LOG_Warning,m="`EPlayer` without `identity` is being used. It is likely not initialized and something is working incorrectly.")
}
\ No newline at end of file
diff --git a/sources/Players/PlayerNotificationQueue.uc b/sources/Players/PlayerNotificationQueue.uc
new file mode 100644
index 0000000..968d619
--- /dev/null
+++ b/sources/Players/PlayerNotificationQueue.uc
@@ -0,0 +1,219 @@
+/**
+ * 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 PlayerNotificationQueue extends AcediaObject
+ config(AcediaSystem);
+
+/// Manages queue of notifications that should be displayed for a certain player.
+
+/// Describes a single notification: title (optional, can be `none`) + message body and timeout
+struct Notification {
+ var Text title;
+ var Text body;
+ var float time;
+};
+var private array notificationQueue;
+/// Reference to the `PlayerController` for the player that owns this queue
+var private NativeActorRef playerControllerRef;
+/// Timer until next notification can be displayed
+///
+/// `none` if queue currently doesn't display any notifications
+var private Timer nextNotificationTimer;
+
+/// Maximum time that a notification is allowed to be displayed on the player's screen
+var private const config float maximumNotifyTime;
+
+var private const int CODEPOINT_NEWLINE;
+
+protected function Finalizer() {
+ local int i;
+
+ for (i = 0; i < notificationQueue.length; i += 1) {
+ _.memory.Free(notificationQueue[i].title);
+ _.memory.Free(notificationQueue[i].body);
+ }
+ notificationQueue.length = 0;
+ _.memory.Free(nextNotificationTimer);
+ _.memory.Free(playerControllerRef);
+ nextNotificationTimer = none;
+ playerControllerRef = none;
+}
+
+/// Set owner `PlayerController` for this queue
+public final /*native*/ function SetupController(NativeActorRef newPlayerControllerRef) {
+ local PlayerController oldController, newController;
+
+ if (playerControllerRef != none) {
+ oldController = PlayerController(playerControllerRef.Get());
+ }
+ if (newPlayerControllerRef != none) {
+ newController = PlayerController(newPlayerControllerRef.Get());
+ }
+ if (oldController != newController) {
+ InterruptScheduling();
+ _.memory.Free(playerControllerRef);
+ if (newPlayerControllerRef != none) {
+ newPlayerControllerRef.NewRef();
+ playerControllerRef = newPlayerControllerRef;
+ SetupNextNotification(none);
+ } else {
+ playerControllerRef = none;
+ }
+ }
+}
+
+/// Add new notification to the queue
+public final function AddNotification(BaseText title, BaseText body, float time) {
+ local Notification newNotification;
+
+ if (body == none) {
+ return;
+ }
+ if (title != none) {
+ newNotification.title = title.Copy();
+ }
+ newNotification.body = body.Copy();
+ notificationQueue[notificationQueue.length] = newNotification;
+ if (!IsUpdateScheduled()) {
+ SetupNextNotification(none);
+ }
+}
+
+// Sets up [`SetupNextNotification()`] to be called after [`timeUntilNextUpdate`]
+private function ScheduleUpdate(float timeUntilNextUpdate) {
+ if (nextNotificationTimer == none) {
+ nextNotificationTimer = __level().time.StartRealTimer(timeUntilNextUpdate, false);
+ nextNotificationTimer.OnElapsed(nextNotificationTimer).connect = SetupNextNotification;
+ } else {
+ nextNotificationTimer.SetInterval(timeUntilNextUpdate);
+ nextNotificationTimer.Start();
+ }
+}
+
+private function bool IsUpdateScheduled() {
+ return (nextNotificationTimer != none);
+}
+
+// Prevents scheduled [`SetupNextNotification()`] call from going off
+private function InterruptScheduling() {
+ _.memory.Free(nextNotificationTimer);
+ nextNotificationTimer = none;
+}
+
+// Properly translates [`contents`] into a colored [`string`] before setting it up as
+// a [`PlayerController`]'s progress message at the given line index.
+private function SetupProgressLine(
+ int lineIndex,
+ BaseText contents,
+ PlayerController playerController
+) {
+ local string contentsAsString;
+ local BaseText.Formatting startingFormatting;
+
+ if (contents == none) return;
+ if (playerController == none) return;
+
+ // Drop first colored tag, since we'll set first color through `playerController.progressColor`
+ contentsAsString = Mid(contents.ToColoredString(,, _.color.white), 4);
+ startingFormatting = contents.GetFormatting(0);
+ if (startingFormatting.isColored) {
+ playerController.SetProgressMessage(lineIndex, contentsAsString, startingFormatting.color);
+ }
+ else {
+ playerController.SetProgressMessage(lineIndex, contentsAsString, _.color.white);
+ }
+}
+
+// Prints [`notification`] on given lines, respecting line breaks inside it as much as possible
+// (creates up to [`maxLines`], replacing the rest of line breaks with whitespaces)
+private function PrintNotifcationAt(
+ PlayerController playerController,
+ BaseText notification,
+ int startingLine,
+ int maxLines
+) {
+ local int i, j;
+ local MutableText lastLine;
+ local array lines;
+
+ if (notification == none) return;
+ if (startingLine < 0) return;
+ if (startingLine > 3) return;
+
+ lines = notification.SplitByCharacter(_.text.CharacterFromCodePoint(CODEPOINT_NEWLINE),, true);
+ for (i = 0; i < lines.length; i += 1) {
+ if (i + 1 < maxLines) {
+ SetupProgressLine(i + startingLine, lines[i], playerController);
+ } else {
+ lastLine = lines[i].MutableCopy();
+ for (j = i + 1; j < lines.length; j += 1) {
+ lastLine.Append(P(" "));
+ lastLine.Append(lines[j]);
+ }
+ SetupProgressLine(startingLine + maxLines - 1, lastLine, playerController);
+ _.memory.Free(lastLine);
+ break;
+ }
+ }
+}
+
+private function SetupNextNotification(Timer callerInstance) {
+ local int titleShift;
+ local MutableText upperCaseTitle;
+ local Notification nextNotification;
+ local PlayerController playerController;
+
+ // Get appropriate [`PlayerController`] and next notification
+ playerController = PlayerController(playerControllerRef.Get());
+ if (playerController == none) {
+ _.memory.Free(playerControllerRef);
+ playerControllerRef = none;
+ }
+ if (notificationQueue.length <= 0 || playerController == none) {
+ InterruptScheduling();
+ return;
+ }
+ nextNotification = notificationQueue[0];
+ notificationQueue.Remove(0, 1);
+ nextNotification.time = FMin(nextNotification.time, maximumNotifyTime);
+ if (nextNotification.time <= 0) {
+ nextNotification.time = 10.0;
+ }
+
+ // And print
+ playerController.ClearProgressMessages();
+ playerController.SetProgressTime(nextNotification.time);
+ if (nextNotification.title != none) {
+ upperCaseTitle = nextNotification.title.UpperMutableCopy();
+ upperCaseTitle.ChangeDefaultColor(_.color.TextHeader);
+ PrintNotifcationAt(playerController, upperCaseTitle, 0, 1);
+ titleShift = 1;
+ _.memory.Free(upperCaseTitle);
+ }
+ PrintNotifcationAt(playerController, nextNotification.body, titleShift, 4 - titleShift);
+ ScheduleUpdate(nextNotification.time);
+ _.memory.Free2(nextNotification.title, nextNotification.body);
+}
+
+defaultproperties {
+ CODEPOINT_NEWLINE = 10
+ maximumNotifyTime = 20.0
+}
\ No newline at end of file
diff --git a/sources/Users/User.uc b/sources/Users/User.uc
index 246a825..4eae0ad 100644
--- a/sources/Users/User.uc
+++ b/sources/Users/User.uc
@@ -103,6 +103,25 @@ public final function int GetKey()
return key;
}
+/**
+ * Returns a reference to user's session data for a given group.
+ *
+ * Guaranteed to not be `none`.
+ */
+public final function HashTable GetSessionData(Text groupName)
+{
+ local HashTable groupData;
+ if (sessionData == none) {
+ sessionData = _.collections.EmptyHashTable();
+ }
+ groupData = sessionData.GetHashTable(groupName);
+ if (groupData == none) {
+ groupData = _.collections.EmptyHashTable();
+ sessionData.SetItem(groupName, groupData);
+ }
+ return groupData;
+}
+
/**
* Returns persistent data for the caller user. Data is specified by the its
* name along with the name of the data group it is stored in.