diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index 3b3c24b7d5..bc1163e60b 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -3,6 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import { v4 as uuidv4 } from "uuid"; import { translateText } from "../client/Utils"; import { UserSettings } from "../core/game/UserSettings"; +import { getRandomUsername } from "../core/utilities/UsernameGenerator"; import { MAX_USERNAME_LENGTH, validateUsername, @@ -94,7 +95,7 @@ export class UsernameInput extends LitElement { } private generateNewUsername(): string { - const newUsername = "Anon" + this.uuidToThreeDigits(); + const newUsername = getRandomUsername(Math.random()); this.storeUsername(newUsername); return newUsername; } diff --git a/src/core/Util.ts b/src/core/Util.ts index c5cbe044ea..8824d8276e 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -12,10 +12,7 @@ import { Winner, } from "./Schemas"; -import { - BOT_NAME_PREFIXES, - BOT_NAME_SUFFIXES, -} from "./execution/utils/BotNames"; +import { getRandomUsername } from "./utilities/UsernameGenerator"; export function manhattanDistWrapped( c1: Cell, @@ -276,16 +273,10 @@ export function createRandomName( name: string, playerType: string, ): string | null { - let randomName: string | null = null; if (playerType === "HUMAN") { - const hash = simpleHash(name); - const prefixIndex = hash % BOT_NAME_PREFIXES.length; - const suffixIndex = - Math.floor(hash / BOT_NAME_PREFIXES.length) % BOT_NAME_SUFFIXES.length; - - randomName = `πŸ‘€ ${BOT_NAME_PREFIXES[prefixIndex]} ${BOT_NAME_SUFFIXES[suffixIndex]}`; + return getRandomUsername(simpleHash(name)); } - return randomName; + return null; } export const emojiTable = [ diff --git a/src/core/utilities/UsernameGenerator.ts b/src/core/utilities/UsernameGenerator.ts new file mode 100644 index 0000000000..1b48e74afb --- /dev/null +++ b/src/core/utilities/UsernameGenerator.ts @@ -0,0 +1,272 @@ +const PLURAL_NOUN = Symbol("plural!"); +const NOUN = Symbol("noun!"); + +const names = [ + ["World Famous", NOUN], + ["Famous", PLURAL_NOUN], + ["Comically Large", NOUN], + ["Comically Small", NOUN], + ["Clearance Aisle", PLURAL_NOUN], + ["Massive", PLURAL_NOUN], + ["Smelly", NOUN], + ["Friendly", NOUN], + ["Tardy", NOUN], + ["Evil", NOUN], + ["Rude", NOUN], + ["Malicious", NOUN], + ["Spiteful", NOUN], + ["Mister", NOUN], + ["Suspicious", NOUN], + ["Sopping Wet", PLURAL_NOUN], + ["Not Too Fond Of", PLURAL_NOUN], + ["Honk For", PLURAL_NOUN], + ["Canonically Evil", NOUN], + ["Limited Edition", NOUN], + ["Patent Pending", NOUN], + ["Patented", NOUN], + ["Space", NOUN], + ["Defend The", PLURAL_NOUN], + ["Crime", PLURAL_NOUN], + ["Anarchist", NOUN], + ["Garbage", NOUN], + ["Farting", PLURAL_NOUN], + ["Suspiciously Textured", NOUN], + ["Army Of Laser", PLURAL_NOUN], + ["Republic of", PLURAL_NOUN], + ["Slippery", NOUN], + ["Wealthy", PLURAL_NOUN], + ["Politically Correct", NOUN], + ["Mall", NOUN], + ["Certified", NOUN], + ["Dr", NOUN], + ["Runaway", NOUN], + ["Chrome", NOUN], + ["All New", NOUN], + ["Top Shelf", PLURAL_NOUN], + ["Prosumer", NOUN], + ["Freshly Squeezed", NOUN], + ["Vine Ripened", NOUN], + ["Invading", PLURAL_NOUN], + ["Eau De", NOUN], + ["Freshly Showered", NOUN], + ["Loyal To", PLURAL_NOUN], + ["United States of", NOUN], + ["United States of", PLURAL_NOUN], + ["Flowing Rivers of", NOUN], + ["House of", PLURAL_NOUN], + ["Suspiciously Shaped", NOUN], + ["Fishy", NOUN], + ["Certified Organic", NOUN], + ["Unregulated", NOUN], + + [NOUN, "For Hire"], + [PLURAL_NOUN, "That Bite"], + [PLURAL_NOUN, "in my walls"], + [PLURAL_NOUN, "Are Opps"], + [NOUN, "Hotel"], + [PLURAL_NOUN, "The Movie"], + [NOUN, "Scholar"], + [NOUN, "Merchandise"], + [NOUN, "Connoisseur"], + [NOUN, "Kardashian"], + [NOUN, "Consequences"], + [NOUN, "Corporation"], + [PLURAL_NOUN, "Inc"], + [NOUN, "Democracy"], + [NOUN, "Network"], + [NOUN, "Railway"], + [NOUN, "Congress"], + [NOUN, "Alliance"], + [NOUN, "Island"], + [NOUN, "Kingdom"], + [NOUN, "Empire"], + [NOUN, "Dynasty"], + [NOUN, "Cartel"], + [NOUN, "Cabal"], + [NOUN, "Land"], + [NOUN, "Oligarchy"], + [NOUN, "Scientist"], + [NOUN, "Seeking Missile"], + [NOUN, "Post Office"], + [NOUN, "Nationalist"], + [NOUN, "State"], + [NOUN, "Duchy"], + [NOUN, "Ocean"], + + ["Alternate", NOUN, "Universe"], + ["Let That", NOUN, "In"], + ["Famous", NOUN, "Collection"], + ["Supersonic", NOUN, "Spaceship"], + ["Secret", NOUN, "Agenda"], + ["Ballistic", NOUN, "Missile"], + ["The", PLURAL_NOUN, "are SPIES"], + ["Traveling", NOUN, "Circus"], + ["The", PLURAL_NOUN, "Lied"], + ["Casual", NOUN, "Enthusiast"], + ["Sacred", NOUN, "Knowledge"], + ["Quantum", NOUN, "Computer"], + ["Hadron", NOUN, "Collider"], + ["Large", NOUN, "Obliterator"], + ["Interstellar", NOUN, "Cabal"], + ["Interstellar", NOUN, "Army"], + ["Interstellar", NOUN, "Pirates"], + ["Interstellar", NOUN, "Dynasty"], + ["Interstellar", NOUN, "Clan"], + ["Galactic", NOUN, "Smugglers"], + ["Galactic", NOUN, "Cabal"], + ["Galactic", NOUN, "Alliance"], + ["Galactic", NOUN, "Empire"], + ["Galactic", NOUN, "Army"], + ["Galactic", NOUN, "Crown"], + ["Galactic", NOUN, "Pirates"], + ["Galactic", NOUN, "Dynasty"], + ["Galactic", NOUN, "Clan"], + ["Alien", NOUN, "Army"], + ["Alien", NOUN, "Cabal"], + ["Alien", NOUN, "Alliance"], + ["Alien", NOUN, "Empire"], + ["Alien", NOUN, "Pirates"], + ["Alien", NOUN, "Clan"], + ["Grand", NOUN, "Empire"], + ["Grand", NOUN, "Dynasty"], + ["Grand", NOUN, "Army"], + ["Grand", NOUN, "Cabal"], + ["Grand", NOUN, "Alliance"], + ["Royal", NOUN, "Army"], + ["Royal", NOUN, "Cabal"], + ["Royal", NOUN, "Empire"], + ["Royal", NOUN, "Dynasty"], + ["Holy", NOUN, "Dynasty"], + ["Holy", NOUN, "Empire"], + ["Holy", NOUN, "Alliance"], + ["Eternal", NOUN, "Empire"], + ["Eternal", NOUN, "Cabal"], + ["Eternal", NOUN, "Alliance"], + ["Eternal", NOUN, "Dynasty"], + ["Invading", NOUN, "Cabal"], + ["Invading", NOUN, "Empire"], + ["Invading", NOUN, "Alliance"], + ["Immortal", NOUN, "Pirates"], + ["Shadow", NOUN, "Cabal"], + ["Secret", NOUN, "Dynasty"], + ["The Great", NOUN, "Army"], + ["The", NOUN, "Matrix"], +]; + +const nouns = [ + "Snail", + "Cow", + "Giraffe", + "Donkey", + "Horse", + "Mushroom", + "Salad", + "Kitten", + "Fork", + "Apple", + "Pancake", + "Tree", + "Fern", + "Seashell", + "Turtle", + "Casserole", + "Gnome", + "Frog", + "Cheese", + "Mold", + "Clown", + "Boat", + "Moron", + "Robot", + "Millionaire", + "Billionaire", + "Pigeon", + "Fish", + "Bumblebee", + "Jelly", + "Wizard", + "Worm", + "Rat", + "Pumpkin", + "Zombie", + "Grass", + "Bear", + "Skunk", + "Sandwich", + "Butter", + "Soda", + "Pickle", + "Potato", + "Book", + "Friend", + "Feather", + "Flower", + "Oil", + "Train", + "Fan", + "Hater", + "Opp", + "Salmon", + "Cod", + "Sink", + "Villain", + "Bug", + "Car", + "Soup", + "Puppy", + "Rock", + "Stick", + "Succulent", + "Nerd", + "Mercenary", + "Ninja", + "Burger", + "Tomato", +]; + +function isSeedAcceptable(sanitizedSeed: number) { + const template = names[sanitizedSeed % names.length]; + const noun = nouns[Math.floor(sanitizedSeed / names.length) % nouns.length]; + + const totalLength = + template.map((v) => (v as any)?.length ?? 0).reduce((a, b) => a + b) + + template.length + + noun.length; + + return totalLength <= 26; +} +/** + * Generate a random username based on a numeric seed + * @param seed - the seed to use to select a username + * @returns a string suitable for a player username + */ +export function getRandomUsername(seed: number): string { + // note: ONLY use prime numbers here + let sanitizedSeed = Math.floor( + (seed * 19991) % (names.length * nouns.length), + ); + + while (!isSeedAcceptable(sanitizedSeed)) { + sanitizedSeed += 1; + } + + const template = names[sanitizedSeed % names.length]; + const noun = nouns[Math.floor(sanitizedSeed / names.length) % nouns.length]; + const result: [string?] = []; + + // Convert template to some somewhat-legible word string + for (const step of template) { + if (step === PLURAL_NOUN) { + if (noun.endsWith("s")) result.push(`${noun}es`); + else { + result.push(`${noun}s`); + } + } else if (step === NOUN) { + result.push(noun); + } else { + result.push(step.toString()); + } + } + + return result.join(" "); +} diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts index a7fe4f9dd8..5a96142e77 100644 --- a/src/core/validations/username.ts +++ b/src/core/validations/username.ts @@ -1,22 +1,86 @@ import { + DataSet, RegExpMatcher, collapseDuplicatesTransformer, englishDataset, - englishRecommendedTransformers, + pattern, resolveConfusablesTransformer, resolveLeetSpeakTransformer, skipNonAlphabeticTransformer, + toAsciiLowerCaseTransformer, } from "obscenity"; import { translateText } from "../../client/Utils"; import { simpleHash } from "../Util"; +import { getRandomUsername } from "../utilities/UsernameGenerator"; + +const customDataset = new DataSet() + .addAll(englishDataset) + /* similarity to racial slur */ + .addPhrase((phrase) => + phrase + .setMetadata({ originalWord: "nigg" }) + /* Not used by any english words */ + .addPattern(pattern`niqq`), + ) + /* historic significance / edgy */ + .addPhrase((phrase) => + phrase + .setMetadata({ originalWord: "hitler" }) + .addPattern(pattern`hitl?r`) + .addPattern(pattern`hiti?r`) + .addPattern(pattern`hltl?r`), + ) + .addPhrase((phrase) => + phrase.setMetadata({ originalWord: "nazi" }).addPattern(pattern`|nazi`), + ) + /* aggressive / edgy */ + .addPhrase((phrase) => + phrase.setMetadata({ originalWord: "hang" }).addPattern(pattern`|hang|`), + ) + .addPhrase((phrase) => + phrase + .setMetadata({ originalWord: "kill" }) + .addPattern(pattern`|kill`) + /* not used by any english words */ + .addPattern(pattern`ikill`), + ) + .addPhrase((phrase) => + phrase + .setMetadata({ originalWord: "murder" }) + /* only used by a few english words */ + .addPattern(pattern`murd`) + .addPattern(pattern`mard`), + ) + .addPhrase((phrase) => + phrase + .setMetadata({ originalWord: "shoot" }) + .addPattern(pattern`|shoot`) + .addPattern(pattern`|shot`) + /* only used by a few english words */ + .addPattern(pattern`ishoot`) + .addPattern(pattern`ishot`), + ); const matcher = new RegExpMatcher({ - ...englishDataset.build(), - ...englishRecommendedTransformers, - ...resolveConfusablesTransformer(), - ...skipNonAlphabeticTransformer(), - ...collapseDuplicatesTransformer(), - ...resolveLeetSpeakTransformer(), + ...customDataset.build(), + + blacklistMatcherTransformers: [ + resolveConfusablesTransformer(), + resolveLeetSpeakTransformer(), + skipNonAlphabeticTransformer(), + toAsciiLowerCaseTransformer(), + collapseDuplicatesTransformer({ + customThresholds: new Map([ + ["b", 2], + ["e", 2], + ["o", 2], + ["l", 2], + ["s", 2], + ["g", 2], + ["q", 2], + ]), + }), + ], }); export const MIN_USERNAME_LENGTH = 3; @@ -24,19 +88,9 @@ export const MAX_USERNAME_LENGTH = 27; const validPattern = /^[a-zA-Z0-9_[\] πŸˆπŸ€ΓΌΓœ]+$/u; -const shadowNames = [ - "NicePeopleOnly", - "BeKindPlz", - "LearningManners", - "StayClassy", - "BeNicer", - "NeedHugs", - "MakeFriends", -]; - export function fixProfaneUsername(username: string): string { if (isProfaneUsername(username)) { - return shadowNames[simpleHash(username) % shadowNames.length]; + return getRandomUsername(simpleHash(username)); } return username; } diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts index 74f9cccc27..ea3b71b1ed 100644 --- a/tests/Censor.test.ts +++ b/tests/Censor.test.ts @@ -1,6 +1,18 @@ // Mocking the obscenity library to control its behavior in tests. jest.mock("obscenity", () => { return { + DataSet: class { + constructor() {} + addAll() { + return this; + } + addPhrase() { + return this; + } + build() { + return {}; + } + }, RegExpMatcher: class { private dummy: string[] = ["foo", "bar", "leet", "code"]; constructor(_opts: any) {} @@ -22,6 +34,7 @@ jest.mock("obscenity", () => { resolveConfusablesTransformer: () => ({}), resolveLeetSpeakTransformer: () => ({}), skipNonAlphabeticTransformer: () => ({}), + toAsciiLowerCaseTransformer: () => ({}), }; }); @@ -41,16 +54,6 @@ import { } from "../src/core/validations/username"; describe("username.ts functions", () => { - const shadowNames = [ - "NicePeopleOnly", - "BeKindPlz", - "LearningManners", - "StayClassy", - "BeNicer", - "NeedHugs", - "MakeFriends", - ]; - describe("isProfaneUsername & fixProfaneUsername with leet decoding (mocked)", () => { test.each([ { username: "l33t", profane: true }, // decodes to "leet" @@ -76,7 +79,7 @@ describe("username.ts functions", () => { expect(fixed).toBe(username); } else { // When profane: result should be one of shadowNames - expect(shadowNames).toContain(fixed); + expect(fixed).not.toBe(username); } }); });