9c30bdf8e7
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
A handle joining two meaningful words with a dot ("Тихий.Воин", "Hidden.Hunter")
reads as machine-generated — people use "_" there, a dot only rarely. assembleHandle
now picks "_" about nine times in ten and "." only about one in ten for the
separator-joined form; the camelCase and bare-noun forms are unchanged.
326 lines
16 KiB
Go
326 lines
16 KiB
Go
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
|
||
// camel-cased ("DarkWolf"), or joined by a separator. Between two meaningful words a human
|
||
// almost always uses "_" — a "." there is a rare tell — so the separator is an underscore
|
||
// far more often than a dot. An optional trailing number may follow.
|
||
func assembleHandle(adj, noun string) string {
|
||
var base string
|
||
switch rand.IntN(4) {
|
||
case 0:
|
||
base = noun // a bare noun
|
||
case 1:
|
||
base = adj + noun // camel-cased
|
||
default:
|
||
sep := "_"
|
||
if rand.IntN(10) == 0 {
|
||
sep = "." // a dot between two words is rare
|
||
}
|
||
base = adj + sep + 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) // 1–9
|
||
case 1:
|
||
return strconv.Itoa(rand.IntN(90) + 10) // 10–99
|
||
default:
|
||
return strconv.Itoa(rand.IntN(40) + 1985) // 1985–2024
|
||
}
|
||
}
|
||
|
||
// 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{
|
||
{"Иванов", "Иванова"}, {"Смирнов", "Смирнова"}, {"Кузнецов", "Кузнецова"},
|
||
{"Соколов", "Соколова"}, {"Попов", "Попова"}, {"Лебедев", "Лебедева"},
|
||
{"Новиков", "Новикова"}, {"Морозов", "Морозова"}, {"Волков", "Волкова"},
|
||
{"Зайцев", "Зайцева"}, {"Павлов", "Павлова"}, {"Беляев", "Беляева"},
|
||
{"Орлов", "Орлова"}, {"Громов", "Громова"}, {"Суханов", "Суханова"},
|
||
{"Киселёв", "Киселёва"}, {"Макаров", "Макарова"}, {"Фёдоров", "Фёдорова"},
|
||
{"Никитин", "Никитина"}, {"Захаров", "Захарова"}, {"Борисов", "Борисова"},
|
||
{"Королёв", "Королёва"}, {"Герасимов", "Герасимова"}, {"Пономарёв", "Пономарёва"},
|
||
{"Григорьев", "Григорьева"}, {"Романов", "Романова"}, {"Виноградов", "Виноградова"},
|
||
{"Богданов", "Богданова"}, {"Воробьёв", "Воробьёва"}, {"Сергеев", "Сергеева"},
|
||
{"Кузьмин", "Кузьмина"}, {"Соловьёв", "Соловьёва"}, {"Васильев", "Васильева"},
|
||
{"Михайлов", "Михайлова"}, {"Петров", "Петрова"}, {"Тарасов", "Тарасова"},
|
||
{"Белов", "Белова"}, {"Комаров", "Комарова"}, {"Гусев", "Гусева"},
|
||
{"Титов", "Титова"},
|
||
}
|