Пошаговая стратегия · перенос Master of Orion II

Lord of Thessar

Игра на Spring Boot и React + Phaser, у которой рядом с самой игрой живёт целая мастерская приборов: они меряют баланс, взламывают его, подсматривают управление у настоящей MOO II в DOSBox и учат нейросеть играть. Эта страница — разбор того, из чего всё собрано.

369 классов сервера 116 модулей клиента 24 сущности 15 фаз хода 1145 ключей перевода 18 инструментов 36 сцен оригинала

Раздел 1

Компоненты системы и их связи

Ядро простое: клиент говорит с сервером по REST и слушает события партии, сервер держит партии в PostgreSQL, а правила игры читает из JSON-справочников на диске. Всё остальное на схеме — мастерская: инструменты, которые ту же игру меряют, расшатывают и учат.

КЛИЕНТ React + Phaser ui/ — экраны, game/ — карта state/ — Zustand + XState i18n/ — два языка 116 модулей СЕРВЕР Spring Boot · Java 23 web/ — 29 контроллеров service/ — 105 служб galaxy/ — генерация карты 15 фаз конца хода 369 классов, 49 тестов ХРАНИЛИЩЕ ПАРТИЙ PostgreSQL 15 · 5433 схема — Liquibase H2 в памяти — для прогонов СПРАВОЧНИКИ tech · buildings · ships leaders · race-traits · pairs REST SSE — события JPA чтение на лету МАСТЕРСКАЯ — ИНСТРУМЕНТЫ РАЗРАБОТКИ Пульт балансировки внутри сервера; играет партии пачкой и выносит приговор каждой цене стороны расы Оракул сборок там же; ищет сильнейшую расу поколениями с отсевом — «взломщик» против оценщика Орионометр пульт в меню игры плюс питоновский записыватель: смотрит на MOO II в DOSBox Нейросеть четыре головы, отбор поколениями; ведёт свою империю по тому же REST Сквозная проверка 596 проверок по всем подсистемам regress.py Партия целиком без человека восемь империй ИИ до победы ai_game.py · ai_audit.py Поиск мёртвых механик что тратит ресурсы и не даёт waste_check.py
Сплошные стрелки — то, чем игра работает; пунктир — то, чем её меряют. Пульт балансировки и оракул живут внутри сервера: они играют партии его же кодом, а не своей копией правил.
Правило
Данные, а не код

Новое здание, технология, пушка, корпус или лидер — запись в JSON, а не ветка в сервисе. В базе от них остаются только коды.

Правило
Один расчёт — одно место

Все числовые правила MoO2 живут в PopulationCalculator, боя — в BattleRules, полёта — в FlightRules. Величина, посчитанная дважды, однажды разойдётся.

Правило
Ход считается один раз

Галактика вычитывается раз на ход и передаётся фазам контекстом. Выборка «на игрока» внутри хода бьёт по всему ходу.

Раздел 2

Как приборы балансировки работают вместе

Задача у них одна: назначить честную цену каждой стороне расы — их полсотни, и игрок собирает из них свою расу на заданный бюджет. Цена не назначается на глаз: её меряют, и меряют в четыре этапа, где каждый следующий проверяет предыдущий.

ЭТАП 0 Телеметрия партии пачкой, летопись по ходам, счётчики того, чем империя пользовалась ЭТАП 1 Курс очка сколько силы приносит одно очко бюджета — шкала для приговоров ЭТАП 2 · ОЦЕНЩИК Пульт случайные законные сборки, регрессия по семи мерилам, приговор каждой цене ЭТАП 3 · ВЗЛОМЩИК Оракул сборок поколения с отсевом ищут расу, которая бьёт всё остальное ЕДИНСТВЕННОЕ, ЧТО ДВИГАЕТСЯ race-traits.json — цены сторон расы балансировка меняет ЦЕНУ, а не действие стороны приговоры находки взломщика следующий круг мерит заново Журнал цен: каждая правка записана вместе с тем, на чём она измерена — без него через месяц не откатить и не понять, откуда цена взялась.
Круг замкнут: приговоры и находки двигают цены в справочнике, а поднятые цены меняют и курс очка — поэтому следующий круг меряет всё заново. Мера успеха круга — невязка: медиана расхождения между ценой и измеренной отдачей.

