Files
scrabble-game/backend/internal/robot/namevariety.go
T
Ilia Denisov 183e08ec80
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
feat(robot): per-game display names from a wide name corpus
Decouple the displayed opponent name from the small pool of durable robot
accounts: the disguised auto-match robot now gets a freshly composed name each
game, stamped on a new game_players.display_name seat snapshot. The snapshot
also captures humans' names, freezing what an opponent sees for the life of a
game (a later rename no longer rewrites past games); readers fall back to the
account's current name for pre-migration rows.

Names come from a wide composed corpus (internal/robot/namevariety.go): Western
locales (EN/DE/ES/IT/FR/PT), native Japanese/Chinese names, a gender-agreed
Russian pool, and human-style handles. Routing keeps Pick's spirit -- a Russian
game draws Cyrillic + <=20% Latin and never a CJK script; an English game the
full corpus -- via robot.PickNamed.

Loosen account.ValidateDisplayName (and the UI mirror) to admit a trailing run
of up to five digits, so "Player2007"-style handles are valid for humans too
and the disguised robot stays indistinguishable.

Migration 00007 adds game_players.display_name (additive, NOT NULL DEFAULT '');
jet regenerated. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru, PLAN, README) updated.
2026-06-16 12:28:04 +02:00

298 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package robot
import (
"math/rand/v2"
"strconv"
"scrabble/backend/internal/engine"
)
// This file holds the wide, multi-locale corpus and the generator that gives each
// auto-match robot a fresh, per-game display name (composeRandomName, reached through
// Service.PickNamed). It is deliberately separate from the seeding pools in names.go:
// those are a fixed 32-per-language set bound 1:1 to the durable robot accounts (a
// stable fallback name), whereas this corpus only sources throwaway per-game names and
// can grow freely. Because the chosen name is stored on the seat rather than on the
// account, the small pool of accounts presents as an ever-changing crowd of opponents.
//
// Every composed name stays within account.ValidateDisplayName's editable format
// (Unicode letters, single "." / "_" separators, an optional trailing run of up to five
// digits), so a disguised robot remains indistinguishable from a human
// (docs/ARCHITECTURE.md §7). A Russian-variant game draws Cyrillic names with at most
// latinShareInRussian% Latin and never a CJK script; an English game draws the full
// international corpus (Western Latin names, native Japanese/Chinese names, Latin handles).
// nickSharePercent is the share of per-game names rendered as a handle (e.g.
// "DarkWolf07") rather than a person's real name.
const nickSharePercent = 35
// cjkShareIntl is the share of an international game's real names rendered in a native
// Japanese or Chinese script; the remainder are Western Latin names.
const cjkShareIntl = 25
// composeRandomName builds a fresh display name appropriate to the game variant (see
// the file overview for the routing). The result always satisfies
// account.ValidateDisplayName.
func composeRandomName(variant engine.Variant) string {
if variant == engine.VariantRussianScrabble || variant == engine.VariantErudit {
if rand.IntN(100) < latinShareInRussian {
return latinPersona()
}
return cyrillicPersona()
}
return intlPersona()
}
// cyrillicPersona returns a Cyrillic real name or handle.
func cyrillicPersona() string {
if rand.IntN(100) < nickSharePercent {
return cyrillicNick()
}
return cyrillicReal()
}
// latinPersona returns a Western Latin real name or handle (also the Latin minority of
// Russian games and the handles of international games).
func latinPersona() string {
if rand.IntN(100) < nickSharePercent {
return latinNick()
}
return latinReal()
}
// intlPersona returns an international name: a Latin handle, a native Japanese/Chinese
// real name, or a Western Latin real name.
func intlPersona() string {
if rand.IntN(100) < nickSharePercent {
return latinNick()
}
if rand.IntN(100) < cjkShareIntl {
return cjkReal()
}
return latinReal()
}
// latinReal composes a Western full name within a single locale so it reads naturally.
func latinReal() string {
loc := latinLocales[rand.IntN(len(latinLocales))]
first := loc.first[rand.IntN(len(loc.first))]
surname := loc.surnames[rand.IntN(len(loc.surnames))]
return composeName(first, surname, rand.IntN(3))
}
// cyrillicReal composes a Russian full name, agreeing the surname form with the first
// name's grammatical gender, in one of the three rendering forms.
func cyrillicReal() string {
fn := firstNamesRUWide[rand.IntN(len(firstNamesRUWide))]
sp := surnamesRUWide[rand.IntN(len(surnamesRUWide))]
surname := sp.m
if fn.female {
surname = sp.f
}
return composeName(fn.name, surname, rand.IntN(3))
}
// cjkReal composes a native Japanese or Chinese name.
func cjkReal() string {
if rand.IntN(2) == 0 {
return japaneseName()
}
return chineseName()
}
// japaneseName composes a Japanese name: family-then-given (space-joined), or a bare
// family or given name as a handle.
func japaneseName() string {
family := jpSurnames[rand.IntN(len(jpSurnames))]
given := jpGiven[rand.IntN(len(jpGiven))]
switch rand.IntN(10) {
case 0, 1, 2:
return given
case 3:
return family
default:
return family + " " + given
}
}
// chineseName composes a Chinese name: family then given, written without a separator.
func chineseName() string {
family := cnSurnames[rand.IntN(len(cnSurnames))]
given := cnGiven[rand.IntN(len(cnGiven))]
return family + given
}
// latinNick builds a Latin handle from the adjective and noun stem pools.
func latinNick() string {
return composeNick(adjLatin, nounLatin)
}
// cyrillicNick builds a Cyrillic handle from the adjective and noun stem pools.
func cyrillicNick() string {
return composeNick(adjCyr, nounCyr)
}
// composeNick assembles a handle from an adjective and noun pool: a bare noun, an
// adjective and noun joined by "_" or "." or camel-cased, optionally followed by a
// trailing number. It keeps at most one separator so the result, the number aside,
// reads like a name a human could pick.
func composeNick(adjectives, nouns []string) string {
adj := adjectives[rand.IntN(len(adjectives))]
noun := nouns[rand.IntN(len(nouns))]
var base string
switch rand.IntN(4) {
case 0:
base = noun
case 1:
base = adj + "_" + noun
case 2:
base = adj + "." + noun
default:
base = adj + noun
}
if rand.IntN(2) == 0 {
base += nickNumber()
}
return base
}
// nickNumber returns a short trailing number a handle commonly carries — a single
// digit, a two-digit suffix, or a four-digit year — always within the validator's
// five-digit cap.
func nickNumber() string {
switch rand.IntN(3) {
case 0:
return strconv.Itoa(rand.IntN(9) + 1) // 19
case 1:
return strconv.Itoa(rand.IntN(90) + 10) // 1099
default:
return strconv.Itoa(rand.IntN(40) + 1985) // 19852024
}
}
// latinLocale is one Western locale's first names and surnames, paired only within the
// locale so a composed full name reads naturally.
type latinLocale struct {
first []string
surnames []string
}
// latinLocales are the Western (Latin-script) locales the international and Russian-Latin
// pools draw from: English (reusing the seeding pools), German, Spanish, Italian,
// French, Portuguese.
var latinLocales = []latinLocale{
{first: firstNamesFullEN, surnames: surnamesEN},
{ // German
first: []string{"Lukas", "Leon", "Finn", "Jonas", "Felix", "Maximilian", "Paul", "Elias", "Noah", "Ben", "Klaus", "Wolfgang", "Hannah", "Emma", "Mia", "Sofia", "Lena", "Marie", "Laura", "Greta", "Ingrid", "Stefan"},
surnames: []string{"Müller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", "Wagner", "Becker", "Hoffmann", "Koch", "Richter", "Bauer", "Klein", "Wolf", "Schröder", "Neumann", "Braun", "Werner", "Krüger", "Hartmann", "Lange", "Schulze"},
},
{ // Spanish
first: []string{"Mateo", "Hugo", "Martín", "Lucas", "Daniel", "Pablo", "Álvaro", "Diego", "Javier", "Carlos", "Lucía", "Sofía", "María", "Martina", "Paula", "Valeria", "Carmen", "Elena", "Sara", "Pilar", "Andrés", "Sergio"},
surnames: []string{"García", "Martínez", "López", "Sánchez", "González", "Rodríguez", "Fernández", "Gómez", "Jiménez", "Ruiz", "Hernández", "Díaz", "Moreno", "Álvarez", "Romero", "Alonso", "Navarro", "Torres", "Ramos", "Serrano", "Castro", "Rubio"},
},
{ // Italian
first: []string{"Francesco", "Alessandro", "Lorenzo", "Mattia", "Andrea", "Leonardo", "Gabriele", "Riccardo", "Tommaso", "Matteo", "Sofia", "Giulia", "Aurora", "Alice", "Emma", "Giorgia", "Martina", "Chiara", "Giuseppe", "Marco", "Roberto", "Stefano"},
surnames: []string{"Rossi", "Russo", "Ferrari", "Esposito", "Bianchi", "Romano", "Colombo", "Ricci", "Marino", "Greco", "Bruno", "Gallo", "Conti", "Costa", "Giordano", "Mancini", "Rizzo", "Lombardi", "Moretti", "Barbieri", "Fontana", "Caruso"},
},
{ // French
first: []string{"Gabriel", "Louis", "Raphaël", "Jules", "Adam", "Arthur", "Nathan", "Léo", "Emma", "Jade", "Louise", "Alice", "Chloé", "Léa", "Manon", "Camille", "Pierre", "Sophie", "Julien", "Céline", "Antoine", "Claire"},
surnames: []string{"Martin", "Bernard", "Dubois", "Robert", "Richard", "Petit", "Durand", "Leroy", "Moreau", "Simon", "Laurent", "Lefebvre", "Michel", "Bertrand", "Roux", "Vincent", "Fournier", "Morel", "Girard", "Mercier", "Lambert", "Bonnet"},
},
{ // Portuguese
first: []string{"João", "Pedro", "Tiago", "Miguel", "Tomás", "Rodrigo", "Diogo", "Afonso", "Duarte", "Maria", "Leonor", "Matilde", "Beatriz", "Carolina", "Mariana", "Inês", "Sofia", "Lara", "António", "Rita", "Catarina", "Bruno"},
surnames: []string{"Silva", "Santos", "Ferreira", "Pereira", "Oliveira", "Costa", "Rodrigues", "Martins", "Sousa", "Fernandes", "Gonçalves", "Gomes", "Lopes", "Marques", "Alves", "Almeida", "Ribeiro", "Pinto", "Carvalho", "Teixeira", "Moreira", "Correia"},
},
}
// jpSurnames and jpGiven are common Japanese family and given names in kanji.
var jpSurnames = []string{
"佐藤", "鈴木", "高橋", "田中", "渡辺", "伊藤", "山本", "中村", "小林", "加藤",
"吉田", "山田", "山口", "松本", "井上", "木村", "斎藤", "清水", "山崎", "池田",
"橋本", "阿部",
}
var jpGiven = []string{
"大翔", "蓮", "陽翔", "悠真", "颯太", "湊", "翔太", "大和", "健太", "樹",
"葵", "陽菜", "凛", "結衣", "美咲", "優奈", "七海", "真央", "愛", "杏",
}
// cnSurnames and cnGiven are common Chinese family and given names in Han characters.
var cnSurnames = []string{
"王", "李", "张", "刘", "陈", "杨", "黄", "赵", "周", "吴",
"徐", "孙", "胡", "朱", "高", "林", "何", "郭", "马", "罗",
"梁", "宋", "郑", "韩",
}
var cnGiven = []string{
"伟", "芳", "娜", "敏", "静", "丽", "强", "磊", "军", "洋",
"勇", "艳", "杰", "娟", "涛", "明", "超", "霞", "刚", "婷",
"秀英", "建华",
}
// adjLatin and nounLatin are the adjective and noun stems for Latin handles.
var adjLatin = []string{
"Dark", "Night", "Shadow", "Frost", "Silent", "Swift", "Wild", "Royal", "Lucky", "Crimson",
"Mystic", "Cosmic", "Epic", "Noble", "Brave", "Lone", "Iron", "Steel", "Golden", "Silver",
"Neon", "Cyber", "Turbo", "Rapid", "Hidden", "Frozen", "Electric", "Savage",
}
var nounLatin = []string{
"Wolf", "Fox", "Raven", "Tiger", "Eagle", "Dragon", "Falcon", "Hawk", "Lion", "Bear",
"Panther", "Viper", "Cobra", "Shark", "Phoenix", "Knight", "Ranger", "Hunter", "Rider", "Ghost",
"Phantom", "Comet", "Nova", "Blade", "Arrow", "Storm", "Wizard", "Ninja",
}
// adjCyr and nounCyr are the adjective and noun stems for Cyrillic handles.
var adjCyr = []string{
"Тёмный", "Ночной", "Снежный", "Лунный", "Звёздный", "Огненный", "Ледяной", "Стальной", "Дикий", "Тихий",
"Быстрый", "Гордый", "Грозный", "Хитрый", "Смелый", "Весёлый", "Рыжий", "Серый", "Золотой", "Дерзкий",
"Лютый", "Бравый", "Мудрый", "Шальной", "Вольный", "Яростный", "Седой", "Жгучий",
}
var nounCyr = []string{
"Волк", "Лис", "Ворон", "Тигр", "Орёл", "Дракон", "Сокол", "Ястреб", "Лев", "Медведь",
"Барс", "Кот", "Кит", "Странник", "Охотник", "Рыцарь", "Маг", "Призрак", "Воин", "Комета",
"Клинок", "Ветер", "Гром", "Шторм", "Пилот", "Капитан", "Мастер", "Енот",
}
// firstNamesRUWide is a broad pool of Russian first names — full and colloquial forms
// mixed, each tagged by grammatical gender — for the per-game generator (wider than the
// 32-slot seeding pool in names.go).
var firstNamesRUWide = []genderedName{
{"Александр", false}, {"Саша", false}, {"Дмитрий", false}, {"Дима", false},
{"Сергей", false}, {"Серёжа", false}, {"Алексей", false}, {"Лёша", false},
{"Михаил", false}, {"Миша", false}, {"Николай", false}, {"Коля", false},
{"Владимир", false}, {"Володя", false}, {"Константин", false}, {"Костя", false},
{"Павел", false}, {"Паша", false}, {"Андрей", false}, {"Андрюша", false},
{"Иван", false}, {"Ваня", false}, {"Пётр", false}, {"Петя", false},
{"Роман", false}, {"Рома", false}, {"Антон", false}, {"Евгений", false},
{"Женя", false}, {"Максим", false}, {"Кирилл", false}, {"Артём", false},
{"Анна", true}, {"Аня", true}, {"Мария", true}, {"Маша", true},
{"Анастасия", true}, {"Настя", true}, {"Наталья", true}, {"Наташа", true},
{"Татьяна", true}, {"Таня", true}, {"Екатерина", true}, {"Катя", true},
{"Юлия", true}, {"Юля", true}, {"Ольга", true}, {"Оля", true},
{"Елена", true}, {"Лена", true}, {"Ирина", true}, {"Ира", true},
{"Светлана", true}, {"Света", true}, {"Дарья", true}, {"Даша", true},
{"София", true}, {"Соня", true}, {"Полина", true}, {"Алина", true},
{"Вера", true}, {"Ксения", true},
}
// surnamesRUWide is a broad pool of Russian surnames in masculine and feminine forms
// for the per-game generator.
var surnamesRUWide = []surnamePair{
{"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"},
{"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"},
{"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"},
{"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"},
{"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"},
{"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"},
{"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"},
{"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"},
{"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"},
{"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"},
{"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"}, {"Васильев", "Васильева"},
{"Михайлов", "Михайлова"}, {"Петров", "Петрова"}, {"Тарасов", "Тарасова"},
{"Белов", "Белова"}, {"Комаров", "Комарова"}, {"Гусев", "Гусева"},
{"Титов", "Титова"},
}