diff --git a/config/FutilityNicknames.ini b/config/FutilityNicknames.ini
new file mode 100644
index 0000000..b7360f5
--- /dev/null
+++ b/config/FutilityNicknames.ini
@@ -0,0 +1,51 @@
+[default FutilityNicknames]
+; This feature allows to configure nickname limitations for the server.
+; It allows you to customize vanilla limitations for nickname length and
+; color with those of your own design. Enabling this feature overwrites
+; default behaviour.
+autoEnable=true
+; How to treat whitespace characters inside players' nicknames.
+; * `NSA_DoNothing` - does nothing, leaving whitespaces as they are;
+; * `NSA_Trim` - removes leading and trailing whitespaces for nicknames;
+; * `NSA_Simplify` - removes leading and trailing whitespaces
+; for nicknames, also reducing a sequence of whitespaces inside
+; nickname to a single space, e.g. "my nick" becomes "my nick".
+; Default is `NSA_DoNothing`, same as on vanilla.
+spacesAction=NSA_DoNothing
+; How to treat colored nicknames.
+; * `NCP_ForbidColor` - completely strips down any color from nicknames;
+; * `NCP_ForceTeamColor` - forces all nicknames to have player's current
+; team's color;
+; * `NCP_ForceSingleColor` - allows nickname to be painted with a single
+; color (sets nickname's color to that of the first character);
+; * `NCP_AllowAnyColor` - allows nickname to be colored in any way player
+; wants.
+; Default is `NCP_ForbidColor`, same as on vanilla.
+colorPermissions=NCP_ForbidColor
+; Set this to `true` if you wish to replace all whitespace characters with
+; underscores and `false` to leave them as is.
+; Default is `true`, same as on vanilla. However there is one difference:
+; Futility replaces all whitespace characters (including tabulations,
+; non-breaking spaces, etc.) instead of only ' '.
+replaceSpacesWithUnderscores=true
+; Max allowed nickname length. Negative values disable any length limits.
+;
+; NOTE: `0` resets all nicknames to be empty and, if `correctEmptyNicknames`
+; is set to `true`, they will be replaced with one of the fallback nicknames
+; (see `correctEmptyNicknames` and `fallbackNickname`).
+maxNicknameLength=20
+; Should we replace empty player nicknames with a random fallback nickname
+; (defined in `fallbackNickname` array)?
+correctEmptyNicknames=true
+; Array of fallback nicknames that will be used to replace any empty nicknames
+; if `correctEmptyNicknames` is set to `true`.
+fallbackNickname="Fresh Meat"
+fallbackNickname="Rotten Meat"
+fallbackNickname="Troll Meat"
+fallbackNickname="Rat Meat"
+fallbackNickname="Dog Meat"
+fallbackNickname="Elk Meat"
+fallbackNickname="Crab Meat"
+fallbackNickname="Boar Meat"
+fallbackNickname="Horker Meat"
+fallbackNickname="Bug Meat"
\ No newline at end of file
diff --git a/sources/Features/FutileNickames/FutileNickames.uc b/sources/Features/FutileNickames/FutileNickames.uc
new file mode 100644
index 0000000..a3ff4b6
--- /dev/null
+++ b/sources/Features/FutileNickames/FutileNickames.uc
@@ -0,0 +1,179 @@
+/**
+ * Config object for `FutileNickames_Feature`.
+ * Copyright 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 FutileNickames extends FeatureConfig
+ perobjectconfig
+ config(FutilityNicknames);
+
+enum NicknameSpacesAction
+{
+ NSA_DoNothing,
+ NSA_Trim,
+ NSA_Simplify
+};
+
+enum NicknameColorPermissions
+{
+ NCP_ForbidColor,
+ NCP_ForceTeamColor,
+ NCP_ForceSingleColor,
+ NCP_AllowAnyColor
+};
+
+var public config NicknameSpacesAction spacesAction;
+var public config NicknameColorPermissions colorPermissions;
+var public config bool replaceSpacesWithUnderscores;
+var public config bool correctEmptyNicknames;
+var public config int maxNicknameLength;
+var public config array fallbackNickname;
+
+protected function AssociativeArray ToData()
+{
+ local int i;
+ local DynamicArray fallbackNicknamesData;
+ local AssociativeArray data;
+ data = __().collections.EmptyAssociativeArray();
+ data.SetItem( P("spacesAction"),
+ _.text.FromString(string(spacesAction)), true);
+ data.SetItem( P("colorPermissions"),
+ _.text.FromString(string(colorPermissions)), true);
+ data.SetBool( P("replaceSpacesWithUnderscores"),
+ replaceSpacesWithUnderscores, true);
+ data.SetBool(P("correctEmptyNicknames"), correctEmptyNicknames, true);
+ data.SetInt(P("maxNicknameLength"), maxNicknameLength, true);
+ fallbackNicknamesData = __().collections.EmptyDynamicArray();
+ for (i = 0; i < fallbackNickname.length; i += 1)
+ {
+ fallbackNicknamesData.AddItem(
+ __().text.FromFormattedString(fallbackNickname[i]), true);
+ }
+ data.SetItem(P("fallbackNickname"), fallbackNicknamesData, true);
+ return data;
+}
+
+protected function FromData(AssociativeArray source)
+{
+ local int i;
+ local Text nextNickName;
+ local DynamicArray fallbackNicknamesData;
+ if (source == none) {
+ return;
+ }
+ spacesAction = SpaceActionFromText(source.GetText(P("spacesAction")));
+ colorPermissions = ColorPermissionsFromText(
+ source.GetText(P("colorPermissions")));
+ replaceSpacesWithUnderscores =
+ source.GetBool(P("replaceSpacesWithUnderscores"), true);
+ correctEmptyNicknames = source.GetBool(P("correctEmptyNicknames"), true);
+ maxNicknameLength = source.GetInt(P("correctEmptyNicknames"), 20);
+ fallbackNicknamesData = DynamicArray(source.GetItem(P("fallbackNickname")));
+ if (fallbackNickname.length > 0) {
+ fallbackNickname.length = 0;
+ }
+ for (i = 0; i < fallbackNicknamesData.GetLength(); i += 1)
+ {
+ nextNickName = fallbackNicknamesData.GetText(i);
+ if (nextNickName != none) {
+ fallbackNickname[i] = nextNickName.ToFormattedString();
+ }
+ else {
+ fallbackNickname[i] = "";
+ }
+ }
+}
+
+private function NicknameSpacesAction SpaceActionFromText(Text action)
+{
+ if (action == none) {
+ return NSA_DoNothing;
+ }
+ if (action.EndsWith(P("DoNothing"), SCASE_INSENSITIVE)) {
+ return NSA_DoNothing;
+ }
+ if (action.EndsWith(P("Trim"), SCASE_INSENSITIVE)) {
+ return NSA_Trim;
+ }
+ if (action.EndsWith(P("Simplify"), SCASE_INSENSITIVE)) {
+ return NSA_Simplify;
+ }
+ return NSA_DoNothing;
+}
+
+private function NicknameColorPermissions ColorPermissionsFromText(
+ Text permissions)
+{
+ if (permissions == none) {
+ return NCP_ForbidColor;
+ }
+ if (permissions.EndsWith(P("ForbidColor"), SCASE_INSENSITIVE)) {
+ return NCP_ForbidColor;
+ }
+ if (permissions.EndsWith(P("TeamColor"), SCASE_INSENSITIVE)) {
+ return NCP_ForceTeamColor;
+ }
+ if (permissions.EndsWith(P("SingleColor"), SCASE_INSENSITIVE)) {
+ return NCP_ForceSingleColor;
+ }
+ if (permissions.EndsWith(P("AllowAnyColor"), SCASE_INSENSITIVE)) {
+ return NCP_AllowAnyColor;
+ }
+ return NCP_ForbidColor;
+}
+
+protected function DefaultIt()
+{
+ spacesAction = NSA_DoNothing;
+ colorPermissions = NCP_ForbidColor;
+ replaceSpacesWithUnderscores = true;
+ correctEmptyNicknames = true;
+ maxNicknameLength = 20;
+ if (fallbackNickname.length > 0) {
+ fallbackNickname.length = 0;
+ }
+ fallbackNickname[0] = "Fresh Meat";
+ fallbackNickname[1] = "Rotten Meat";
+ fallbackNickname[2] = "Troll Meat";
+ fallbackNickname[3] = "Rat Meat";
+ fallbackNickname[4] = "Dog Meat";
+ fallbackNickname[5] = "Elk Meat";
+ fallbackNickname[6] = "Crab Meat";
+ fallbackNickname[7] = "Boar Meat";
+ fallbackNickname[8] = "Horker Meat";
+ fallbackNickname[9] = "Bug Meat";
+}
+
+defaultproperties
+{
+ configName = "FutilityNicknames"
+ spacesAction = NSA_DoNothing
+ colorPermissions = NCP_ForbidColor
+ replaceSpacesWithUnderscores = true
+ correctEmptyNicknames = true
+ maxNicknameLength = 20
+ fallbackNickname(0) = "Fresh Meat"
+ fallbackNickname(1) = "Rotten Meat"
+ fallbackNickname(2) = "Troll Meat"
+ fallbackNickname(3) = "Rat Meat"
+ fallbackNickname(4) = "Dog Meat"
+ fallbackNickname(5) = "Elk Meat"
+ fallbackNickname(6) = "Crab Meat"
+ fallbackNickname(7) = "Boar Meat"
+ fallbackNickname(8) = "Horker Meat"
+ fallbackNickname(9) = "Bug Meat"
+}
\ No newline at end of file
diff --git a/sources/Features/FutileNickames/FutileNickames_Feature.uc b/sources/Features/FutileNickames/FutileNickames_Feature.uc
new file mode 100644
index 0000000..b189de8
--- /dev/null
+++ b/sources/Features/FutileNickames/FutileNickames_Feature.uc
@@ -0,0 +1,340 @@
+/**
+ * This feature allows to configure nickname limitations for the server.
+ * It allows you to customize vanilla limitations for nickname length and
+ * color with those of your own design. Enabling this feature overwrites
+ * default behaviour.
+ * Copyright 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 FutileNickames_Feature extends Feature
+ dependson(FutileNickames);
+
+/**
+ * This feature's functionality is rather simple, but we will still break up
+ * what its various components are.
+ *
+ * Fallback nicknames are picked at random from
+ * the `fallbackNicknames` array. This is done by copying that array into
+ * `unusedNicknames` and then picking and removing its random elements each
+ * time we need a fallback. Once `unusedNicknames` is empty - it is copied from
+ * `fallbackNicknames` once again, letting already used nicknames to be reused.
+ * `unusedNicknames` contains same references as `fallbackNicknames`,
+ * so they need to be separately deallocated and should also be forgotten once
+ * `fallbackNicknames` are deallocated`.
+ * This is implemented inside `PickNextFallback()` method.
+ *
+ * Nickname changes are applied inside `CensorNickname()` method that uses
+ * several auxiliary methods to perform different stages of "censoring".
+ * Censoring is performed:
+ * 1. On any player's name change
+ * (using `OnPlayerNameChanging()` signal, connected to
+ * `HandleNicknameChange()`);
+ * 2. When new player logins (using `OnNewPlayer()` signal,
+ * conneted to `CensorOriginalNickname()`) to enforce our own
+ * handling of player's original nickname;
+ * 3. When censoring is re-activated.
+ * In case all censoring options of this feature are disabled
+ * (checked by `IsAnyCensoringEnabled()`) - we do not attempt to
+ * catch any events or do anything at all.
+ * If settings change mid-execution, this feature might need to
+ * enable or disable censoring on-the-fly. To accomplish that we
+ * remember current status inside `censoringNicknames` boolean
+ * variable and enable/disable events if required by settings.
+ * So whenever we re-activate censoring we also need to update
+ * ("censor") current players' nicknames - a third occasion to
+ * call `CensorNickname()`, implemented inside
+ * `CensorCurrentPlayersNicknames()`.
+ */
+
+// How to treat whitespace characters inside players' nicknames.
+// * `NSA_DoNothing` - does nothing, leaving whitespaces as they are;
+// * `NSA_Trim` - removes leading and trailing whitespaces for nicknames;
+// * `NSA_Simplify` - removes leading and trailing whitespaces
+// for nicknames, also reducing a sequence of whitespaces inside
+// nickname to a single space, e.g. "my nick" becomes "my nick".
+// Default is `NSA_DoNothing`, same as on vanilla.
+var private /*config*/ FutileNickames.NicknameSpacesAction spacesAction;
+
+// How to treat colored nicknames.
+// * `NCP_ForbidColor` - completely strips down any color from nicknames;
+// * `NCP_ForceTeamColor` - forces all nicknames to have player's current
+// team's color;
+// * `NCP_ForceSingleColor` - allows nickname to be painted with a single
+// color (sets nickname's color to that of the first character);
+// * `NCP_AllowAnyColor` - allows nickname to be colored in any way player
+// wants.
+// Default is `NCP_ForbidColor`, same as on vanilla.
+var private /*config*/ FutileNickames.NicknameColorPermissions colorPermissions;
+
+// Set this to `true` if you wish to replace all whitespace characters with
+// underscores and `false` to leave them as is.
+// Default is `true`, same as on vanilla. However there is one difference:
+// Futility replaces all whitespace characters (including tabulations,
+// non-breaking spaces, etc.) instead of only ' '.
+var private /*config*/ bool replaceSpacesWithUnderscores;
+
+// Max allowed nickname length. Negative values disable any length limits.
+//
+// NOTE: `0` resets all nicknames to be empty and, if `correctEmptyNicknames`
+// is set to `true`, they will be replaced with one of the fallback nicknames
+// (see `correctEmptyNicknames` and `fallbackNickname`).
+var private /*config*/ int maxNicknameLength;
+
+// Should we replace empty player nicknames with a random fallback nickname
+// (defined in `fallbackNickname` array)?
+var private /*config*/ bool correctEmptyNicknames;
+// Array of fallback nicknames that will be used to replace any empty nicknames
+// if `correctEmptyNicknames` is set to `true`.
+var private /*config*/ array fallbackNickname;
+
+// Guaranteed order of applying changes (only chosen ones) is as following:
+// 1. Trim/simplify spaces;
+// 2. Enforce max limit of nickname's length;
+// 3. Replace empty nickname with fallback nickname (no further changes
+// will be applied to fallback nickname in that case);
+// 4. Enforce color limitation;
+// 5. Replace remaining whitespaces with underscores.
+//
+// NOTE #1: as follows from the instruction described above, no changes will
+// ever be applied to fallback nicknames (unless player's nickname
+// coincides with one by pure accident).
+// NOTE #2: whitespaces inside steam nicknames are converted into underscores
+// before they are passed into the game and this is a change Futility
+// cannot currently abort.
+// Therefore all changes relevant to whitespaces inside nicknames will only
+// be applied to in-game changes.
+
+// Nicknames from `fallbackNickname` that can still be picked in
+// the current rotation.
+var private array unusedNicknames;
+// Are we currently censoring nicknames?
+// Set to `false` if none of the feature's options require
+// any action (censoring) and, therefore, we do not listen to any signals.
+var private bool censoringNicknames;
+
+var private const int CODEPOINT_UNDERSCORE;
+
+protected function OnDisabled()
+{
+ _.memory.FreeMany(fallbackNickname);
+ // Free this `Text` data - it will be refilled with `SwapConfig()`
+ // if this feature is ever reenabled
+ if (fallbackNickname.length > 0)
+ {
+ _.memory.FreeMany(fallbackNickname);
+ fallbackNickname.length = 0;
+ unusedNicknames.length = 0;
+ }
+ if (censoringNicknames)
+ {
+ censoringNicknames = false;
+ _.players.OnPlayerNameChanging(self).Disconnect();
+ _.players.OnNewPlayer(self).Disconnect();
+ }
+}
+
+protected function SwapConfig(FeatureConfig config)
+{
+ local bool configRequiresCensoring;
+ local FutileNickames newConfig;
+ newConfig = FutileNickames(config);
+ if (newConfig == none) {
+ return;
+ }
+ replaceSpacesWithUnderscores = newConfig.replaceSpacesWithUnderscores;
+ correctEmptyNicknames = newConfig.correctEmptyNicknames;
+ spacesAction = newConfig.spacesAction;
+ colorPermissions = newConfig.colorPermissions;
+ maxNicknameLength = newConfig.maxNicknameLength;
+ configRequiresCensoring = IsAnyCensoringEnabled();
+ // Enable or disable censoring if `IsAnyCensoringEnabled()`'s response
+ // has changed.
+ if (!censoringNicknames && configRequiresCensoring)
+ {
+ censoringNicknames = true;
+ // Do this before adding event handler to
+ // avoid censoring nicknames second time
+ CensorCurrentPlayersNicknames();
+ _.players.OnPlayerNameChanging(self).connect = HandleNicknameChange;
+ _.players.OnNewPlayer(self).connect = CensorOriginalNickname;
+ }
+ if (censoringNicknames && !configRequiresCensoring)
+ {
+ censoringNicknames = false;
+ _.players.OnPlayerNameChanging(self).Disconnect();
+ _.players.OnNewPlayer(self).Disconnect();
+ }
+ SwapFallbackNicknames(newConfig);
+}
+
+private function Text PickNextFallback()
+{
+ local int pickedIndex;
+ local Text result;
+ if (fallbackNickname.length <= 0)
+ {
+ // Just in case this feature is really misconfigured
+ return P("Fresh Meat").Copy();
+ }
+ if (unusedNicknames.length <= 0) {
+ unusedNicknames = fallbackNickname;
+ }
+ // Pick one nickname at random.
+ // `pickedIndex` will belong to [0; unusedNicknames.length - 1] segment.
+ pickedIndex = Rand(unusedNicknames.length);
+ result = unusedNicknames[pickedIndex].Copy();
+ unusedNicknames.Remove(pickedIndex, 1);
+ return result;
+}
+
+protected function SwapFallbackNicknames(FutileNickames newConfig)
+{
+ local int i;
+ _.memory.FreeMany(fallbackNickname);
+ if (fallbackNickname.length > 0) {
+ fallbackNickname.length = 0;
+ }
+ for (i = 0; i < newConfig.fallbackNickname.length; i += 1)
+ {
+ fallbackNickname[i] =
+ _.text.FromFormattedString(newConfig.fallbackNickname[i]);
+ }
+ unusedNicknames = fallbackNickname;
+}
+
+private function bool IsAnyCensoringEnabled()
+{
+ return ( replaceSpacesWithUnderscores
+ || correctEmptyNicknames
+ || maxNicknameLength >= 0
+ || colorPermissions != NCP_AllowAnyColor
+ || spacesAction != NSA_DoNothing);
+}
+
+// For nickname changes mid-game.
+private function HandleNicknameChange(
+ EPlayer affectedPlayer,
+ Text oldName,
+ MutableText newName)
+{
+ CensorNickname(newName, affectedPlayer);
+}
+
+// For handling of player's original nicknames.
+private function CensorOriginalNickname(EPlayer affectedPlayer)
+{
+ local Text originalNickname;
+ if (affectedPlayer == none) {
+ return;
+ }
+ originalNickname = affectedPlayer.GetOriginalName();
+ // This will automatically trigger `OnPlayerNameChanging()` signal and
+ // our `HandleNicknameChange()` handler.
+ affectedPlayer.SetName(originalNickname);
+ _.memory.Free(originalNickname);
+}
+
+// For handling nicknames of players after censoring is re-activated by
+// config change.
+private function CensorCurrentPlayersNicknames()
+{
+ local int i;
+ local Text nextNickname;
+ local MutableText alteredNickname;
+ local array currentPlayers;
+ currentPlayers = _.players.GetAll();
+ for (i = 0; i < currentPlayers.length; i += 1)
+ {
+ nextNickname = currentPlayers[i].GetName();
+ alteredNickname = nextNickname.MutableCopy();
+ CensorNickname(alteredNickname, currentPlayers[i]);
+ if (!alteredNickname.Compare(nextNickname)) {
+ currentPlayers[i].SetName(alteredNickname);
+ }
+ _.memory.Free(alteredNickname);
+ _.memory.Free(nextNickname);
+ }
+}
+
+private function CensorNickname(MutableText nickname, EPlayer affectedPlayer)
+{
+ local Text fallback;
+ local Text.Formatting newFormatting;
+ if (nickname == none) return;
+ if (affectedPlayer == none) return;
+
+ if (spacesAction != NSA_DoNothing) {
+ nickname.Simplify(spacesAction == NSA_Simplify);
+ }
+ if (maxNicknameLength >= 0) {
+ nickname.Remove(maxNicknameLength);
+ }
+ if (correctEmptyNicknames && nickname.IsEmpty())
+ {
+ fallback = PickNextFallback();
+ nickname.Append(fallback);
+ _.memory.Free(fallback);
+ return;
+ }
+ if (colorPermissions != NCP_AllowAnyColor)
+ {
+ if (colorPermissions == NCP_ForceSingleColor) {
+ newFormatting = nickname.GetCharacter(0).formatting;
+ }
+ else if (colorPermissions == NCP_ForceTeamColor)
+ {
+ newFormatting.isColored = true;
+ newFormatting.color = affectedPlayer.GetTeamColor();
+ }
+ // `colorPermissions == NCP_ForbidColor`
+ // `newFormatting` is colorless by default
+ nickname.ChangeFormatting(newFormatting);
+ }
+ if (replaceSpacesWithUnderscores) {
+ ReplaceSpaces(nickname);
+ }
+}
+
+// Asusmes `nickname != none`.
+private function ReplaceSpaces(MutableText nickname)
+{
+ local int i;
+ local MutableText nicknameCopy;
+ local Text.Character nextCharacter, underscoreCharacter;
+ nicknameCopy = nickname.MutableCopy();
+ nickname.Clear();
+ underscoreCharacter =
+ _.text.CharacterFromCodePoint(CODEPOINT_UNDERSCORE);
+ for (i = 0; i < nicknameCopy.GetLength(); i += 1)
+ {
+ nextCharacter = nicknameCopy.GetCharacter(i);
+ if (_.text.IsWhitespace(nextCharacter))
+ {
+ // Replace character with underscore, leaving the formatting
+ underscoreCharacter.formatting = nextCharacter.formatting;
+ nextCharacter = underscoreCharacter;
+ }
+ nickname.AppendCharacter(nextCharacter);
+ }
+ _.memory.Free(nicknameCopy);
+}
+
+defaultproperties
+{
+ configClass = class'FutileNickames'
+ CODEPOINT_UNDERSCORE = 95 // '_'
+}
\ No newline at end of file
diff --git a/sources/Manifest.uc b/sources/Manifest.uc
index 0fde09e..7d1d965 100644
--- a/sources/Manifest.uc
+++ b/sources/Manifest.uc
@@ -23,4 +23,5 @@
defaultproperties
{
features(0) = class'Futility_Feature'
+ features(1) = class'FutileNickames_Feature'
}
\ No newline at end of file