Почему оценщику нужен взломщик

Пульт назначает цены по случайным сборкам, а живой игрок случайной не играет: он ищет сильнейшую. Комбинацию, которая бьёт всё остальное, случайными сборками не поймать — она встречается раз на тысячу. Оракул ищет её нарочно и отвечает на два вопроса плана: нет ли доминирующей сборки и какие стороны не вошли в верхушку.

Семь мерил силы

Выработка и наука с весом 1; деньги, военная сила, наземный бой, широта науки и разведка — с весом ½. Одного мерила мало: жителя в MOO II ставят либо к станку, либо в лабораторию, и без мерила науки «плохие учёные» за −3 очка выходили выгодной покупкой (+1,19), а с ним встали на место (−3,43).

Тонкость
Вес берётся регрессией

Стороны покупают не поодиночке, и сильная тянула бы за собой всё, что обычно стоит рядом. Доля приводится к нулю внутри партии, стартовый угол идёт столбцами.

Тонкость
У связки своя цена

Цены сторон складываются, а сила иных пар умножается. Поэтому у пары есть надбавка — и она тоже измеряется, а не назначается.

Тонкость
Пусто ≠ ноль

Мерило, не сработавшее в партии ни разу, отдаёт пусто. Ноль у всех — это «мерить нечем», а не «все одинаково слабы».

Раздел 3

Нейросеть: устройство и обучение

Сеть учится играть в нашу игру, а не в оригинал, и причина простая: партия у нас считается по REST за секунды, а в DOSBox — это полчаса настоящей мыши. Тысячи партий там не сыграть ни за какое время.

ОДНА ГОЛОВА = ОДНО РЕШЕНИЕ Стройка вход: 10 признаков колонии + 15 признаков проекта оценивает каждый проект по очереди, строит лучший Наука вход: 4 признака империи + 10 признаков уровня восемь разделов дерева Флот вход: 6 признаков флота + 9 признаков цели без неё сеть не выходила из родной системы ни разу Жители вход: 10 + 3 занятия добавлена последней: доля мощи стояла на 0,101 при справедливой 0,25 Скрытый слой: 48 узлов, tanh у каждой головы свои веса; выход — одно число «насколько это хорошо» ОБУЧЕНИЕ — ОТБОРОМ, А НЕ ГРАДИЕНТОМ поколение весов → партии по REST → доля своей мощи в мощи всех империй → лучшие остаются → потомки с шумом sigma sigma подстраивается сама: растёт при улучшении, падает при застое, при долгом застое — разогрев
Награда — исход целой партии, а не отдельного хода, и сказать, какое именно нажатие сколько принесло, нечем. Отбор требует лишь сравнения: сыграли одними весами, сыграли другими — чьи лучше, те и остались.

Каждая голова заведена не для полноты, а после замера, показавшего, что без неё учиться нечему. Сеть, умевшая только строить, за шестьдесят ходов не вышла из родной системы ни разу — колония одна, флотов ноль; сеть, не трогавшая жителей, играла тем распределением, какое колония получила при основании.

Раздел 4

Способ разработки: снятие сцен с оригинала

Половина окон MOO II нигде не выложена, а по памяти разметку не восстановить. Поэтому разметка наших экранов сверяется со снимками настоящей игры, снятыми в DOSBox. Инструменты для этого выросли в отдельную подсистему.

