diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 04a76c7cf..6a8a4042f 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -30,7 +30,7 @@ import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader"; import { PseudoRandom } from "./PseudoRandom"; import { ClientID, GameStartInfo, Turn } from "./Schemas"; import { sanitize, simpleHash } from "./Util"; -import { fixProfaneUsername } from "./validations/username"; +import { censorNameWithClanTag } from "./validations/username"; export async function createGameRunner( gameStart: GameStartInfo, @@ -46,17 +46,16 @@ export async function createGameRunner( ); const random = new PseudoRandom(simpleHash(gameStart.gameID)); - const humans = gameStart.players.map( - (p) => - new PlayerInfo( - p.clientID === clientID - ? sanitize(p.username) - : fixProfaneUsername(sanitize(p.username)), - PlayerType.Human, - p.clientID, - random.nextID(), - ), - ); + const humans = gameStart.players.map((p) => { + return new PlayerInfo( + p.clientID === clientID + ? sanitize(p.username) + : censorNameWithClanTag(p.username), + PlayerType.Human, + p.clientID, + random.nextID(), + ); + }); const nations = gameStart.config.disableNPCs ? [] diff --git a/src/core/Util.ts b/src/core/Util.ts index d060b7c67..3a6395e49 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -339,9 +339,18 @@ export function sigmoid( // Compute clan from name export function getClanTag(name: string): string | null { + const clanTag = clanMatch(name); + return clanTag ? clanTag[1].toUpperCase() : null; +} + +export function getClanTagOriginalCase(name: string): string | null { + const clanTag = clanMatch(name); + return clanTag ? clanTag[1] : null; +} + +function clanMatch(name: string): RegExpMatchArray | null { if (!name.includes("[") || !name.includes("]")) { return null; } - const clanMatch = name.match(/\[([a-zA-Z0-9]{2,5})\]/); - return clanMatch ? clanMatch[1].toUpperCase() : null; + return name.match(/\[([a-zA-Z0-9]{2,5})\]/); } diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index a7fe4f9dd..b9d50ccc1 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -8,7 +8,7 @@ import { skipNonAlphabeticTransformer, } from "obscenity"; import { translateText } from "../../client/Utils"; -import { simpleHash } from "../Util"; +import { getClanTagOriginalCase, sanitize, simpleHash } from "../Util"; const matcher = new RegExpMatcher({ ...englishDataset.build(), @@ -45,6 +45,55 @@ export function isProfaneUsername(username: string): boolean { return matcher.hasMatch(username); } +/** + * Sanitizes and censors profane usernames and clan tags. + * Profane username is overwritten, profane clan tag is removed. + * + * Preserves non-profane clan tag: + * prevents desync after clan team assignment because local player's own clan tag and name aren't overwritten + * + * Removing bad clan tags won't hurt existing clans nor cause desyncs: + * - full name including clan tag was overwritten in the past, if any part of name was bad + * - only each seperate local player name with a profane clan tag will remain, no clan team assignment + * + * Examples: + * - "GoodName" -> "GoodName" + * - "Good$Name" -> "GoodName" + * - "BadName" -> "Censored" + * - "[CLAN]GoodName" -> "[CLAN]GoodName" + * - "[CLaN]BadName" -> "[CLaN] Censored" + * - "[BAD]GoodName" -> "GoodName" + * - "[BAD]BadName" -> "Censored" + */ +export function censorNameWithClanTag(username: string): string { + const sanitizedUsername = sanitize(username); + + // Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match + const clanTag = getClanTagOriginalCase(sanitizedUsername); + + const nameWithoutClan = clanTag + ? sanitizedUsername.replace(`[${clanTag}]`, "").trim() + : sanitizedUsername; + + const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false; + const usernameIsProfane = isProfaneUsername(nameWithoutClan); + + const censoredNameWithoutClan = usernameIsProfane + ? fixProfaneUsername(nameWithoutClan) + : nameWithoutClan; + + // Restore clan tag if it existed and is not profane + if (clanTag && !clanTagIsProfane) { + if (usernameIsProfane) { + return `[${clanTag}] ${censoredNameWithoutClan}`; + } + return sanitizedUsername; + } + + // Don't restore profane or nonexistent clan tag + return censoredNameWithoutClan; +} + export function validateUsername(username: string): { isValid: boolean; error?: string;