Решили: GameConfig держит ссылки на StatsConfig и ClassBalanceConfig; скоупы и editor-бенчи берут их оттуда. Ни RootLifetimeScope, ни CombatLifetimeScope больше не сериализуют стат-конфиги своими полями.

Почему: дубль был не небрежностью разводки, а следствием: сериализованная ссылка живёт в сцене, а автономных сцен две — CoreScene и CombatSystemsScene (standalone dev-арена обязана подниматься без Root). Пока ссылка в сцене, дубль неизбежен, и выбор стоял между «дубль под присмотром теста» и «отказаться от запуска боевой сцены без CoreScene». Оба варианта плохи: первый лечит симптом (тест сверяет две пары полей, и каждое новое общее SO добавит ещё пару), второй платит рабочим инструментом за чистоту.

Третий путь убирает корень: ссылка уезжает из сцены в ассет. Дубль ссылки на один GameConfig остаётся, но это уже не второй владелец факта — расходиться нечему, потому что «какой StatsConfig играет» решает ассет.

Заодно закрылись две вещи. armorK перестал иметь вторую копию (пункт C3 долга): боевой скоуп больше не владеет StatsConfig вообще. И бенчи перестали угадывать — BalanceAssets брал «первый по алфавиту ассет типа StatsConfig», то есть второй такой ассет молча увёл бы балансные отчёты на конфиг, которым игра не играет. Это худшая форма расхождения: врёт не код, а инструмент измерения, и ошибка выглядит как цифра в отчёте.

Цена, названная сразу: один уровень косвенности (gameConfig.Stats вместо поля) и GameConfig становится корневым конфиг-узлом. Правило, чтобы он не стал свалкой: только ссылки и глобальные скаляры, никакой логики.

Грабли:

  1. Тест был владельцем инварианта, которого больше нет. SceneWiringTests охранял «тот же ассет в обеих сценах». После миграции сцены о стат-конфигах не знают вовсе — и если бы я просто убрала оттуда имена полей, миграция забрала бы охрану и не поставила ничего взамен. Поэтому пустоту ссылок внутри ассета теперь проверяет ConfigValidationTests, сверяя их с единственными ассетами проекта.
  2. Где искать компонент — спрашивают у Unity, а не у YAML. Grep имени типа по сцене находит и parentReference (VContainer пишет туда имя РОДИТЕЛЬСКОГО скоупа строкой), поэтому по имени RootLifetimeScope «находится» в трёх сценах, а физически живёт в одной. Надёжный текстовый признак — guid скрипта в m_Script: он есть только там, где есть компонент. Так и подтвердилось: боевой скоуп — только в CombatSystemsScene.
  3. Компиляцию в момент правки роняли чужие ошибки параллельной сессии (IStatWriter, PhysicalSubtype). Свой код проверяется отдельно — validate_script по каждому файлу; ждать зелёной сборки, чтобы узнать про свою опечатку, не нужно.

Владелец правды: Assets/_Project/Scripts/Data/Definitions/GameConfig.cs (свойства Stats, ClassBalance с <remarks> о причине), тесты ConfigValidationTests.GameConfig_PointsAtTheProjectBalanceConfigs и SceneWiringTests.