ИСТОЧНИК MOO II в DOSBox сборка 1.50.26, окно 640×480 ×2 ГЛАЗА Снимок окна PrintWindow — берёт кадр, даже если окно закрыто УЗНАВАНИЕ Примета 32×24 средние яркости НЕ всего кадра, а области, что стоит на месте СПРАВОЧНИК scenes.json — 36 сцен имя, область, что нажимать; 11 помечены «окно поверх» ВЕТКА А — ИНСТРУМЕНТ ИГРАЕТ САМ moo2_play.py читает растровый шрифт по словарю, меряет карту по пикселям, а решает выписками из нашего AiEmpireService ВЕТКА Б — ОРИОНОМЕТР СМОТРИТ moo2_record.py пишет каждое нажатие человека: время, точка в игровых координатах, сцена, расхождение узнавания и снимок ДО клика Неизвестный экран записыватель кладёт снимком и останавливается — так справочник и растёт: 21 сцена утром, 36 вечером.
Две ветки решают разные задачи. Инструмент, играющий сам, проверяет ход игры: что оригинал даёт строить, в каком порядке и почём. Орионометр отвечает на вопрос «каким нажатием это делается» — и отвечает дешевле любого перебора догадок.

Почему записывать человека оказалось дешевле, чем догадываться

Инструмент не умел переставлять жителей с работы на работу — и не умел никогда. Это чинилось четырьмя заходами подряд, и каждый стоил партии: перетаскивание фигурки, нажатие по фигурке, нажатие по свободному месту, перебор всего ряда. Наблюдения противоречили друг другу. Хозяин проекта назвал правило одной фразой: нажатие берёт всех, кто правее точки, а следующее кладёт их туда, где курсор. Всё сошлось с первой попытки. Орионометр и родился из этого счёта: вопрос стоит одной фразы, а перебор догадок — часов и испорченных партий.

Три грабли, на которых узнавание сцен ломалось

Примета
Снимают с неподвижного

У колонии примета бралась с угла, где стоит портрет губернатора, — только что основанная колония не узнавалась вовсе. У окна выкупа — с вопроса, где стоят цена и объём, а они меняются каждый раз.

Примета
Бедная — глотает чужое

Тёмная полоса во всю высоту экрана совпадает почти с чем угодно: ею оказались помечены 50 нажатий по 32 разным точкам — в окне, где кнопка одна.

Примета
Окно над картой — «поверх»

Примета карты снята с нижней полосы, а она видна из-под любого окна. Так спрятались ровно те две вещи, ради которых всё и делалось: отправка флота и начало боя — 37 кадров под чужим именем.

Вывод, записанный в грабли проекта: отсутствие чего-то в протоколе — это утверждение о приборе, а не о мире.

Раздел 5

Экраны игры

Снято с работающей сборки: сервер на 8080, клиент на 5173, партия на средней галактике с четырьмя империями. Разметка каждого экрана сверена со снимком соответствующего окна оригинала.

Экран входа в игру: заголовок Stellar Dominion, форма входа, переключатели языка и размера текста
Первая сцена — вход Экран стоит перед главным меню: сервер спрашивает пропуск на входе в любую партию. Справа вверху — переключатель языка и четыре ступени размера текста; размер и язык живут в учётной записи, а не в браузере.
Схема Млечного Пути: спиральная галактика из ста тысяч звёзд, панель управления поворотом и наклоном
Схема Млечного Пути Сто тысяч сто точек: сотня именованных элементов плюс сто тысяч звёзд фона. Кадр — 4,5 мс: координаты в типизированном массиве, синусы поворота считаются раз на кадр, звёзды рисуются пикселями в буфер одним вызовом.
Карта галактики: звёзды разных цветов, родная звезда Denebola, правая панель с казной, флотом, едой и наукой
Карта галактики Сцена Phaser с одной нижней полосой разделов — верхней полосы нет, весь верх отдан галактике. Справа сегменты империи: казна, флот, еда, транспорт, наука. Масштаб меняется колесом мыши.
Экран выбора исследования: восемь разделов дерева технологий двумя столбцами
Выбор исследования Разметка окна CHANGE CURRENT RESEARCH: два столбца на четыре строки, слева описание технологии под курсором. Заголовков у окна два — «выбор нового» и «смена текущего», и это тоже оригинал.

Раздел 6

Приборы измерения: пульт и оракул подробно

