Files
scrabble-game/backend/internal/robot/namevariety.go
T
Ilia Denisov 14b5f61af9
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
feat(robot): gender-agree Cyrillic handle adjectives
Cyrillic adjective+noun handles paired a masculine adjective with any noun, so a
feminine noun read ungrammatically ("Вольный Комета"). Carry masculine + feminine
adjective forms (cyrAdjective) and tag each noun's gender (cyrNoun); cyrillicNick
now renders the adjective in the agreeing form ("Рыжая Комета", "Дикий Волк"), and a
few feminine nouns (Комета/Звезда/Молния/Пантера/Рысь/Буря/Сова/Акула) are added for variety.
2026-06-16 15:51:00 +02:00

323 lines
16 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. English
// adjectives do not inflect, so any pairing reads naturally.
func latinNick() string {
return assembleHandle(adjLatin[rand.IntN(len(adjLatin))], nounLatin[rand.IntN(len(nounLatin))])
}
// cyrillicNick builds a Cyrillic handle, agreeing the adjective's form with the noun's
// grammatical gender ("Дикий Волк", "Дикая Рысь").
func cyrillicNick() string {
noun := nounCyr[rand.IntN(len(nounCyr))]
adj := adjCyr[rand.IntN(len(adjCyr))]
form := adj.m
if noun.female {
form = adj.f
}
return assembleHandle(form, noun.word)
}
// assembleHandle joins an adjective and noun into a handle: a bare noun, the two 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 assembleHandle(adj, noun string) string {
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",
}
// cyrAdjective is a Russian adjective in its masculine and feminine forms, so a handle's
// stem can agree with the gender of its noun.
type cyrAdjective struct{ m, f string }
// cyrNoun is a Russian noun for a handle, tagged feminine so the paired adjective agrees.
type cyrNoun struct {
word string
female bool
}
// adjCyr and nounCyr are the adjective and noun stems for Cyrillic handles; cyrillicNick
// renders the adjective in the form that agrees with the chosen noun's gender.
var adjCyr = []cyrAdjective{
{"Тёмный", "Тёмная"}, {"Ночной", "Ночная"}, {"Снежный", "Снежная"}, {"Лунный", "Лунная"},
{"Звёздный", "Звёздная"}, {"Огненный", "Огненная"}, {"Ледяной", "Ледяная"}, {"Стальной", "Стальная"},
{"Дикий", "Дикая"}, {"Тихий", "Тихая"}, {"Быстрый", "Быстрая"}, {"Гордый", "Гордая"},
{"Грозный", "Грозная"}, {"Хитрый", "Хитрая"}, {"Смелый", "Смелая"}, {"Весёлый", "Весёлая"},
{"Рыжий", "Рыжая"}, {"Серый", "Серая"}, {"Золотой", "Золотая"}, {"Дерзкий", "Дерзкая"},
{"Лютый", "Лютая"}, {"Бравый", "Бравая"}, {"Мудрый", "Мудрая"}, {"Шальной", "Шальная"},
{"Вольный", "Вольная"}, {"Яростный", "Яростная"}, {"Седой", "Седая"}, {"Жгучий", "Жгучая"},
}
var nounCyr = []cyrNoun{
{"Волк", 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},
}
// 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{
{"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"},
{"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"},
{"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"},
{"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"},
{"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"},
{"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"},
{"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"},
{"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"},
{"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"},
{"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"},
{"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"}, {"Васильев", "Васильева"},
{"Михайлов", "Михайлова"}, {"Петров", "Петрова"}, {"Тарасов", "Тарасова"},
{"Белов", "Белова"}, {"Комаров", "Комарова"}, {"Гусев", "Гусева"},
{"Титов", "Титова"},
}