IGN allowed characters complete list + replace automatically

This commit is contained in:
Kalle
2026-10-02 19:38:42 +03:00
parent 92bce91a1b
commit 5d87e2833f
5 changed files with 507 additions and 39 deletions

View File

@@ -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);
});

View File

@@ -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<string>([
...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++) {

View File

@@ -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 ’)

View File

@@ -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<string, string>([
["\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<any>): Promise<void> {
const rows = await db
.selectFrom("User")
.select([sql<number>`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)}`;
}

View File

@@ -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