Оба отвечают на один вопрос — сколько стоит сторона расы, — и отвечают по-разному. Пульт меряет цены по случайным сборкам и выносит приговор каждой. Оракул делает обратное: нарочно ищет сборку, которая бьёт всё остальное. Оценщик и взломщик работают по очереди, и найденное взломщиком идёт в следующий замер.

Ниже — устройство обоих по коду: BalanceRunService, BalanceVerdictRules, RaceBuildGenerator, LeastSquares, BalanceOracleService, BalanceOracleRules. Все числа — из этих файлов, а не из общих соображений: каждое стоит там с объяснением, откуда оно взялось.

Пульт балансировки — оценщик

Хозяин игры описывает прогон из браузера: галактика, сколько империй и чем каждая играет, сколько партий и по сколько ходов, какую связку подсадить. Сервер играет их в фоне и возвращает не сырые замеры, а приговор каждой цене.

Что прибор делает, прежде чем начать считать

  • Снимает цены до первой партии. Прогон идёт десятки минут, а цены правятся с соседнего экрана и прямо в файле. Снимок лежит в самой записи прогона — иначе через день нельзя сказать, какие цены он мерил.
  • Пускается после фиксации транзакции. Фоновый поток первым делом читает запись прогона и из незакоммиченной транзакции её не находит: прогон молча замирал со словами «идёт» и нулём партий.
  • Идёт один за раз — исполнитель на один поток. Партий разом столько, сколько даёт BalanceLoad: ночью в полную силу, днём меньше, и предел спрашивается перед каждой партией, а не при старте — иначе менять его значило бы перезапустить сервер, то есть убить идущий прогон.

Откуда берутся сборки

RaceBuildGenerator выдаёт законную сборку ровно на бюджет — ту же, что собрал бы игрок: не больше одной стороны из группы-переключателя, без несовместимых пар, с надбавками за связки. Три решения внутри него важнее прочих.

Сборка
Умеет продавать слабости

Пока генератор брал одни плюсовые стороны, вся минусовая половина таблицы была слепым пятном: четырнадцать сторон из пятидесяти трёх не попадали в замер ни разу, и цену им нечем было проверить.

Сборка
Возврат выбирается заранее

Жадный набор брал бы слабости всегда — они ведь только освобождают очки, — и возврат почти всегда упирался бы в потолок. Тогда замер сравнивал бы не «со слабостью и без», а «с этой слабостью и с той».

Сборка
Бесплатное добирается в конце

Там, где группа осталась пустой. Так в сборке всегда есть строй: в конструкторе он есть у каждой расы, диктатура просто не стоит ничего. Без этого сборка на ноль очков была расой вовсе без строя — состоянием, в котором игрок не бывает.

Случайная сборка переигрывается на каждую партию, а не один раз на прогон: наблюдений в такой выборке столько, сколько сборок, а не сколько империй. И сборки переставляются по местам от партии к партии — стартовый угол главный источник разброса, и закреплённая за местом сборка забирала бы его удачу себе.

Семь мерил силы

МерилоВесЧем считаетсяПочему столько
PRODUCTION1доля выработки партииоснова: с неё разрыв бюджетов виден уверенно, тогда как по мощи тонул в своей же ошибке
RESEARCH1доля науки партиивторая половина хозяйства: жителя ставят либо к станку, либо в лабораторию
MONEY0,5доход за ходпоток вторичный: кредиты обращаются в производство по 2–4 за единицу. Не казна — она запас
MILITARY0,5сила построенных кораблейфлот не поток, а вложение; не строивший флот получает ноль — и это верно
GROUND0,5взятые десантом колонии минус потерянныеодни захваты не различали бойцов вовсе: 0,73 / 0,56 / 1,04 при ценах 2 / 1 / −1
TECHNOLOGY0,5число изученных технологийзаведено ради изобретательности: её польза в широте, а мерило науки такой разницы не видит
ESPIONAGE0,5доведённые до конца заданияне поток, а орудие: горстка событий за партию, и доля прыгает сильнее прочих

