From e0ee25cbb66057d72d8263bac2015e2fab4ef2a0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:01:36 +0300 Subject: [PATCH] Expand ign allow list --- app/features/user-page/in-game-name.test.ts | 24 +++++++++-- app/features/user-page/in-game-name.ts | 45 +++++++++++++++++---- app/form/fields.ts | 2 + 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/app/features/user-page/in-game-name.test.ts b/app/features/user-page/in-game-name.test.ts index e7f2ea9ee..ecef89c56 100644 --- a/app/features/user-page/in-game-name.test.ts +++ b/app/features/user-page/in-game-name.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { inGameNameIsValid } from "./in-game-name"; +import { inGameNameIsValid, sanitizeInGameName } from "./in-game-name"; describe("inGameNameIsValid", () => { test("passes valid in-game names", () => { @@ -31,6 +31,14 @@ describe("inGameNameIsValid", () => { "〆〇〃#1234", "※Test#1234", "○☆Mikurby#2897", + "仝#1234", + "三#1234", + "日本語#1234", + "名前テスト1234#ab12c", + "한국어#1234", + "中文名#1234", + "Test!#1234", + "Cafe\u0301#1234", ]; for (const name of validNames) { @@ -48,9 +56,7 @@ describe("inGameNameIsValid", () => { "Sendou#ABCD", "12345678901#1234", "𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔#1234", - "名前テスト1234#ab12c", "☆CR☆Sh𝓔𝓔p!#1234", - "日本語#1234", ]; for (const name of invalidNames) { @@ -72,3 +78,15 @@ describe("inGameNameIsValid", () => { } }); }); + +describe("sanitizeInGameName", () => { + test.each([ + ["仝#1234", "仝#1234", "kanji outside the character picker"], + ["三三三#1234", "三三三#1234", "pasted kanji"], + ["한국어#1234", "한국어#1234", "hangul"], + ["Cafe\u0301#1234", "Café#1234", "decomposed accents are composed"], + ["100%#1234", "100#1234", "characters the game does not allow"], + ])("%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 f2d93f494..209474fa2 100644 --- a/app/features/user-page/in-game-name.ts +++ b/app/features/user-page/in-game-name.ts @@ -58,7 +58,7 @@ export const IN_GAME_NAME_CHARACTER_CATEGORIES = [ characters: ReadonlyArray; }>; -const SPECIAL_CHARACTERS = IN_GAME_NAME_CHARACTER_CATEGORIES.flatMap( +const PICKER_CHARACTERS = IN_GAME_NAME_CHARACTER_CATEGORIES.flatMap( (category) => category.characters, ); @@ -69,9 +69,26 @@ const ASCII_CHARACTERS = range(0x20, 0x7e).filter( const ALLOWED_CHARACTERS = new Set([ ...ASCII_CHARACTERS, - ...SPECIAL_CHARACTERS, + ...PICKER_CHARACTERS, ]); +/** + * Letters of every script a Nintendo Switch keyboard can produce. Notably this + * includes kanji/hanzi and hangul, which are too numerous to enumerate in the + * character picker but are perfectly valid to paste in. + */ +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; + +/** Punctuation & symbol code point ranges not covered by any of 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; + const IN_GAME_NAME_REGEXP = new RegExp( `^(.+)#([0-9a-z]{${IN_GAME_NAME.DISCRIMINATOR_MIN_LENGTH},${IN_GAME_NAME.DISCRIMINATOR_MAX_LENGTH}})$`, "u", @@ -82,14 +99,18 @@ export function inGameNameLength(value: string): number { return [...value].length; } +/** Normalizes the in-game name and drops every character the game does not allow. */ export function sanitizeInGameName(value: string): string { - return [...value.normalize("NFC")] - .filter((character) => ALLOWED_CHARACTERS.has(character)) - .join(""); + return [...normalizeInGameName(value)].filter(characterIsAllowed).join(""); +} + +/** Unicode normalization applied to in-game names before they are validated or stored. */ +export function normalizeInGameName(value: string): string { + return value.normalize("NFC"); } export function inGameNameIsValid(value: string): boolean { - const match = IN_GAME_NAME_REGEXP.exec(value); + const match = IN_GAME_NAME_REGEXP.exec(normalizeInGameName(value)); if (!match) return false; const nameCharacters = [...match[1]]; @@ -100,7 +121,17 @@ export function inGameNameIsValid(value: string): boolean { return false; } - return nameCharacters.every((character) => ALLOWED_CHARACTERS.has(character)); + return nameCharacters.every(characterIsAllowed); +} + +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, + ); } function range(from: number, to: number): string[] { diff --git a/app/form/fields.ts b/app/form/fields.ts index 0da973ed8..bcfaaa968 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -3,6 +3,7 @@ import * as v from "valibot"; import { IN_GAME_NAME_MAX_LENGTH, inGameNameIsValid, + normalizeInGameName, } from "~/features/user-page/in-game-name"; import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; @@ -253,6 +254,7 @@ export function inGameName( safeNullableStringSchema({ max: IN_GAME_NAME_MAX_LENGTH, }), + v.transform((val) => (val === null ? null : normalizeInGameName(val))), v.check( (val) => val === null || inGameNameIsValid(val), "forms:errors.profileInGameName",