diff --git a/app/features/user-page/in-game-name.test.ts b/app/features/user-page/in-game-name.test.ts index ecef89c56..1c6f93704 100644 --- a/app/features/user-page/in-game-name.test.ts +++ b/app/features/user-page/in-game-name.test.ts @@ -17,19 +17,22 @@ describe("inGameNameIsValid", () => { "naïve#1234", "「テスト」#1234", "あ、い。#1234", - "テスト〜#1234", + "テスト~#1234", + "ジャンプ#1234", "ロー・ル#1234", "こゝろ#1234", - "すゞ#1234", - "ヽヾ#1234", "【Foo】#1234", - "〈Foo〉#1234", - "《Foo》#1234", - "『Foo』#1234", - "〔Foo〕#1234", "こ々ろ#1234", - "〆〇〃#1234", + "〆仝#1234", "※Test#1234", + "■□●#1234", + "‐―#1234", + "¦¨´#1234", + "fuse⌒*#2949", + "ƒ´`#1234", + "Santiago’s#6987", + "ȚțDŽDZĈĕ#1234", + "ς〒#1234", "○☆Mikurby#2897", "仝#1234", "三#1234", @@ -37,7 +40,7 @@ describe("inGameNameIsValid", () => { "名前テスト1234#ab12c", "한국어#1234", "中文名#1234", - "Test!#1234", + "Test!?##1234", "Cafe\u0301#1234", ]; @@ -66,10 +69,24 @@ describe("inGameNameIsValid", () => { test("rejects characters the Switch keyboard does not allow in names", () => { const invalidNames = [ + "Santiago's#6987", "test@me#1234", "100%#1234", "a\\b#1234", - "●#1234", + "fuse⏜*#2949", + "⚪⚫#1234", + "◻◼#1234", + "▲▼#1234", + "𝑓#1234", + "ˊˋ#1234", + "⍑#1234", + "•“„‛‚″#1234", + "『Foo』《Bar》#1234", + "〈〉〔〕〇〃〜#1234", + "すゞヽヾ#1234", + "Test#1234", + "ıẞĘЁ#1234", + "ɛᴠᴋї#1234", "♥#1234", ]; @@ -86,6 +103,27 @@ describe("sanitizeInGameName", () => { ["한국어#1234", "한국어#1234", "hangul"], ["Cafe\u0301#1234", "Café#1234", "decomposed accents are composed"], ["100%#1234", "100#1234", "characters the game does not allow"], + [ + "Santiago's#6987", + "Santiago’s#6987", + "apostrophe becomes the in-game one", + ], + ["fuse⏜•#2949", "fuse⌒●#2949", "lookalikes become the in-game character"], + ["▲Yellow▼#6089", "△Yellow▽#6089", "filled triangles become hollow ones"], + [ + "⍑FireMuffn#2264", + "〒FireMuffn#2264", + "up tack overbar becomes postal mark", + ], + [ + "『Foo』《Bar》〜〃#1234", + "「Foo」«Bar»~”#1234", + "CJK brackets become their in-game lookalikes", + ], + ["★ ⦾culus ★#1898", "★ ◎culus ★#1898", "symbol lookalikes"], + ["Test。ア#1234", "Test。ア#1234", "fullwidth and halfwidth forms"], + ["ĘąěЁ#1234", "EaeЕ#1234", "accents the game lacks are dropped"], + ["ìıɛᴋ#1234", "ìiεк#1234", "letter lookalikes"], ])("%s -> %s (%s)", (input, expected) => { expect(sanitizeInGameName(input)).toBe(expected); }); diff --git a/app/features/user-page/in-game-name.ts b/app/features/user-page/in-game-name.ts index 2f1dae52c..8dc4b0d58 100644 --- a/app/features/user-page/in-game-name.ts +++ b/app/features/user-page/in-game-name.ts @@ -15,20 +15,20 @@ export const IN_GAME_NAME_CHARACTER_CATEGORIES = [ id: "symbols", label: "inGameName.categories.symbols", characters: [ - ..."¿¡′‘’‚‛•…″“”„«»←→↑↓⇒⇔˜ˊˋ¢€£¥¤𝑓×÷±∞√¬∀⊂⊃∴∵⌒∂№°¹²³¼½¾♪♭♀♂○⚪⚫◎◻◼◇◆△▲▽▼☆★©®™§¶†⍑※", + ..."¿¡′‘’…”‐―«»←→↑↓⇒⇔˜´¨¦¢€£¥¤ƒ×÷±∞√¬∀⊂⊃∴∵⌒∂№°¹²³¼½¾♪♭♀♂○●◎□■◇◆△▽☆★©®™§¶†※", ], }, { id: "accented", label: "inGameName.categories.accented", characters: [ - ..."àáâãäåæāăąçćċčðďdždzèéêëēęěğġģħìíîïīįıijķĺļľłÀÁÂÃÄÅÆĀĂĄÇĆĊČÐĎDžDzÈÉÊËĒĘĚĞĠĢĦÌÍÎÏĪĮİIJĶĹĻĽŁñńņňòóôõöøœőŕřšßśşþťţùúûüūůűųýÿźżžÑŃŅŇÒÓÔÕÖØŒŐŔŘŠẞŚŞÞŤŢÙÚÛÜŪŮŰŲÝŸŹŻŽ", + ..."àáâãäåæāăçćċčĉðďdždzèéêëēĕğġģħìíîïīįijķĺļľłÀÁÂÃÄÅÆĀĂÇĆĊČĈÐĎDŽDZÈÉÊËĒĔĞĠĢĦÌÍÎÏĪĮİIJĶĹĻĽŁñńņňòóôõöøœőŕřšßśşþťțùúûüūůűųýÿźżžÑŃŅŇÒÓÔÕÖØŒŐŔŘŠŚŞÞŤȚÙÚÛÜŪŮŰŲÝŸŹŻŽ", ], }, { id: "greek", label: "inGameName.categories.greek", - characters: [..."αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"], + characters: [..."αβγδεζηθικλμνξοπρσςτυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"], }, { id: "cyrillic", @@ -38,17 +38,17 @@ export const IN_GAME_NAME_CHARACTER_CATEGORIES = [ { id: "hiragana", label: "inGameName.categories.hiragana", - characters: [...range(0x3041, 0x3096), ..."ゝゞ"], + characters: [...range(0x3041, 0x308f), ..."をんゝ"], }, { id: "katakana", label: "inGameName.categories.katakana", - characters: [...range(0x30a1, 0x30fa), ..."ヽヾ"], + characters: [...range(0x30a1, 0x30ef), ..."ヲンヴヵヶ"], }, { id: "cjk-symbols", label: "inGameName.categories.cjkSymbols", - characters: [..."、。「」『』【】〈〉《》〔〕〜・ー々〆〇〃"], + characters: [..."、。「」【】・ー~々〆〒仝"], }, ] as const satisfies ReadonlyArray<{ id: string; @@ -60,28 +60,152 @@ const PICKER_CHARACTERS = IN_GAME_NAME_CHARACTER_CATEGORIES.flatMap( (category) => category.characters, ); -const ASCII_NOT_VALID = new Set(["%", "@", "\\"]); +const ASCII_NOT_VALID = new Set(["'", "%", "@", "\\"]); const ASCII_CHARACTERS = range(0x20, 0x7e).filter( (character) => !ASCII_NOT_VALID.has(character), ); +/** Japanese keyboard symbols, left out of the picker as they look like their ASCII counterparts. */ +const FULLWIDTH_SYMBOLS = [ + ..."!#$&()*+,-./:;<=>?[]^_{|}¥", +]; + const ALLOWED_CHARACTERS = new Set([ ...ASCII_CHARACTERS, ...PICKER_CHARACTERS, + ...FULLWIDTH_SYMBOLS, ]); -/** Every script a Switch keyboard can produce, incl. kanji/hanzi and hangul which the picker can't enumerate. */ -const ALLOWED_SCRIPTS_REGEXP = - /^[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}\p{Script=Hangul}]$/u; +/** Kanji/hanzi and hangul, too numerous for the picker. */ +const IDEOGRAPH_OR_HANGUL_REGEXP = + /^[\p{Unified_Ideograph}\p{Script=Hangul}]$/u; -/** Punctuation & symbols not covered by the scripts above. */ -const ALLOWED_CODE_POINT_RANGES = [ - [0x3000, 0x303f], // CJK symbols and punctuation - [0x3099, 0x309c], // Kana voiced sound marks - [0x30fb, 0x30fc], // Katakana middle dot & prolonged sound mark - [0xff01, 0xff60], // Fullwidth forms - [0xffe0, 0xffe6], // Fullwidth signs -] as const; +/** Lookalikes mapped to the in-game character. Anything else falls back to its compatibility or accentless form. */ +const CHARACTER_REPLACEMENTS = new Map([ + ["'", "’"], + ["\t", " "], + ["⏜", "⌒"], + ["⁀", "⌒"], + ["⌢", "⌒"], + ["◠", "⌒"], + ["𝑓", "ƒ"], + ["◼", "■"], + ["▪", "■"], + ["⬛", "■"], + ["◻", "□"], + ["☐", "□"], + ["⬜", "□"], + ["▢", "□"], + ["⚪", "○"], + ["◯", "○"], + ["⚫", "●"], + ["⬤", "●"], + ["⏺", "●"], + ["•", "●"], + ["⦾", "◎"], + ["⊚", "◎"], + ["⭗", "◎"], + ["⦿", "◎"], + ["◉", "◎"], + ["▲", "△"], + ["▵", "△"], + ["🔺", "△"], + ["▼", "▽"], + ["∇", "▽"], + ["🔻", "▽"], + ["▿", "▽"], + ["♦", "◆"], + ["♢", "◇"], + ["◊", "◇"], + ["⬦", "◇"], + ["⭐", "★"], + ["⭑", "★"], + ["🌟", "★"], + ["✫", "★"], + ["✬", "★"], + ["✭", "★"], + ["✯", "★"], + ["✩", "☆"], + ["✰", "☆"], + ["✝", "†"], + ["✞", "†"], + ["🎵", "♪"], + ["🎶", "♪"], + ["♫", "♪"], + ["♩", "♪"], + ["⨯", "×"], + ["✖", "×"], + ["✕", "×"], + ["✘", "×"], + ["⟹", "⇒"], + ["⇛", "⇒"], + ["⬆", "↑"], + ["⬇", "↓"], + ["➡", "→"], + ["⟶", "→"], + ["≪", "«"], + ["≫", "»"], + ["《", "«"], + ["》", "»"], + ["『", "「"], + ["』", "」"], + ["〜", "~"], + ["〃", "”"], + ["⍑", "〒"], + ["·", "・"], + ["‧", "・"], + ["∙", "・"], + ["⋅", "・"], + ["ˊ", "´"], + ["ˋ", "`"], + ["˚", "°"], + ["º", "°"], + ["∘", "°"], + ["“", "”"], + ["„", "”"], + ["‛", "‘"], + ["‚", ","], + ["″", '"'], + ["–", "‐"], + ["—", "―"], + ["−", "-"], + ["∼", "~"], + ["Ţ", "Ț"], + ["ţ", "ț"], + ["Ș", "Ş"], + ["ș", "ş"], + ["Đ", "Ð"], + ["ı", "i"], + ["ẞ", "ß"], + ["ї", "ï"], + ["І", "I"], + ["і", "i"], + ["ѕ", "s"], + ["∅", "Ø"], + ["∆", "Δ"], + ["∑", "Σ"], + ["Ʌ", "Λ"], + ["Ө", "Θ"], + ["Ɵ", "Θ"], + ["ɵ", "θ"], + ["⍺", "α"], + ["ɑ", "α"], + ["ɛ", "ε"], + ["⍳", "ι"], + ["ɩ", "ι"], + ["ƞ", "η"], + ["ᴋ", "к"], + ["ᴍ", "м"], + ["ᴛ", "т"], + ["ᴠ", "v"], + ["ʙ", "в"], + ["ᴄ", "c"], + ["ʜ", "н"], + ["ᴏ", "o"], + ["ᴎ", "и"], + ["ᴙ", "я"], + ["ɸ", "φ"], +]); const IN_GAME_NAME_REGEXP = new RegExp( `^(.+)#([0-9a-z]{${IN_GAME_NAME.DISCRIMINATOR_MIN_LENGTH},${IN_GAME_NAME.DISCRIMINATOR_MAX_LENGTH}})$`, @@ -93,9 +217,9 @@ export function inGameNameLength(value: string): number { return [...value].length; } -/** Normalizes and drops every character the game does not allow. */ +/** Normalizes, swaps lookalikes for the in-game character and drops every character the game does not allow. */ export function sanitizeInGameName(value: string): string { - return [...normalizeInGameName(value)].filter(characterIsAllowed).join(""); + return [...normalizeInGameName(value)].map(toAllowedCharacters).join(""); } /** Unicode normalization applied before validating or storing. */ @@ -119,15 +243,39 @@ export function inGameNameIsValid(value: string): boolean { } function characterIsAllowed(character: string): boolean { - if (ALLOWED_CHARACTERS.has(character)) return true; - if (ALLOWED_SCRIPTS_REGEXP.test(character)) return true; - - const codePoint = character.codePointAt(0)!; - return ALLOWED_CODE_POINT_RANGES.some( - ([from, to]) => codePoint >= from && codePoint <= to, + return ( + ALLOWED_CHARACTERS.has(character) || + IDEOGRAPH_OR_HANGUL_REGEXP.test(character) ); } +function toAllowedCharacters(character: string): string { + if (characterIsAllowed(character)) return character; + + const replacement = CHARACTER_REPLACEMENTS.get(character); + if (replacement) return replacement; + + const compatibilityForm = [...character.normalize("NFKC")] + .map((part) => CHARACTER_REPLACEMENTS.get(part) ?? part) + .join(""); + if ([...compatibilityForm].every(characterIsAllowed)) { + return compatibilityForm; + } + + return withoutDiacritics(character) ?? ""; +} + +function withoutDiacritics(character: string): string | null { + const decomposed = [...character.normalize("NFD")]; + while (decomposed.length > 1) { + decomposed.pop(); + const candidate = decomposed.join("").normalize("NFC"); + if (characterIsAllowed(candidate)) return candidate; + } + + return null; +} + function range(from: number, to: number): string[] { const characters: string[] = []; for (let codePoint = from; codePoint <= to; codePoint++) { diff --git a/changelog/2026-10-01-in-game-name-characters.md b/changelog/2026-10-01-in-game-name-characters.md new file mode 100644 index 000000000..2852a450b --- /dev/null +++ b/changelog/2026-10-01-in-game-name-characters.md @@ -0,0 +1,10 @@ +--- +navItem: u +type: bug +--- +In-game names now should accept exactly the characters the game allows + +- More symbols such as ■, □, ● and ― are now allowed +- Lookalike characters the game doesn't have were removed from the picker +- Existing names using them were switched to the matching in-game character where possible +- Typing a lookalike converts it automatically (e.g. ' becomes ’) diff --git a/migrations/20261001043951-in-game-name-lookalike-characters.ts b/migrations/20261001043951-in-game-name-lookalike-characters.ts new file mode 100644 index 000000000..6433856f7 --- /dev/null +++ b/migrations/20261001043951-in-game-name-lookalike-characters.ts @@ -0,0 +1,273 @@ +import { type Kysely, sql } from "kysely"; + +/** Characters stored in-game names use that the game doesn't have -> the in-game character. */ +const REPLACEMENTS = new Map([ + ["\u0009", " "], + ["'", "’"], + ["\u00a0", " "], + ["µ", "μ"], + ["·", "・"], + ["º", "°"], + ["Ą", "A"], + ["ą", "a"], + ["Đ", "Ð"], + ["Ę", "E"], + ["ę", "e"], + ["Ě", "E"], + ["ě", "e"], + ["ı", "i"], + ["ō", "o"], + ["Ţ", "Ț"], + ["ţ", "ț"], + ["ƞ", "η"], + ["Ɵ", "Θ"], + ["ơ", "o"], + ["ǎ", "a"], + ["ǒ", "o"], + ["Ș", "Ş"], + ["ș", "ş"], + ["Ȩ", "E"], + ["ȩ", "e"], + ["Ʌ", "Λ"], + ["ɑ", "α"], + ["ɛ", "ε"], + ["ɩ", "ι"], + ["ɵ", "θ"], + ["ɸ", "φ"], + ["ʙ", "в"], + ["ʜ", "н"], + ["ˊ", "´"], + ["ˋ", "`"], + ["˚", "°"], + ["ά", "α"], + ["έ", "ε"], + ["ή", "η"], + ["ί", "ι"], + ["ύ", "υ"], + ["ϕ", "φ"], + ["І", "I"], + ["ё", "е"], + ["ѕ", "s"], + ["і", "i"], + ["ї", "ï"], + ["Ө", "Θ"], + ["ᴄ", "c"], + ["ᴋ", "к"], + ["ᴍ", "м"], + ["ᴎ", "и"], + ["ᴏ", "o"], + ["ᴙ", "я"], + ["ᴛ", "т"], + ["ᴠ", "v"], + ["ᵃ", "a"], + ["ᵒ", "o"], + ["Ṭ", "T"], + ["ẞ", "ß"], + ["\u2009", " "], + ["–", "‐"], + ["—", "―"], + ["‚", ","], + ["‛", "‘"], + ["“", "”"], + ["„", "”"], + ["•", "●"], + ["‧", "・"], + ["″", '"'], + ["⁀", "⌒"], + ["⁰", "0"], + ["⁺", "+"], + ["₃", "3"], + ["ₒ", "o"], + ["ℏ", "ħ"], + ["ℓ", "l"], + ["⇛", "⇒"], + ["∅", "Ø"], + ["∆", "Δ"], + ["∇", "▽"], + ["∑", "Σ"], + ["−", "-"], + ["∘", "°"], + ["∙", "・"], + ["∼", "~"], + ["≪", "«"], + ["≫", "»"], + ["⊚", "◎"], + ["⋅", "・"], + ["⌢", "⌒"], + ["⍑", "〒"], + ["⍳", "ι"], + ["⍺", "α"], + ["⏜", "⌒"], + ["⏺", "●"], + ["Ⓘ", "I"], + ["Ⓞ", "O"], + ["Ⓡ", "R"], + ["⓪", "0"], + ["▢", "□"], + ["▪", "■"], + ["▲", "△"], + ["▵", "△"], + ["▼", "▽"], + ["▿", "▽"], + ["◉", "◎"], + ["◊", "◇"], + ["◠", "⌒"], + ["◯", "○"], + ["◻", "□"], + ["◼", "■"], + ["☐", "□"], + ["♢", "◇"], + ["♦", "◆"], + ["♩", "♪"], + ["♫", "♪"], + ["⚪", "○"], + ["⚫", "●"], + ["✕", "×"], + ["✖", "×"], + ["✘", "×"], + ["✝", "†"], + ["✞", "†"], + ["✩", "☆"], + ["✫", "★"], + ["✬", "★"], + ["✭", "★"], + ["✯", "★"], + ["✰", "☆"], + ["➡", "→"], + ["⟶", "→"], + ["⟹", "⇒"], + ["⦾", "◎"], + ["⦿", "◎"], + ["⨯", "×"], + ["⬆", "↑"], + ["⬇", "↓"], + ["⬛", "■"], + ["⬜", "□"], + ["⬤", "●"], + ["⬦", "◇"], + ["⭐", "★"], + ["⭑", "★"], + ["⭗", "◎"], + ["\u3000", " "], + ["〃", "”"], + ["《", "«"], + ["》", "»"], + ["『", "「"], + ["』", "」"], + ["〜", "~"], + ["ゞ", "ゝ"], + ["㊦", "下"], + ["㋛", "シ"], + ["︵", "("], + ["﹁", "「"], + ["﹒", "."], + ["﹤", "<"], + ["﹥", ">"], + ["0", "0"], + ["1", "1"], + ["3", "3"], + ["8", "8"], + ["C", "C"], + ["O", "O"], + ["`", "`"], + ["。", "。"], + ["「", "「"], + ["」", "」"], + ["、", "、"], + ["・", "・"], + ["ア", "ア"], + ["カ", "カ"], + ["シ", "シ"], + ["ソ", "ソ"], + ["タ", "タ"], + ["チ", "チ"], + ["ヒ", "ヒ"], + ["メ", "メ"], + ["モ", "モ"], + ["¢", "¢"], + ["𝐅", "F"], + ["𝐌", "M"], + ["𝐚", "a"], + ["𝐜", "c"], + ["𝐞", "e"], + ["𝐫", "r"], + ["𝐬", "s"], + ["𝑓", "ƒ"], + ["𝑥", "x"], + ["𝒂", "a"], + ["𝒇", "f"], + ["𝒐", "o"], + ["𝒕", "t"], + ["𝓒", "C"], + ["𝓔", "E"], + ["𝓜", "M"], + ["𝓦", "W"], + ["𝓨", "Y"], + ["𝓪", "a"], + ["𝓮", "e"], + ["𝓱", "h"], + ["𝓲", "i"], + ["𝓴", "k"], + ["𝓸", "o"], + ["𝓽", "t"], + ["𝕆", "O"], + ["𝕖", "e"], + ["𝕟", "n"], + ["𝕣", "r"], + ["𝘢", "a"], + ["𝘧", "f"], + ["𝘮", "m"], + ["𝘳", "r"], + ["𝘵", "t"], + ["𝙼", "M"], + ["𝛂", "α"], + ["𝜁", "ζ"], + ["𝜄", "ι"], + ["𝜶", "α"], + ["𝝰", "α"], + ["𝝸", "ι"], + ["𝞶", "ν"], + ["🌟", "★"], + ["🎵", "♪"], + ["🎶", "♪"], + ["🔺", "△"], + ["🔻", "▽"], +]); + +const NAME_MAX_LENGTH = 10; + +const INVISIBLE_CHARACTER_REGEXP = /^(?:\p{Cf}|\p{Variation_Selector})$/u; + +export async function up(db: Kysely): Promise { + const rows = await db + .selectFrom("User") + .select([sql`rowid`.as("rowId"), "inGameName"]) + .where("inGameName", "is not", null) + .execute(); + + for (const row of rows) { + const replaced = withoutOverflowingTrailingSpaces( + [...row.inGameName] + .filter((character) => !INVISIBLE_CHARACTER_REGEXP.test(character)) + .map((character) => REPLACEMENTS.get(character) ?? character) + .join(""), + ); + if (replaced === row.inGameName) continue; + + await db + .updateTable("User") + .set({ inGameName: replaced }) + .where(sql`rowid`, "=", row.rowId) + .execute(); + } +} + +function withoutOverflowingTrailingSpaces(inGameName: string) { + const discriminatorIndex = inGameName.lastIndexOf("#"); + if (discriminatorIndex === -1) return inGameName; + + const name = inGameName.slice(0, discriminatorIndex); + if ([...name].length <= NAME_MAX_LENGTH) return inGameName; + + return `${name.trimEnd()}${inGameName.slice(discriminatorIndex)}`; +} diff --git a/scripts/scanner/build-glyph-atlas.ts b/scripts/scanner/build-glyph-atlas.ts index 7f3b5cd07..5358f15c6 100644 --- a/scripts/scanner/build-glyph-atlas.ts +++ b/scripts/scanner/build-glyph-atlas.ts @@ -106,14 +106,13 @@ const NAME_GREEK = "ια"; // ι: "Rιppιng_H", α: "◇Dαrz™" (special-symb * The rest of the in-game name editor's symbol pickers (sendou.ink's * IN_GAME_NAME_CHARACTER_CATEGORIES "symbols" + "cjk-symbols"), minus what * nameCharset() already carries and chars the Blitz cmap doesn't map - * (ˊˋ𝑓⁀⚪⚫◻◼⍑; nameSymbols() re-checks at build time). "•" stays out: BlitzMain's + * (nameSymbols() checks at build time). "•" stays out: BlitzMain's * bullet is a 4px dot, the on-screen circle comes from "●" via RENDER_ALIASES. * Only the fullwidth "~" (U+FF5E), the form fixture labels attest — the wave * dash "〜" (U+301C) is a pixel-identical homoglyph that would duel it. * Scoreboard-names only (not death-tag) until attested, like NAME_GREEK. */ -const NAME_SYMBOLS = - "′‘’‚‛…″“”„←→↑↓⇒⇔˜€∞√∀⊂⊃∴∵∂№♭♀♂◎◇◆△▲▽▼†※™『』【】〈〉《》〔〕々〆〇〃~"; +const NAME_SYMBOLS = "′‘’…”‐―←→↑↓⇒⇔˜€ƒ∞√∀⊂⊃∴∵∂№♭♀♂◎□■◇◆△▽†※™【】々〆〒仝~"; /** * Render the key char but emit it as the value: in-game names show "•" as a