Приговор выносится по сумме (TOTAL), но пульт показывает и слагаемые: «сильно» и «сильно, но только в науке» — разные вещи, и решает их хозяин проекта, а не прибор.

  • Доля приводится к нулю внутри партии. Среднее известно заранее — сто процентов на число империй, — и вычитая его, эффект галактики убирается целиком, без отдельного столбца на партию.
  • Наземный бой — своим способом. Он и так разность взятого и потерянного, и среднее по партии у него тождественно ноль: всякий захват — чья-то потеря. Вычесть оттуда 100/n значило бы наказать всех, кто в этой партии не воевал на земле.
  • Пусто — это не ноль. Мерило, не сработавшее в партии ни разу, даёт ровно ноль, а не минус среднее: иначе отсутствие замера стало бы замером.

Как замер превращается в приговор

ЗАМЕРЫ Строка на империю что взяла, на сколько очков, семь долей и стартовый угол партий × империй строк МАТРИЦА Столбцы наблюдений • по столбцу на сторону расы • по столбцу на связку (до 40) • 2 ковариаты стартового угла • свободный член ответ — доля, приведённая к нулю СЧЁТ МНК с гребнем поправка — 2 % от числа наблюдений, не константа Гаусс — Жордан, без библиотек ЧТЕНИЕ Вес ± ошибка носителей ≥ 12, доля ≤ ½, расхождение против 2 ошибок дорого · дёшево · адекватно Вырожденность здесь встроена в правила игры: каждая сборка стоит ровно бюджет, поэтому взвешенная ценами сумма столбцов — константа, линейно зависимая со свободным членом. Символический гребень систему формально решал, а ошибки весов улетали в сотни пунктов.
Вес стороны — это то, что она добавляет сверх всего остального в сборке. Сравнение «взявшие против невзявших» так не умеет: стороны берутся не поодиночке, и сильная тянет за собой всё, что обычно стоит с ней рядом.

Пороги, по которым выносится приговор

ЧислоЗначениеЧто означает
MIN_TAKERS12меньше носителей — сторона не измерена вовсе, и приговор ей «не измерена», а не «адекватна»
MAX_SHARE_OF_TAKERS0,5взятую больше чем половиной сборок не мерим: «не взявшие» купили вместо неё соседку по группе, и сравнение выходит не с отсутствием, а с заменой. Бесплатная диктатура стоит в 97 % сборок
ERRORS_TO_JUDGE2,0обычный порог различимости. Меньше двух ошибок — это разброс партий, а не свойство стороны
RIDGE_SHARE0,02гребень долей от числа наблюдений: с ростом выборки сжатие остаётся соразмерным, и цены не прыгают оттого, что прогон стал длиннее
MAX_FAIR_PRICE_BUDGETS2,0справедливая цена дальше двух бюджетов не показывается: это уже не осторожная оценка, а мусор. Приговор при этом остаётся — он от деления не зависит
DAMPING0,4цена двигается долей пути к измеренной, а не в точку: ценность зависит от цены, и прыжок раскачивал бы цены вместо того, чтобы их успокаивать

Курс очка берётся из самих замеров — по различимым сторонам строится прямая «цена — сила», её наклон и есть курс. Единый курс, снятый по бюджетам на этапе 1, для этого не годится: он оказался неотличим от нуля, потому что сильные стороны в случайной сборке гасятся слабыми.

Шкала цен и шкала ценностей разошлись, и это главный вывод этапа 2: очко стоит около 0,05 п.п. доли выработки, то есть весь бюджет в пятнадцать очков — три четверти пункта, а одни «хорошие промышленники» за четыре очка дают два с половиной пункта, больше трёх полных бюджетов. Поэтому совет зажимается в то, что таблица выразить может, а показанная справедливая цена — нет: пусть видно, насколько сторона выходит за выразимое.

Связки сторон — второй замер

У половины таблицы честный ответ «сама по себе ничего не стоит»: киборги только едят, а с промышленниками и голодным миром они и есть Меклар. Такие стороны покупают не поодиночке, и мерить их поодиночке — значит мерить не то, за чем игрок к ним идёт. Поэтому в замер добавляется столбец на связку: единица там, где взяты обе стороны сразу, а его вес — ровно то, что связка даёт сверх своих половин.

  • Это второй замер, а не первый. Столбцы связок отбирают вес у самих сторон, а цену игрок платит за сторону, в какой бы сборке она ни оказалась. Приговоры ценам остаются с первого замера.
  • Пар берут сорок самых частых при пороге в двенадцать носителей: пар из полусотни сторон больше тысячи, и столбец на каждую сделал бы матрицу шире выборки — выродился бы весь замер, а не только связки.
  • Порог растёт с числом проверок. Проверяется не одна названная пара, а сорок разом, и самый крайний из сорока независимых замеров уходит от нуля примерно на √(2·ln 40) ≈ 2,7 ошибки сам по себе. На первом настоящем прогоне прибор так и выдал ровно одну «находку» — связку, которой неоткуда взяться.
  • Фон вычитается медианой. Гребень сжимает веса сторон к нулю, и столбец связки подбирает остаток модели: на выдуманных данных без всякой связки прибор насчитал ±0,44 при собственной ошибке 0,2. Медиана прибавок по всем парам и есть фон, а находкой считается то, что торчит из него втрое.
  • Тройка — не прихоть. У неё свой столбец рядом со столбцами всех трёх пар внутри себя, поэтому её вес отвечает на точный вопрос: даёт ли тройка что-то сверх того, что уже дают её пары и одиночки.
  • Подсадка идёт жребием на каждую сторону — у пары четыре четверти, у тройки восемь восьмых. «Все или никто» было бы губительно: столбец связки совпал бы со столбцами её частей, и веса у такого набора не определены ни один.

Невязка — чем меряется успех круга

Медиана расхождения между ценой и тем, что сторона заработала, в очках цены. Не счёт «адекватных»: тот измеряет не баланс, а размытость прогона. Измерено по повторам на одной и той же таблице цен — в одной тройке прогонов адекватных вышло 20, 40 и 27, размах больше, чем весь путь журнала от первого прогона до лучшего, — а по 37 прогонам связь «больше медианная ошибка → больше адекватных» даёт r = +0,83. Невязка на тех же повторах стояла: 6,4 / 6,4 / 6,8.

Полоска ошибки в неё не входит вовсе: считается разница между ценой и заработанным, а не «отличимо ли одно от другого». Размытому прогону нечем себя приукрасить. Берётся медиана, а не среднее: у насыщенных сторон честная цена выходит за два десятка очков при потолке анти-выбора в десять, и среднее они перетянули бы на себя целиком.

Оракул сборок — взломщик

Цены, честные в среднем по всем сборкам, могут оставить в таблице одну-две комбинации, которые бьют всё остальное. Заметить это, меряя случайные сборки, нельзя в принципе: сильнейшая встречается среди них раз на тысячу. Оракул ищет её нарочно.

Поколениями с отсевом

Сборки играют партии, худшая половина отсеивается, на её место приходят потомки выживших — та же сборка с одной заменённой стороной. Потомок законен по построению: выброшенная сторона запрещается, остальные объявляются обязательными, а бюджет добирает тот же генератор — второго свода правил законности в приборе нет.

ЧислоЗначениеЧто означает
EXPLORE_GENERATIONS4сколько поколений идёт разведка. Дальше поиск кончается и начинается спор за первое место: поле сжимается вдвое и больше не пополняется
FINAL_TABLES2во сколько столов упирается отсев — шестнадцать финалистов. Было в один стол: восемь сборок на полсотни сторон, и «мёртвую сторону» такое поле назвать не может
MAX_TURNS500потолок длины партии, равный длине партии замера. Было 300 — и это оказалось потолком не времени, а зрения
GAME_WORKERS10партий разом. Прежние четыре брались «всё упирается в базу» — замер показал занятыми ядро с четвертью из двенадцати
TOP20сколько сборок считать верхушкой при проверке обоих условий плана
GAMES_SHARE_FOR_TOP0,25какую долю партий лидера сборка должна отыграть, чтобы её вообще ставили в верхушку
COMPETING_BUILDS3разрыв меряется между первой и третьей сборкой, а не с серединой верхушки
DOMINATION_GAP1,0процентный пункт доли выработки: примерно десятая доля того, что даёт весь бюджет
Урок
Горизонт — это тоже прибор

С потолком в 300 ходов отсев решался на партиях в 50, 100 и 200, и до финала доживали спринтеры. В круге 5 лидером вышла сборка, продавшая учёных и бойцов, — а замер на пятистах ходах тем же вечером назвал «плохих учёных» дорогими. Спорили не приборы, а горизонты.

Урок
Соседи тасуются каждое поколение

Сила меряется долей, то есть всегда относительно тех, кто сидит в той же партии. Закрепи соседей — и оракул начнёт искать не сильнейшую сборку, а удачное соседство.

Урок
Три сборки вровень — это и есть баланс

Прежде разрыв считался с серединой верхушки, и это было слишком строго: требовать, чтобы лучшая не отрывалась от двадцатой, значит требовать, чтобы выбор расы ничего не значил. Вопрос другой: есть ли у сильнейшей достойные соперники.

Урок
Барьер, который ничего не делал

После пола отсева состав финалистов не меняется ни разу, а цикл всё равно шёл поколениями по два стола с ожиданием между ними: на прогоне в двести партий так проходило около девяноста поколений из ста. Остаток теперь играется одной пачкой — те же партии с теми же соседями, только их больше не ждут по две.

Что оракул отвечает

Ровно на два условия плана, и на второе — двумя списками, а не одним:

  • Есть ли доминирующая сборка — разрыв первой с третьей против допуска в один пункт. Сборок в верхушке меньше трёх — такая верхушка считается доминирующей: выбора там нет по построению.
  • Мёртвые стороны — те, что поиск пробовал, но ни в одну сборку верхушки они не вошли. По плану это «механика не работает», а не «сторона дорога».
  • Непробованные — те, которых поиск не ставил ни в одну сборку ладдера. Про них сказать нечего, и валить их к мёртвым значит выдавать пробел поиска за приговор стороне.

Мерило у оракула то же самое, что у приговоров ценам, — и это не экономия, а условие: двух разных мерил в одном приборе быть не может, иначе оценщик и взломщик спорили бы не о ценах, а о том, что считать силой.

Как они смыкаются в круг

Замер назначает цены → цены правятся долей пути → оракул ищет, что из новых цен можно выжать → находки идут в следующий замер подсаженной связкой → и всё сначала. Обе записи живут в одной таблице прогонов, различаясь только родом: это тот же прогон партий, просто с другим вопросом. Каждая правка цены ложится в журнал вместе с тем, на чём она измерена, — без журнала через месяц не откатить и не понять, откуда цена взялась.

Прогон, застигнутый остановкой сервера, закрывается при следующем старте: его поток умер вместе с процессом, а оставленный «идущим» он навсегда запер бы пульт.

Раздел 7

Технические детали

Из чего собран сервер

ПакетКлассовЧто там
service/105вся игровая логика; service/stub/ — точки расширения без реализации
dto/101контракты REST; пустые поля не сериализуются вовсе
domain/enums/37игровые правила таблицами
web/29контроллеры по подсистемам и обработка ошибок
repository/29Spring Data; выборки внутри хода с явным порядком
domain/entity/24JPA-сущности партии
galaxy/4генерация галактики, планет, родных миров

Ключевые службы

Числа игры
PopulationCalculator

Все числовые правила MoO2 в одном месте: выработка, рост, деньги, загрязнение, цена выкупа стройки. Формулы правятся только здесь.

Колонии
ColonyService

Занятия жителей, выработка со зданиями, очередь стройки, продажа построек, выкуп, загрязнение. Очередь хранится строкой в самой планете.

Ход
TurnService + TurnContext

Галактика вычитывается один раз на ход и передаётся фазам контекстом. Фаза, что изменила состояние, правит и контекст.

Корабли
ShipDesignService + ShipDesignRules

Шесть ячеек проектов не пустуют; обновление идёт новой строкой, а старая помечается вытесненной — построенный корабль остаётся с тем, с чем построен.

Бой
TacticalBattleService + BattleRules

Поле 32×20, корабли поимённо, очередь по инициативе корабля. Попадание считается кривой оригинала; пробитая броня выбивает случайное гнездо.

Флот
FleetService + FlightRules

Дальность от ближайшей своей опоры, время в пути по самому медленному кораблю. Летящий флот не стоит нигде.

Соседи
AiEmpireService

Стройка по окупаемости, расселение, оборона по угрозе, выкуп за кредиты, разведка и подчинение телепатами. Механика, к которой у ИИ нет пути, для балансировки мертва.

Баланс
BalanceRunService + BalanceOracleService

Оценщик и взломщик. Цены снимаются до первой партии и лежат в самой записи прогона: цены правятся с соседнего экрана, и без снимка не сказать, что он мерил.

Пятнадцать фаз конца хода

Новая фаза — новый бин; TurnService подхватит сам. Порядок как в MoO2:

5Население 6Перевозки 7Производство 8Империи ИИ 9Лидеры 10Исследования 11Ассимиляция 12Разведка 13Прибытие флотов 14Встречи флотов 15Знакомства 16События галактики 17Дипломатия ИИ 18Летопись империй 19Победа

Сущности партии

GameEntity · PlayerEntity · StarSystemEntity · PlanetEntity · PlanetBuildingEntity · FleetEntity · FleetShipEntity · FleetEncounterEntity · ShipDesignEntity · ShipDesignComponentEntity · SpaceBattleEntity · BattleShipEntity · PlayerTechnologyEntity · PlayerLeaderEntity · PlayerExploredSystemEntity · PlayerEventEntity · DiplomacyRelationEntity · CouncilVoteEntity · SpyEntity · PopulationTransferEntity · EmpireHistoryEntity · EmpireActivityEntity · TurnReportEntity · GameSaveEntity

Справочники — данными, а не кодом

ФайлРазмерЧто описывает
Technologies/tech.json75 КБдерево из восьми разделов; у технологии есть код, и он же лежит в базе
Ships/ship-components.json59 КБкорпуса, компоненты, 14 модификаций оружия, платформы обороны, тела чудищ
Races/race-traits.json58 КБ53 стороны расы в 18 группах, тринадцать готовых рас, развитые строи
Leaders/leaders.json44 КБлидеры со званиями, способностями и приносимыми технологиями
Buildings/buildings.json20 КБздания парами «тип эффекта — количество»

Все пять двуязычны: поле — либо строка, либо пара { "en": …, "ru": … }. Язык выбирается при обращении, а не при чтении файла: справочник разбирается один раз и лежит в памяти общим для всех запросов.

Договорённости, на которых всё держится

  • Отказ API несёт ключ, а не текст. Переводит его один обработчик; подстановки переводятся тоже. Слово, сохранённое в базе на одном языке, потом не перевести ничем.
  • Отчёт хода хранится ключом, а переводится при чтении. Ход заканчивает один игрок, а отчёт открывают все — у каждого свой язык.
  • Цвет текста — роль, размер — ступень шкалы. Шесть ролей с измеренным контрастом на основном фоне; оттенков по месту в интерфейсе нет.
  • Случайность выводится из зерна партии везде — лидеры, бои, исследования, события. Иначе парные прогоны сравнивали бы не расы, а удачу.

Шесть ролей цвета и их контраст

ink-bright 18,2 : 1 ink 14,0 : 1 ink-soft 9,5 : 1 ink-dim 7,1 : 1 ink-faint 5,2 : 1 ink-off 3,3 : 1

Эта страница набрана теми же ролями — это палитра самой игры, а не подобранная заново.