Skip to content

feat(simulator): автономный симулятор баланса (#2967)#3220

Open
kvirund wants to merge 56 commits into
masterfrom
claude/vibrant-raman-695d14
Open

feat(simulator): автономный симулятор баланса (#2967)#3220
kvirund wants to merge 56 commits into
masterfrom
claude/vibrant-raman-695d14

Conversation

@kvirund
Copy link
Copy Markdown
Collaborator

@kvirund kvirund commented Apr 29, 2026

Автономный симулятор баланса (issue #2967)

Что это

Отдельная сборочная цель mud-sim — исполняемый файл, который линкует тот же движок, что и circle, но запускается без сетевого слоя. Грузит мир, по YAML-сценарию программно создаёт участников, гоняет бой в цикле и пишет JSONL-журнал событий. Вся логика боя/магии/аффектов — настоящая, общая с продом; меняется только обвязка.

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

Сборка

mkdir -p build && cd build
cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=ON -DHAVE_YAML=ON ..
make -j$(($(nproc)/2)) mud-sim tests

-DHAVE_YAML=ON обязателен: симулятор парсит YAML-сценарии, движок при этом грузит мир в YAML-формате.

Подготовка мира

Симулятор работает с любым миром, переданным через -d. Один раз нужно подготовить:

mkdir -p small && cp -r ../lib/* small/ && cp -r ../lib.template/* small/
pip install --user --break-system-packages ruamel.yaml      # если ещё не стоит
python3 ../tools/converter/convert_to_yaml.py -i small -o small -f yaml

Базовый прогон: PC против моба

Минимальный сценарий — игрок-богатырь без оружия бьёт моба автоатакой:

seed: 42
rounds: 100
output: /tmp/sim.jsonl
attacker: { type: player, class: bogatyr, level: 30 }
victim:   { type: mob, vnum: 102 }
./mud-sim --config scen.yaml -d small

Параметры:

  • seed — зерно RNG для воспроизводимости (по умолчанию 0).
  • rounds — число боевых раундов; один раунд = одно pulse_violence.
  • output — путь к выходному JSONL.
  • attacker / victimtype: player или type: mob.

Каст заклинаний

Поле action переключает режим. По умолчанию melee (автоатака). Для каста:

attacker: { type: player, class: koldun, level: 30 }
victim:   { type: mob, vnum: 102 }
action:   { type: cast, spell: шаровая молния }

Симулятор каждый раунд ждёт kGlobalCooldown и wait_state после прошлого спелла, потом вызывает движковый CastSpell (минуя DoCast, чтобы избежать ветки отложенного каста через SetCast). Память заклинаний и wait_state уважаются как в реальной игре.

Имя заклинания — точно как в lib/cfg/spells.xml (русское). Если класс знает это заклинание на этом уровне — каст пройдёт.

Чармисы / поднятая нежить

Никакого kCharm не кастуется. Подчинённые описываются прямо в участнике и приходят с ним уже лояльными:

attacker:
  type: player
  class: kudesnik
  level: 30
  pets:
    - { vnum: 4001 }
    - { vnum: 4002, stats: { max_hit: 800 } }
victim: { type: mob, vnum: 102 }

Каждый pet получает EAffect::kCharmed, set_master(owner), add_follower, SetFighting(pet, victim) — ровно тот же setup, что делает движок в конце kCharm. Cleanup в порядке pet-then-owner.

Damage от чармиса в JSONL помечается attacker_is_charmie: true и полем attacker_master_name.

PvP

type: player для обоих участников:

attacker: { type: player, class: bogatyr, level: 30 }
victim:   { type: player, class: lekar, level: 30 }

Работает через тот же SpawnParticipant / SetFighting, без отдельной ветки кода.

Mob vs Mob

«Спарринг мобов» — оба type: mob:

attacker: { type: mob, vnum: 101 }
victim:   { type: mob, vnum: 102 }

Перебивка статов и HP

Любой стат (str/dex/con/int/wis/cha) и max_hit можно переопределить:

attacker:
  type: player
  class: koldun
  level: 30
  stats:
    wis: 80
    int: 60
    max_hit: 1500

Что не указано — остаётся как назначил движок: для PC CharacterBuilder.make_basic_player ставит все статы по 25, для моба — берётся прототип. max_hit, если задан — побеждает дефолтную выдачу INT_MAX/4 (которую симулятор иначе ставит, чтобы участник пережил все раунды).

Стартовая экипировка

attacker:
  type: player
  class: bogatyr
  level: 30
  inventory: [111, 207]

Каждый предмет создаётся через world_objects.create_from_prototype_by_vnum и автонадевается:

  • оружие (kWeapon): kWieldkBothkHold (как equip_start_outfit для noob outfits);
  • всё остальное: через find_eq_pos.

Что не нашло слот — остаётся в инвентаре. После надевания пересчитывается affect_total, чтобы applies от предмета (например, kPhysicDamagePercent от меча) попали в add_abils.

Алиасы имён классов

Чтобы YAML был ASCII-friendly, поддерживаются латинские и английские алиасы вместо русских имён из pc_*.xml:

класс латиница английский
богатырь bogatyr warrior
наёмник naemnik assassin
кудесник kudesnik charmer
колдун koldun conjurer
лекарь lekar sorcerer / healer
охотник ohotnik ranger
волхв volkhv magus
дружинник druzhinnik guard
купец kupets merchant
чернокнижник chernoknizhnik necromancer
витязь vityaz paladine / paladin
тать tat thief
кузнец kuznets vigilant / smith
волшебник volshebnik wizard

Русские имена тоже работают, если YAML записан в KOI8-R.

Матричный прогон по статам

Главный кейс из задачи — таблица «урон vs значение стата». Достаточно прогнать одинаковый сценарий N раз с разными значениями и подать вывод в tools/balance_simulator_matrix.py:

for w in 10 20 30 40 50 60 70 80; do
  cat > /tmp/scen.yaml <<EOF
seed: 42
rounds: 50
output: /tmp/sim_wis_$w.jsonl
attacker: { type: player, class: koldun, level: 30, stats: { wis: $w } }
victim:   { type: mob, vnum: 102 }
action:   { type: cast, spell: шаровая молния }
EOF
  iconv -f utf-8 -t koi8-r /tmp/scen.yaml | sponge /tmp/scen.yaml
  ./mud-sim --config /tmp/scen.yaml -d small
done

python3 ../tools/balance_simulator_matrix.py wisdom \
  10:/tmp/sim_wis_10.jsonl 20:/tmp/sim_wis_20.jsonl ... 80:/tmp/sim_wis_80.jsonl \
  /tmp/lightning_by_wis.png

На выходе график «средний урон за каст vs мудрость», на котором видно плато до wisdom 30 и линейный рост дальше — настоящая балансная формула шаровой молнии.

События в JSONL

Один JSON на строку, ключи в фиксированном порядке (ts, name, потом атрибуты по алфавиту). Совместимо с OTLP log record schema.

name когда эмитится ключевые поля
damage каждый успешный удар, в Damage::Process после set_hit attacker_name, victim_name, dam, real_dam, over_dam, victim_hp_after, dmg_type, crit, spell_id, skill_id, attacker_is_charmie, attacker_master_name
miss каждый промах автоатаки, в hit() attacker_name, victim_name, reason ∈ {auto_5pct, level_diff, thac0_roll}
affect_added в affect_to_char после affect_total target_name, spell_id, duration, modifier, location, bitvector
affect_removed в RemoveAffectFromChar перед AffectRemove то же
char_state старт боя + после каждого раунда для всех ролей (attacker, victim, attacker_pet_N, victim_pet_N) role, target_name, hp, max_hp, move, max_move, position, in_room, spellpower_add_pct, physdam_add_pct, affects_count, aff_silence, aff_charmed, aff_sleep
round конец каждого раунда round, attacker_*, victim_*, hp_before, hp_after, damage_observed, victim_alive

Этого достаточно, чтобы внешний скрипт восстановил состояние любого участника на любой момент боя (replay-режим).

Глобальный EventSink в движке

MUD::observability::GlobalEventSink() + SetGlobalEventSink(EventSink*) — точка подключения для боевого/магического/affects-кода. По умолчанию возвращается NoOp (нулевая регрессия в проде). Симулятор ставит FileEventSink на время прогона и сбрасывает в nullptr на выходе (включая catch-ветку).

Реализация FileEventSink спрятана за фабрикой MakeFileEventSink(path) — публичен только интерфейс EventSink.

Воспроизводимость

./mud-sim --config scen.yaml -d small  # выдаёт /tmp/sim.jsonl
cp /tmp/sim.jsonl /tmp/sim_first.jsonl
./mud-sim --config scen.yaml -d small
diff -I '"ts":' /tmp/sim_first.jsonl /tmp/sim.jsonl   # пусто

Игнорируется ts (wall-clock), всё остальное побайтно совпадает: те же броски, те же попадания, тот же урон. Управляется через SetRandomSeed — единственный экземпляр std::mt19937 (Random::rnd), через который идут все боевые броски (number(), RollDices(), BernoulliTrial(), GaussIntNumber()).

Визуализаторы

  • tools/balance_simulator_viz.py — обзор нескольких прогонов: stacked dpr (master melee + master spell + pets), hit rate, кумулятивный урон по реальному таймлайну.
  • tools/balance_simulator_matrix.py — линейный график «средний урон за каст vs значение варьируемого стата», для матричных прогонов.
python3 tools/balance_simulator_viz.py /tmp/sim_*.jsonl --output /tmp/demo.png

Новые модульные тесты (23)

  • tests/random.seed.cpp — детерминизм RNG.
  • tests/file_event_sink.cpp — формат JSONL, escape, порядок ключей.
  • tests/character_builder.cpp — сеттеры и make_basic_player.
  • tests/event_sink_global.cpp — NoOp-дефолт, рутинг через установленный sink, сброс.
  • tests/scenario_loader.cpp — 16 тестов: парсер YAML, action-варианты, ошибки, PvP, mob-vs-mob, статовые перебивки, чармисы, inventory.

Где какие правки

  • src/utils/random.{h,cpp}SetRandomSeed.
  • src/engine/observability/event_sink.{h,cpp}, file_event_sink.cpp — приёмник событий, фабрика, KOI8-R → UTF-8 конвертер для JSON.
  • src/engine/entities/character_builder.{h,cpp} — перенос из tests/, расширение под симулятор: статы, skills/feats/spells класса, размещение в комнате.
  • src/gameplay/mechanics/damage.cpp — точка эмиссии damage в Damage::Process.
  • src/gameplay/fight/fight_hit.cpp — точка эмиссии miss в hit().
  • src/gameplay/affects/affect_data.cpp — точки эмиссии affect_added / affect_removed.
  • src/simulator/main.cpp, scenario.h, scenario_loader.{h,cpp}, scenario_runner.{h,cpp} — собственно симулятор.
  • src/simulator/README.md — документация.
  • tools/balance_simulator_viz.py, tools/balance_simulator_matrix.py — построение графиков.

Известные ограничения

  • Каст идёт через прямой CastSpell (минуя DoCast), потому что DoCast в бою уходит в отложенный путь через SetCast/mem_queue, и magic damage начинает прилетать через несколько pulse, плохо согласуясь с понятием «раунд». Для синтетического PC в одной арене такой обход адекватен; в живой игре поведение другое.
  • Арена — это room rnum 1 из загруженного мира. С неё временно снимаются kNoMagic и kPeaceful на время прогона и возвращаются на выходе.
  • После affect_total синтетический PC оказывается в position 3 (kSit/Rest); симулятор принудительно ставит kStand после ВСЕХ вызовов affect_total, иначе движок отказывается кастовать.
  • Свой синтетический загрузчик мира (одна стерильная комната без зависимости от lib.template) не делается — текущая работа со small достаточна.
  • Способности (feats) выдаются по «верхнему потолку». PC получает все способности класса, доступные на его уровне и реморте: и врождённые (inborn=1), и автоматические по уровню (inborn=0 slot=0), и слотовые (inborn=0 slot>=1) — последние без оглядки на лимит слотов. То есть симулятор играет «идеально прокачанным» персонажем, а реальный игрок ограничен числом слотов и должен выбирать. Числа в матрицах — это «верхняя граница dpr класса при идеальной прокачке».
  • Родовые способности (race feats) не выдаются. Раса у синтетического PC не задана (default), так что родовые feats игнорируются. Если для класса есть существенные родовые — в реальной игре они дадут плюс сверху симуляторных чисел. Расширение YAML под race: <название> — отдельный пункт.
  • OtlpLogsSink отложен (изначально планировался поверх OtelLogSender, пользователь снизил приоритет).

Контрольные прогоны

сценарий dpr примечание
богатырь без оружия 65.7 kPunch + kLeftHit (вторая рука)
богатырь + алебарда (vnum 111) 82.1 +25% от оружия
кудесник + кислота 548.5 spell-урон, spellpower_add_pct = 0
кудесник + 2 чармиса (101, 105) 581.1 +33 dpr от двух слабых чармисов
колдун + кислота 814.5 kPowerMagic +50% подтверждён ровно
колдун + шаровая молния, wisdom 30 → 80 619 → 890 плато до 30, линейный рост дальше

kvirund added 7 commits April 29, 2026 08:03
Автономный симулятор баланса (issue #2967) требует управляемого зерна
RNG, чтобы прогоны с одинаковыми параметрами давали одинаковые
последовательности бросков. SetRandomSeed публикует доступ к seed()
единственного экземпляра std::mt19937 (Random::rnd), через который идут
все боевые броски — number(), RollDices(), BernoulliTrial(),
GaussIntNumber().
Базовый интерфейс приёмника событий и реализация записи в файл одной
JSON-строкой на событие. Используется автономным симулятором баланса
(issue #2967) для сырых данных боя; в очереди задач — реализация
OtlpLogsSink поверх существующего OtelLogSender для отправки тех же
событий в Loki.

Атрибуты событий — std::variant из int64/double/string/bool. Формат
JSONL соответствует layout-у OTLP log record (плоские атрибуты), чтобы
файлы можно было залить в Loki без конвертации.

Сериализация — через fmt::format, без зависимости на nlohmann/json.
CharacterBuilder используется существующими модульными тестами для
программного создания CharData с заданным классом, уровнем и аффектами.
Автономному симулятору баланса (issue #2967) нужен такой же построитель.
Чтобы не дублировать код, переносим класс в src/engine/entities/, меняем
namespace test_utils -> entities. В tests/char.utilities.hpp оставляем
тонкую прослойку с typedef-ом в test_utils, чтобы существующие тесты не
пришлось править.

Логика без изменений (расширение API — отдельным коммитом). Все 388
существующих тестов плюс 4 теста FileEventSink и 3 теста RandomSeed
проходят (392/392).
Добавлены методы для программного создания участников балансных
прогонов (issue #2967):
  - set_str/dex/con/int/wis/cha — индивидуальные статы
  - set_hit/set_max_hit — управление HP
  - make_basic_player(class, level) — пресет: класс + уровень + все
    статы по 25 (середина 0..90), HP — дефолт конструктора
  - place_in_room(room) — размещение в комнате (требует загруженного
    мира)

Существующие методы (create_new, set_class, set_level, add_poison,
add_sleep, make_group и т.д.) не тронуты — все 392 существующих теста
проходят. Три новых теста (CharacterBuilder.MakeBasicPlayerSets...,
IndividualStatSetters..., HitSetters...) проверяют новый API.
Итого: 395/395.
Минимальная версия: новая CMake-цель mud-sim линкуется с circle.library
и запускает урезанный main без сокетов. Грузит мир через
BootMudDataBase(), сидирует RNG, тикает heartbeat заданное число раз
и выходит. Никаких сценариев и приёмников событий — это шаги 5/6.

CLI временный (--seed, --rounds, -d); в шаге 5 заменится на чтение
YAML-сценария.

Контрольная проверка:
  ./mud-sim -d small --seed 42 --rounds 5
  -> мир грузится за 0.2 с, 5 пульсов отрабатывают, деструкторы
     вызываются, exit 0.
  ./circle -c -d small
  -> прежний syntax check продолжает работать (регрессия по boot не
     внесена).

Issue: #2967
CLI mud-sim сводится к --config и -d. Все параметры прогона (seed,
rounds, output, attacker, victim) живут в YAML-сценарии. Это даёт
воспроизводимый артефакт сценария и расширяется без правки CLI:
полные статы, владения и экипировка участников лягут в ту же схему
отдельным коммитом (пункт 2 очереди задач).

ScenarioLoader не требует загруженного мира: имя класса сохраняется
строкой и резолвится в момент создания участника (Шаг 6) через
существующий FindAvailableCharClassId(). Это сохраняет порядок
SetRandomSeed(seed) -> BootMudDataBase() -> resolve participants.

Сборка mud-sim теперь обусловлена HAVE_YAML=ON. Это согласуется с
миграцией движка с legacy-формата мира на YAML (после неё HAVE_YAML
станет дефолтом). Если отключено — mud-sim не собирается, остальной
проект и tests без regressions. Тест scenario_loader.cpp тоже
собирается только при HAVE_YAML.

Минимальный сценарий:
  seed: 42
  rounds: 100
  output: /tmp/sim.jsonl
  attacker: { type: player, class: sorcerer, level: 30 }
  victim:   { type: mob, vnum: 1234 }

src/simulator/README.md описывает сборку, конвертацию мира в YAML
(in-place через tools/converter/convert_to_yaml.py) и формат сценария.

Контрольная проверка: 8 новых тестов ScenarioLoader + 403/403 общие.
./mud-sim --config scen.yaml -d small (с предварительной конвертацией
small/world в YAML) грузит сценарий, сидирует RNG, бутит мир, тикает
rounds пульсов и выходит чисто.

Issue: #2967
ScenarioRunner — основной кусок симулятора. На каждый раунд:
  1. Создаёт атакующего (PlayerSpec через CharacterBuilder либо MobSpec
     через ReadMobile/PlaceCharToRoom)
  2. Создаёт жертву таким же образом
  3. Размещает обоих в арена-комнате (rnum 1, как делает vstat)
  4. Снимок HP жертвы до боя
  5. SetFighting(attacker, victim)
  6. Тикает kBattleRound пульсов (один боевой раунд по штатному
     расписанию heartbeat)
  7. Снимок HP после
  8. Эмитит событие 'hit' в EventSink с полным набором атрибутов:
     attacker_type/class+level или vnum, victim_type/vnum, hp_before,
     hp_after, damage_observed, victim_alive, round
  9. Очищает обоих через ExtractCharFromWorld

Атакующему даётся огромный HP-пул (INT_MAX/4), чтобы он не помер
раньше времени и не смазал измерение dps.

main.cpp теперь подключает FileEventSink из движка и передаёт его
в RunScenario; путь берётся из scenario.output. Если что-то падает
(неизвестный класс, mob vnum не найден), процесс возвращает 1 и
печатает ошибку в stderr.

Контрольный прогон (mob-vs-mob, sm-мир сконвертирован в YAML):
  ./mud-sim --config /tmp/scen.yaml -d small
  -> 5 строк JSONL, событие 'hit' на каждый раунд
  -> diff -I '"ts":' между двумя прогонами с одним сидом пуст
  -> seed=99 даёт другие damage_observed
  -> 403/403 модульных теста проходят

PC-vs-mob с английским именем класса (sorcerer) пока не работает:
имена классов в pc_classes.xml русские (KOI8-R), и
FindAvailableCharClassId() не знает английских алиасов. Это вынесено
в README как known limitation; alias table — пункт очереди задач.

Issue: #2967
@kvirund kvirund force-pushed the claude/vibrant-raman-695d14 branch from eff89a0 to 9d42fa9 Compare April 29, 2026 06:03
kvirund added 3 commits April 29, 2026 08:12
…d_json

Кастомный JSON-кодер в FileEventSink заменён на nlohmann (header-only,
уже в src/third_party_libs/nlohmann/). Это убирает ручной escape-код и
работу с std::variant в одной функции, оставляет только явный порядок
ключей.

Используется ordered_json вместо обычного json: библиотека сохраняет
порядок вставки, а имена атрибутов мы явно сортируем сами (vector
+ std::sort), не полагаясь на порядок в std::map. Гарантированный
порядок: ts, name, потом attrs по алфавиту. Это критично для проверки
воспроизводимости (diff между прогонами с одним сидом).
В small-мире нет моба с vnum 1234 — это был абстрактный пример,
который сбивал с толку. Заменено на 102, который реально существует
(zone 1, локальный индекс 2) и используется в приведённых командах
прогона.
Было: 100 чисел в диапазоне 1..1000000, отдельный тест с двумя
явными SetRandomSeed внутри. Стало: 50 чисел в диапазоне 1..100,
третий тест удалён (после рефактора стал дубликатом первого, потому
что draw_sequence() уже вызывает SetRandomSeed внутри).

Убрана reserve() — на 50 элементах он бессмыслен. Просто нагляднее
без него.
@kvirund kvirund marked this pull request as ready for review April 29, 2026 06:13
kvirund added 17 commits April 29, 2026 08:16
Класс FileEventSink перенесён в anonymous namespace внутрь .cpp;
публикуется только factory-функция MakeFileEventSink(path) в
event_sink.h. Заголовок file_event_sink.h удалён за ненадобностью.

Зачем: callers (main симулятора, тесты) не должны знать про детали
реализации (поля m_file, конструктор, исключения). Им хватает
интерфейса EventSink и одной строчки 'возьми мне sink, который
пишет в файл'. Когда появится OtlpLogsSink, он добавится таким же
способом — отдельной фабрикой в том же event_sink.h, без правки
caller-кода.
Заменён R-литерал на обычный с \\-эскейпами в проверке JSON-escape.
MSVC ругался на токенизацию: 'illegal escape sequence', 'invalid
literal suffix quoted'. Поведение и сам ожидаемый текст не меняются.
…KOI8-R)

Три бага сразу не давали PC-сценарию работать:

1. SpawnParticipant возвращал raw pointer от локального
   CharacterBuilder; после возврата shared_ptr уничтожался,
   следующий же perform_violence ловил use-after-free и крэшил
   процесс. Теперь PC регистрируется в глобальном character_list
   (по аналогии с ReadMobile для мобов), владение глобальное.

2. Проверка выхода из боя 'if (!attacker->in_room || ...)':
   in_room это RoomRnum (целое), 0 это валидная комната, поэтому
   цикл прерывался на первой же итерации даже у живых участников.
   Заменено на сравнение с kNowhere.

3. Имена классов в YAML/движке хранятся в KOI8-R; nlohmann::json
   валидирует UTF-8 и швырял исключение на первой эмиссии.
   Конвертируем class_name через koi_to_utf8 перед записью в
   событие; JSON получается валидным UTF-8 (готов к Loki/OTLP).

Контрольная проверка: класс 'богатырь' уровня 30 vs mob 102,
50 раундов = 50 строк JSONL, damage_observed реально меняется
от раунда к раунду, выход чистый.
tools/balance_simulator_viz.py читает любое число JSONL-файлов
(каждый = один прогон mud-sim) и рисует одностраничную сводку:
средний damage_observed по классам (bar) и кумулятивный урон по
раундам (линия).

docs/issue-2967/classes-vs-mob.png — демо-прогон по issue #2967:
богатырь, лекарь, охотник, наемник, кудесник, колдун (все lvl 30)
vs mob 102, по 80 раундов с одним сидом 42. Видно ожидаемое:
рукопашные классы наносят 1.75-2.19 урона за раунд, маги (кудесник,
колдун) без заклинаний почти ничего не делают (~0.04). Это и есть
известное ограничение MVP — отсутствие экипировки и каста; график
делает это видимым.
Бинарным файлам не место в исходниках. Демо-график для issue #2967
будет приложен другим способом (gh-attachment, gist), а не в дереве.

Скрипт визуализации tools/balance_simulator_viz.py остаётся —
любой может прогнать его сам и получить такую же картинку.
Хардкод /tmp/ работал только на UNIX и валил винду:
'FileEventSink: cannot open /tmp/file_event_sink_test_*.jsonl for writing'.
std::filesystem::temp_directory_path() даёт правильный каталог на любой
платформе (на винде это %TEMP%/%TMP%/%USERPROFILE%, на UNIX — /tmp).
GlobalEventSink() и SetGlobalEventSink(EventSink*) — глобальная точка
подключения приёмника событий, чтобы движок мог эмитить из combat/
magic/где угодно без проброса sink через все сигнатуры.

По умолчанию возвращается NoOp-инстанс (виртуальный вызов в пустые
методы) — нулевой риск для прода, который ничего не устанавливает.
Симулятор в main устанавливает свой FileEventSink на время прогона
и сбрасывает в nullptr на выходе (включая catch-ветку).

Это инфраструктурная подготовка для следующего шага — эмиссии событий
о попадании/промахе/уроне на каждый swing из src/gameplay/fight/.

Тесты: 3 новых модульных (NoOp по умолчанию, рутинг через установленный
sink, fallback на NoOp при reset).
В Damage::Process сразу после применения урона к жертве (set_hit)
эмитим событие 'damage' через GlobalEventSink с точными числами:
dam, real_dam, over_dam, victim_hp_after, dmg_type, crit. В проде
GlobalEventSink возвращает NoOp, накладные = один виртуальный вызов
+ построение Event на стеке.

Это закрывает главный пробел в данных симулятора, который был
заметен в первом демо-прогоне: damage_observed (HP-дельта между
снимками) включает в себя регенерацию жертвы и хилы, из-за чего
кумулятивная кривая на графике провисала. Теперь параллельно с
round-событиями (HP-дельта) пишутся damage-события (чистый roll
на каждый удар), и кумулятивная сумма по 'damage' монотонна.

Имена ch/victim в движке хранятся в KOI8-R; чтобы nlohmann::json не
ругался на не-UTF8, добавлен общий хелпер observability::EngineStringToUtf8
в event_sink. Дублирующая Koi8rToUtf8 из scenario_runner удалена.

Контрольный прогон: богатырь lvl 30 vs mob 102, 30 раундов.
JSONL содержит 89 строк = 30 round + 59 damage (попадание не каждый
раунд, но в среднем по 2 удара за pulse_violence).
Скрипт теперь группирует JSONL по двум видам событий:
  - 'damage' (приоритет): по одному на каждый успешный удар, точное
    значение dam из Damage::Process. Используется для bar (sum/rounds)
    и кумулятивной линии (монотонной).
  - 'round' (fallback): HP-дельта с регенерацией, как раньше. Берётся
    только если damage-событий нет (старые JSONL без инструментации).

Label берётся из первого 'round'-события (там есть attacker_class /
attacker_vnum), потому что в damage-событиях этих полей нет.

Перепрогон по 6 классам vs mob 102: воины (богатырь/лекарь/охотник)
выбивают 4.72 урона за раунд, наёмник 3.02, маги (кудесник/колдун)
1.70 — раньше regen занижал маг до 0.04. Кумулятивная кривая теперь
монотонна, как и должна быть.
В hit() три ветки промаха автоатаки:
  - 'auto_5pct' — фиксированные 5% при level diff <= 5 или PvP
  - 'level_diff' — шанс пропорционален разнице левелов и ремортов
  - 'thac0_roll' — обычный roll по THAC0/AC

В каждой перед return эмитим событие 'miss' с reason через
GlobalEventSink. В проде GlobalEventSink == NoOp, накладные = один
виртуальный вызов и заполнение Event на стеке. Имена ch/victim
конвертируются через общий observability::EngineStringToUtf8.

Это позволяет визуализатору считать hit rate (damage/(damage+miss))
и распределение причин промахов. На контрольном прогоне (богатырь
lvl 30 vs mob 102, 80 раундов): hit_rate 60.5%, причины
{thac0_roll: 95, level_diff: 6, auto_5pct: 3}.
Скрипт визуализации теперь рисует три панели:
  1. Средний урон за раунд (sum dam / rounds) -- как раньше.
  2. Hit rate -- % попаданий от всех swing (damage / (damage + miss)).
     Считается из событий 'damage' и 'miss', которые эмитятся боевым
     кодом начиная с предыдущего коммита.
  3. Кумулятивный нанесённый урон -- монотонная линия по точным
     damage-событиям.

Контрольный прогон: воины (богатырь, лекарь, охотник) -- 4.72 dpr и
60.5% попаданий; наёмник 3.02 dpr / 57.4%; маги (кудесник, колдун)
1.70 dpr / 51.6%. Видно, что меньшие dpr у магов в основном из-за
hit rate (а не размера удара).
…eats

Раньше make_basic_player создавал PC с класcом, уровнем и базовыми
статами, но без skills и feats. Бой проходил по fallback-веткам
(kPunch без специализации, без kSecondAttack, без kBash и т.п.) и
все рукопашные классы давали одинаковый dpr 4.72 / hit 60.5% --
это не отражало баланс классов вообще.

Теперь grant_class_skills_and_feats() итерирует по
MUD::Class(cls).skills и .feats, и для каждого, чей min_level/
min_remort удовлетворены, выставляет skill на 200 и SetFeat.
Это даёт 'теоретический максимум прокачки' для симуляции -- то что
балансер хочет измерять, не зашумляя данными о том, что игрок
своё чего-то не докачал.

Контрольный прогон vs mob 102:
  богатырь  56.73 dpr / 73.1% hit  (был 4.72 / 60.5%)
  наёмник   14.78 dpr / 66.2% hit  (был 3.02 / 57.4%)
  лекарь    14.65 dpr / 66.8% hit  (был 4.72 / 60.5%)
  охотник   15.07 dpr / 66.5% hit  (был 4.72 / 60.5%)
  кудесник  14.47 dpr / 66.8% hit  (был 1.70 / 51.6%)
  колдун    14.47 dpr / 66.8% hit  (был 1.70 / 51.6%)
Богатырь теперь резко выделяется (вторая рука + специализация
в оружии); магов автоатака подняла, потому что им начислился kPunch
и базовые feats. Заклинания всё ещё в очереди (пункт 1 backlog).

Метод grant_class_skills_and_feats() публичный -- его можно вызвать
отдельно после ручной сборки персонажа (set_class+set_level+stats),
без полного make_basic_player.
- Scenario получил необязательное поле 'action' с вариантами 'melee'
  (default) и 'cast' (со spell_name на русском). YAML-парсер обновлён.
- ScenarioRunner на 'cast' каждый раунд ждёт пока спадёт kGlobalCooldown
  и вызывает DoCast(attacker, "'spell' victim_keyword", 0, 0).
- CharacterBuilder.grant_class_skills_and_feats() теперь дополнительно
  выдаёт PC все class-заклинания, доступные на его уровне:
  SET_BIT(SPELL_TYPE, kKnow) + SET_SPELL_MEM до 250.

Контрольный прогон (колдун lvl 30 + cast 'шаровая молния' vs mob 102):
DoCast вызывается 30 раз, выставляет get_wait=50 после каждого, но
damage_observed остаётся 0 на всех раундах. Вероятная причина -
CalcCastSuccess фейлит из-за того что skill-for-spell не прокачан
(или не настроены чародейские круги слотов).

Что ещё надо сделать для рабочего cast-сценария:
- разобраться с CalcCastSuccess (skill-for-school + слоты круга);
- учесть отложенные касты (ch->SetCast при наличии enemy);
- эмитить событие 'cast' на каждый attempt (success/fail);
- эмитить 'cast_attempt_skipped' с reason='cooldown' когда раунд пропущен.

Issue: #2967
…ge event

Что сделано:
- ScenarioRunner всегда делает SetFighting в начале (и для melee, и для
  cast). Без enemy DoCast не находил target (kCallLighting требует
  kTarCharRoom|kTarFightVict, а первое не резолвится для multi-word
  alias моба типа 'костяная гончая'). Теперь target находится через
  enemy.
- Damage::Process в эмиссии 'damage' добавляет поля spell_id и skill_id
  (значения ESpell/ESkill underlying; 0 = kUndefined для голой
  автоатаки). В виз позволит отличать удар от cast.

Что НЕ работает (требует отдельной отладки, остаётся в backlog):
- Magic damage от cast пока не доходит до Damage::Process. DoCast
  правильно резолвит target и spell, но в боевом контексте (есть
  enemy) caster_state ставится через ch->SetCast(...) на отложенный
  каст. Обработка отложенных кастов идёт в heartbeat через mem_queue
  step, и видимо там Damage::Process не вызывается тем же путём (либо
  level=0, либо до Damage не доходит). На контрольном прогоне
  (колдун lvl 30 + cast 'шаровая молния' vs mob 102, 100 раундов):
  melee_dam=1583, cast_dam=0 (все damage events spell_id=0).
- В следующем коммите надо: либо обходить SetCast (сразу CastSpell),
  либо разобраться почему magic damage в отложенном пути теряется.

Issue: #2967
…ё 0)

Заменил DoCast на прямой CastSpell(attacker, victim, ..., spell, spell)
+ ручной декремент SPELL_MEM, чтобы обойти ветку 'есть enemy ->
SetCast (отложенный)'. Цель: получить immediate magic damage в
Damage::Process на каждом раунде вместо отложенного через mem_queue.

Результат: magic damage всё равно не наносится. На контрольном
прогоне (колдун lvl 30 + 'шаровая молния' vs mob 102, 100 раундов):
melee_dam=1565, spell_dam=0, ни одной строки 'MAG DAMAGE' в syslog --
значит CastDamage не вызывается. Скорее всего CastSpell или
CallMagic возвращаются раньше из-за того что синтетический PC от
CharacterBuilder не проходит какую-то PC-инициализационную проверку
(не выставлены mana / EAffect / какие-то PC-only поля).

Отладка этого пути -- отдельная сессия с trace в CastSpell/CallMagic.
Пока инфраструктура для cast-сценария на месте: scenario.action,
parser, runner, spell_id/skill_id в damage event, выдача spells через
CharacterBuilder. Когда CallMagic заработает, никаких изменений в
этом коде не понадобится -- damage будет автоматически писаться с
ненулевым spell_id.
Точки инструментации в src/gameplay/affects/affect_data.cpp:
- affect_to_char: после affect_total(ch) -> 'affect_added';
- RemoveAffectFromChar: перед каждым AffectRemove -> 'affect_removed'.

Поля события: target_name, spell_id (тип аффекта), duration,
modifier, location, bitvector. Это позволяет внешнему скрипту
восстановить полную хронологию баффов/дебаффов: что и кому добавили,
кто кого ослабил, когда оно сошло. Нужно для replay-режима по запросу
пользователя.

Точки внутри тестов через CharacterBuilder.add_poison/add_sleep и т.п.
эмиссию НЕ дёргают, потому что они напрямую делают affected.push_front
в обход affect_to_char (специально, чтобы тесты не требовали
загруженного мира). Это разумно: тесты тестируют сами модули, а не
цепочку affect_total.

В контрольном прогоне (кулак vs mob 102, 100 раундов): affect_*
событий ноль (никто не отравляет, нет авто-кастов баффов). Когда
сценарии расширятся cast-баффами (огненный щит и т.п.) и мобы будут
использовать debuff-атаки -- сразу появятся в JSONL.

Issue: #2967
Проблема: ScenarioRunner ставил участников в room rnum 1, но эта
комната в lib.template (small/world) помечена флагом ERoomFlag::kNoMagic.
В CallMagic() есть ранний возврат: если caster в no-magic комнате,
заклинание гасится без эффекта. Вся цепочка DoCast -> CastSpell ->
CallMagic -> CastDamage -> Damage::Process до Damage::Process не
доходила.

Решение: RAII-helper ArenaFlagSweep, который перед прогоном снимает
с kArenaRoom флаги kNoMagic и kPeaceful, а на выходе из RunScenario
возвращает их обратно (чтобы не портить загруженный мир для других
прогонов в том же процессе).

Контрольный прогон (колдун lvl 30 + 'шаровая молния' vs mob 102,
100 раундов): melee_dam=1463 (PC автоатакует когда не кастует),
spell_dam=62497 (100 кастов kCallLighting через Damage::Process),
spell_id корректно проставлен в каждом damage event.

Issue: #2967
kvirund added 25 commits April 29, 2026 19:13
После того как cast наконец заработал и spell_id корректно
проставляется в damage event, виз теперь:
  - в bar 'dpr' рисует stacked: melee (синий) + spell (оранжевый)
    -- видно вклад автоатаки vs заклинаний;
  - в hit-rate считает все swing вместе (физ + магические);
  - в кумулятиве собирает все damage-события подряд.

На демо-прогоне (6 классов vs mob 102, 100 раундов): колдун 639.6
dpr (преимущественно spell), кудесник 547.8 (тоже spell-tilted),
богатырь 56.4 (чисто melee), остальные ~15 (нет cast). Это OK для
картинки, но в реальности cast перебалансен -- симулятор пока
кастует каждый раунд без mana/slot/cooldown проверок.
…аймлайну

scenario_runner:
- Перед каждым кастом теперь ждём, пока спадут и HasCooldown
  (kGlobalCooldown), и get_wait() предыдущего заклинания. Без этого
  спелл шёл каждый раунд, без задержки на время произнесения, и dpr
  получался завышенным относительно того, что было бы в реальной
  игре. Верхний предел ожидания kBattleRound*4 на случай, если
  заклинание зависнет.
- После CastSpell вручную ставим SetWaitState(attacker, kBattleRound) --
  именно это делает DoCast на нормальном пути; раз мы DoCast обходим
  (см. предыдущий коммит), эмулируем сами.

Скрипт построения графиков:
- Кумулятивный график перерисовывает damage-события по реальному
  таймлайну (секунды от первого события), а не по порядку, в котором
  скрипт их обнаружил при чтении файла. Раньше получался ложный излом:
  сначала шли подряд все физические удары, потом подряд все спеллы --
  выглядело как будто маги долго ничего не делают, потом резко
  включаются. Артефакт построителя графиков, не симулятора.
Помимо старых событий 'damage'/'miss'/'round', сценарий теперь
выкладывает 'char_state' для обоих участников:
  - один раз сразу после спавна (round = -1) -- стартовое состояние
    для replay-инструмента;
  - после каждого боевого раунда -- если участник всё ещё в комнате
    (после смерти снимать состояние нечего).

Поля события: target_name, hp, max_hp, move, max_move, position,
in_room. Это даёт внешнему скрипту-проигрывателю возможность
восстановить состояние любого участника на любой раунд, не складывая
заново всю историю damage/affect событий. Тонкая динамика баффов
по-прежнему доступна через affect_added/affect_removed (см. предыдущие
коммиты), эти события покрывают изменения между снимками char_state.

На контрольном прогоне (колдун + 'шаровая молния' vs mob 102, 30
раундов): 62 char_state события (2 init + 30 x 2 round-snapshot),
ожидаемо.
ScenarioRunner и парсер уже умеют PvP (player attacker против player
victim) без правки кода: SpawnParticipant использует std::visit,
который одинаково работает для PlayerSpec и MobSpec. Контрольный
прогон богатырь vs лекарь, 50 раундов: 149 damage events, 34 miss,
102 char_state. Чтобы это поведение случайно не сломать, добавлены
пять модульных тестов на парсер сценария:

  PlayerVsPlayerWithCastAction   PvP с action: cast (главное)
  MeleeActionDefault             отсутствие action -> melee
  ExplicitMeleeAction            action: { type: melee }
  CastActionWithoutSpellIsFatal  action: cast без spell -> ошибка
  UnknownActionTypeIsFatal       action: dance -> ошибка

Итого тестов в наборе: 13 (было 8).
Расширение YAML-схемы под главный сценарий из задачи #2967 -- матрица
по статам (мудрость, владения и т.п.). Каждый участник теперь может
содержать поле stats:

  attacker:
    type: player
    class: koldun
    level: 30
    stats:
      str: 30
      dex: 35
      con: 40
      int: 50
      wis: 60
      cha: 25
      max_hit: 1500

Любая перебивка не задана -- остаётся то, что назначил движок (для
игрока CharacterBuilder.make_basic_player выставляет всё по 25, для
моба -- значения из прототипа). Указание max_hit ещё и побеждает
дефолтную выдачу INT_MAX/4 от RunScenario, чтобы можно было
прогонять реалистичные дуэли с настоящим HP.

Внутренние имена полей: str/dex/con/int/wis/cha/max_hit. Использовано
intel вместо int в C++-структуре, потому что int -- ключевое слово.
В YAML по-прежнему 'int', парсер маппит.

Тесты: добавлено 3 модульных (полная перебивка, частичная, перебивка
max_hit для моба). Итого: 16 тестов в наборе.
Принимает несколько JSONL-прогонов вида 'STAT_VALUE:path.jsonl' и строит
линейный график 'средний урон за каст -- значение варьируемого стата'.
Это главный сценарий из задачи #2967: видеть, как изменение мудрости /
владения / уровня влияет на спелл.

Пример использования:

  for w in 10 20 30 40 50 60 70 80; do
    cat > /tmp/scen.yaml <<EOF
seed: 42
rounds: 50
output: /tmp/sim_wis_$w.jsonl
attacker: { type: player, class: koldun, level: 30, stats: { wis: $w } }
victim:   { type: mob, vnum: 102 }
action:   { type: cast, spell: lightning }
EOF
    iconv -f utf-8 -t koi8-r /tmp/scen.yaml | sponge /tmp/scen.yaml
    ./mud-sim --config /tmp/scen.yaml -d small
  done
  python3 tools/balance_simulator_matrix.py wisdom \
    10:/tmp/sim_wis_10.jsonl 20:/tmp/sim_wis_20.jsonl ... \
    /tmp/lightning_by_wis.png

На демо-прогоне kCallLighting у колдуна lvl 30 видно плато до wis=30
(619 урона/каст), потом линейный рост ~+45 за каждые +10 wis. Это
именно то, ради чего задумывался симулятор -- увидеть скрытую
балансную формулу и порог чувствительности.
ResolveClassAlias в scenario_runner перед FindAvailableCharClassId
маппит ASCII-имя на русское из pc_*.xml (KOI8-R-байты, захардкожены
escape-последовательностями чтобы файл оставался ASCII-чистым). Теперь
в YAML сценарий можно писать без iconv-конвертации:

  attacker: { type: player, class: koldun, level: 30 }

вместо

  attacker: { type: player, class: \xCB\xCF\xCC\xC4\xD5\xCE, level: 30 }

Поддерживаются обе формы для каждого класса:
  - транслитерация: bogatyr, naemnik, kudesnik, koldun, lekar, ohotnik,
    volkhv, druzhinnik, kupets, chernoknizhnik, vityaz, tat, kuznets,
    volshebnik;
  - английский смысл: warrior, assassin, charmer, conjurer, sorcerer
    (или healer), ranger, magus, guard, merchant, necromancer, paladine
    (или paladin), thief, vigilant (или smith), wizard.

Если ни алиас, ни оригинал не подходит -- передаётся как есть, и
FindAvailableCharClassId возвращает kUndefined как раньше.

Контрольный прогон 'class: koldun' с английским алиасом резолвится
в kConjurer и кастует, как и должен.
Никто не кастует kCharm в сценарии -- pets просто перечисляются в
описании участника и приходят с ним уже подчинёнными:

  attacker:
    type: player
    class: kudesnik
    level: 30
    pets:
      - { vnum: 4001 }
      - { vnum: 4002, stats: { max_hit: 800 } }

ScenarioRunner для каждого pet:
  1. ReadMobile + PlaceCharToRoom (тот же арена-room);
  2. Применяет stat overrides (max_hit и т.п.);
  3. Накладывает Affect{type=kCharm, bitvector=kCharmed} -- ровно так
     же, как делает движок в конце kCharm spell;
  4. owner->add_follower(pet);
  5. SetFighting(pet, victim).

Cleanup в порядке pet-then-owner.

Damage event дополнен полями attacker_is_charmie (bool) и
attacker_master_name -- визуализатор сможет отделить вклад хозяина
и слуг.

char_state снимок теперь идёт каждый раунд для всех ролей: attacker,
victim, attacker_pet_0..N, victim_pet_0..N.

Контрольный прогон (kudesnik + 2 чармиса 101/105 vs mob 102, 30 раундов):
119 damage events: 59 от хозяина + 60 от чармисов (по ~1 удар/раунд),
2 affect_added (kCharmed на каждого), 124 char_state (4 роли x 31).

Тесты: 3 новых на парсер pets. Итого 19/19 в наборе.
CharacterBuilder.SetFeat дёргает affect_total(this), но affect_total
рано возвращается, если ch->in_room == kNowhere. В моём пайплайне:

  CharacterBuilder.make_basic_player()  // SetFeat * N
  character_list.push_front()
  ApplyStatOverrides()
  PlaceCharToRoom(ch, kArenaRoom)        // вот когда in_room устанавливается

То есть к моменту последнего affect_total ch ещё не в комнате,
и feat applies (kPowerMagic +50% percent_spellpower_add для колдуна,
kMagicUser, kBerserker и т.п.) НЕ переноcились в add_abils.

После PlaceCharToRoom явно вызываем affect_total(ch) для каждого
участника и каждого pet -- теперь applies пересчитываются.

Бонусом: affect_total для синтетического PC (без полной char-init)
устанавливает position в дефолт NPC (3, kSit/Rest), и движок отказывает
DoCast/CastSpell проверкой по MIN_POS заклинания. Принудительно
выставляем kStand сразу после affect_total, иначе каст молчит.

Контрольный прогон (одинаковая 'кислота' на mob 102, 50 раундов):
  колдун:    spellpower=+50%, avg/cast = 795.6
  кудесник:  spellpower=+0%,  avg/cast = 530.6
  соотношение 1.50 -- ровно феат kPowerMagic.

Также в EmitCharState добавлены поля spellpower_add_pct,
physdam_add_pct, affects_count, aff_silence/aff_charmed/aff_sleep --
полезно для диагностики 'почему этот класс бьёт меньше чем ожидаешь'.

Issue: #2967
Каждый участник может содержать поле inventory: [vnum, vnum, ...].
Каждый предмет создаётся через world_objects.create_from_prototype_by_vnum,
кладётся в inventory персонажа, потом auto-надевается:

  - оружие (kWeapon): сначала пробуем kWield, потом kBoth, потом kHold
    (логика equip_start_outfit для noob outfits, потому что find_eq_pos
    самоё по себе weapon-слоты не подбирает);
  - всё остальное: через find_eq_pos (для брони/украшений работает).

Предметы, для которых не нашлось свободного слота, остаются в
inventory.

После EquipFromVnums делается affect_total ещё раз, чтобы apply
от надетых предметов (kPhysicDamagePercent на оружие, +stats от
украшений и т.п.) попали в add_abils. SetPosition(kStand) перенесён
в самый конец, после ВСЕХ affect_total -- иначе сбрасывается в kSit
и движок не даёт кастовать/бить.

Контрольный прогон (богатырь lvl 30 vs mob 102, 50 раундов):
  без оружия:        dpr=65.7
  с алебардой 111:   dpr=82.1   (+25%)

Issue: #2967
Четыре новых модульных теста:

  InventoryParsed              inventory: [vnum, vnum, ...] -- список
                               предметов сохраняется в порядке
  NoInventoryByDefault         отсутствие поля -> пустой vector
  InventoryNonSequenceIsFatal  inventory как map -> ScenarioLoadError
  MobVsMob                     attacker и victim оба type: mob, action
                               по умолчанию melee

Итого 23 теста в наборе.
Раньше скрипт построения графиков складывал damage от чармисов в
master_melee (потому что у pet тоже spell_id=0). Использовал поле
attacker_is_charmie из damage event и теперь рисует три слоя
в stacked bar:
  - master melee  (синий)
  - master spell  (оранжевый)
  - pets          (зелёный)

Заодно: если несколько прогонов имеют одинаковый label (две колонки
"kudesnik (lvl 30)" -- например solo и с чармисами), к каждой
дописывается имя файла-источника, чтобы matplotlib не сливал bars
в одну колонку. Figsize становится шире, когда метки длинные.

Контрольный прогон (4 сценария: bogatyr, kudesnik solo, kudesnik+pets,
koldun) -- все 4 колонки видны раздельно, доли master/pet корректные.
CharacterBuilder -- инфраструктура автономного симулятора (issue #2967),
а не движка: единственный фактический потребитель -- mud-sim, тесты
используют его как хелпер. Переезд в src/simulator/ убирает класс из
circle.library и приводит код в порядок перед добавлением веб-UI
поверх симулятора.

- src/engine/entities/character_builder.{h,cpp} -> src/simulator/
- namespace entities -> simulator
- CMakeLists: убран из circle.library SOURCES/HEADERS, добавлен в
  SIMULATOR_SOURCES; tests/CMakeLists добавляет .cpp в UTILITIES
- shim tests/char.utilities.hpp обновлён под новый namespace
GlobalEventSink был единственным slot'ом с NoOp-default'ом: каждый
эмиттер (damage, miss, affect_added/removed, char_state) безусловно
строил Event, конвертил KOI8-R -> UTF-8, вызывал виртуальный Emit().
В проде sink == NoOp, и стоимость -- сотни наносекунд на каждый удар
вхолостую.

Заменено на список:
- RegisterEventSink/UnregisterEventSink -- регистрация на старте/выходе
  процесса.
- HasAnyEventSink() -- !sinks.empty(), inline-friendly guard.
- EmitToAllSinks/FlushAllSinks -- рассылка/flush по всему списку.

Каждый эмиттер начинается с if (!HasAnyEventSink()) return;: в проде
вектор пуст, ранний выход без построения Event и без аллокаций.
Симулятор регистрирует FileEventSink в main.cpp (через try/catch с
явным Unregister на выходе) и больше не таскает sink параметром через
RunScenario.

Тесты EventSinkRegistry покрывают пустой регистр, регистрацию,
unregister, multiple-sink fanout и idempotent повтор Register.
Веб-UI визуализатора (issue #2967, отдельный репозиторий) хочет
показывать «то, что персонаж видел бы при telnet-подключении» --
combat log от act/SendMsgToChar, аффекты, всё, что обычно идёт игроку
в сокет.

Реализовано без модификации движка: к каждому синтетическому PC
прицепляется dummy DescriptorData (descriptor=-1, state=kPlaying,
output=small_outbuf), привязывается через ch->desc. Паттерн взят с
admin_api/crud_handlers.cpp. iosystem::write_to_output не проверяет ни
fd, ни state, ни character -- молча копит текст в буфере.

Каждый раунд после pulse_violence симулятор вычитывает буфер,
конвертит KOI8-R -> UTF-8 и эмитит screen_output событие в JSONL с
ролью (attacker/victim) и round'ом. Большой буфер (large_outbuf)
возвращается в bufpool через iosystem::flush_queues -- никаких утечек.

Spawn/setup-спам перед первым раундом отбрасывается через Discard()
чтобы JSONL содержал только боевую часть.

Мобы/чармисы descriptor не получают -- у них и в живой игре нет
вывода. Detach делается до ExtractCharFromWorld, чтобы ch->desc не
оставлял висячий указатель.

На smoke-сценарии (богатырь vs vnum 102, 5 раундов) screen_output
ровно те самые «Вы сильно ударили костяную гончую» / «Костяная гончая
промазала» -- то, что увидит имм в реплейере.
balance_simulator_viz.py / balance_simulator_matrix.py делали статичные
PNG-графики из JSONL прогонов mud-sim. Их функцию заменяет веб-UI
визуализатор (отдельный репозиторий bylins/mud-balance-ui): он же
рендерит таймлайн с возможностью промотать боя по слайдеру, тогда
как старые скрипты давали только агрегированные средние.

Матричные прогоны (одинаковый сценарий с варьируемым параметром,
сводный график) -- backlog у веб-UI.
Веб-UI визуализатора показывает state-панель со всем тем, что игрок
видит в команде 'спос' / 'аффекты' / стат-странице. Раньше JSONL давал
только hp/move/position и счётчик аффектов; этого мало для визуальной
отладки баланса.

Добавлено в каждый char_state event:
- str/dex/con/int/wis/cha (через get_*())
- feats_list -- '|'-separated имена выданных способностей класса (один
  раз итерируем MUD::Feats() и проверяем HaveFeat -- O(N) от числа
  feat'ов в классе)
- affects_list -- '|'-separated 'имя_заклинания (Nt)' для каждой
  активной аффект-записи (имя берём через MUD::Spell(a->type).GetCName)

KOI8-R текст конвертится в UTF-8 через EngineStringToUtf8 как для
прочих строковых полей.
Веб-UI просит видеть нормальные имена в combat-log и иметь возможность
выдать персонажу пред-заданные баффы (защита, доблесть) до начала боя.

- CharacterBuilder.set_name(name) -- тонкий wrapper над CharData.set_name.
- В scenario_runner после SpawnParticipant для PC выставляется имя по
  роли: 'attacker' / 'victim'. Без этого GET_NAME(pc) -> "" и в JSONL
  лог идёт ' -> костяная гончая: 5 (удар)'.
- Scenario.attacker.affects / victim.affects -- список { spell, duration }.
  Парсер scenario_loader валидирует структуру; runner для каждого
  affect делает self-cast (CastSpell(ch, ch, ..., sid, sid)) -- так мы
  переиспользуем engine'овые CalcDuration/modifier/location вместо
  ручной сборки Affect. duration в YAML пока используется только как
  документация (engine ставит свою через CalcDuration); явный override
  -- backlog. После применения affects -- SetPosition(kStand) на
  случай, если каст уронил позицию.
…+ equip

Веб-UI просит видеть в state-панели "что это за персонаж" и "чем
одет" -- одного hp/stats недостаточно для отладки баланса.

Добавлено в char_state:
- is_npc -- bool, отделяет PC от моба;
- vnum -- GET_MOB_VNUM для NPC, -1 для PC;
- short_descr -- get_npc_name() для NPC (полное имя из прототипа), пусто
  для PC;
- level -- GetLevel() (для мобов это уровень моба-прототипа);
- class_name -- MUD::Class(class).GetName() для PC;
- equip_list -- 'slot:vnum:name|slot:vnum:name|...' для всех непустых
  слотов equipment[]. slot -- стабильный ASCII-id (wield, body, head, ...).
Item-аффекты (kHaste, kStoneHands, kWaterBreath, ...) лежат в
AFF_FLAGS(ch), а не в ch->affected[]: пользователь надевал штаны со
"ускорение, дыхание.водой, каменная.рука", но в state-панели они не
показывались. Аналогично apply'и со шмота (+1 str, +2 hitroll/damroll)
работали в боевом коде, но мы эмитили base str через get_str() --
выглядело как "стат не изменился".

- str/dex/con/int/wis/cha теперь эмитятся как GetReal* (база + апплаи),
  плюс отдельные str_base/... для отладки overrides.
- hitroll/damroll/hit_add/ac_add/armour_add/morale_add/initiative_add
  из add_abils -- веб-UI рисует их в "Бонусы".
- flags_list: '|'-separated имена affect-flag'ов из AFF_FLAGS(ch);
  таблица из 38 наиболее интересных enum'ов EAffect (ускорение,
  каменные руки, доблесть, святилище, ауры, vampirism, и т.д.).
После ~8 пульсов без player-input game_limits.cpp::check_idling
вытаскивает PC в kStrangeRoom и пишет 'Вы пропали в пустоте этого
мира'. На длинных сценариях (сотни раундов) синтетический PC
выкидывало в комнату 0 -- бой обрывался, screen_output захватывало
этот спам.

Каждый раунд после pulse_violence обнуляем char_specials.timer для
attacker, victim и всех pets. Тесты не задеваются.
Ловушка с auto_mort_req: CObjectPrototype::get_auto_mort_req() для
items с ilevel > 20 возвращает 3+ ремортов (>30 -- 9, >35 -- 12).
EquipObj в handler.cpp:811 проверяет remort и отказывается надевать
если у носителя меньше; упавший item возвращается в инвентарь молча
(SendMsgToChar в headless desc, в JSONL только два obj_to_char).

В реальной игре имбовый шмот раздаётся только высокоремортным
персонажам -- симулятор должен играть 'максимально прокачанной'
версией класса, чтобы нижняя планка не стопорила тестируемый шмот.

CharacterBuilder.make_basic_player() теперь вызывает
m_result->set_remort(kMaxRemort) (= 99). Для 'простенького кинжальчика'
(vnum 1066, +50 ко всем статам, ilevel ~37) -- надевается; для
обычного шмота поведение не меняется (auto_mort_req=0 не блокирует).
Откатывает мотивацию b4f2406 («синтетический PC -- max remort»). MVP
действительно был собран с упрощением «всегда max remort», но цель
симулятора прямо противоположна: дать пользователю конфигурировать
каждый параметр персонажа (уровень, реморт, статы, шмот, аффекты,
петы) и наблюдать влияние на бой. Зашитый kMaxRemort убивает половину
смысла: нельзя сравнить класс на 0/3/12 ремортов.

Что меняется:
- CharacterBuilder.make_basic_player() больше не дёргает
  set_remort(kMaxRemort); реморт берётся из YAML-сценария.
- Появился публичный CharacterBuilder.set_remort(value) -- clamp
  в [0..kMaxRemort].
- PlayerSpec.remort -- новое поле YAML (default 0).
- scenario_loader парсит remort: N.
- scenario_runner вызывает set_remort(s.remort) ПЕРЕД
  grant_class_skills_and_feats(): grant читает уровень И реморт,
  и должен видеть финальный реморт, иначе высоко-ремортные
  скиллы/фиты/спеллы выпадают.
- grant_class_skills_and_feats() теперь не зовётся внутри
  make_basic_player() -- caller сам выстраивает порядок
  set_class/set_level/set_remort/grant.
- UI mud-balance-ui рендерит шмот в автокомплите с приглушением
  и плашкой 'r N'/'lvl N' если PC не дотягивает -- но сам объект
  всё равно можно выбрать (полезно для отладки EquipObj-reject).
…tate

CastSpell в apply_affects проверяет, что кастер знает заклинание,
резолвит его в своём классовом списке, помнит (mem > 0). Для предза-
клинаний из YAML это мешает -- пользователь хочет вывести в бой
дружинника с 'защитой богов' (заклинание витязя), и сыпаться на
'вы не знаете такого заклинания' не хочется.

CallMagic -- внутренний слой ниже CastSpell, применяет спелл-эффект
на цели как будто заклинание уже было успешно вычитано (CalcDuration,
modifier, location, bitvector всё резолвятся из spell-config). level берём
из GetRealLevel(ch).

Также char_state теперь эмитит 'remort' (PC: get_remort, NPC: 0)
чтобы веб-UI мог показать реморт на карточке участника.
Раньше был хардкод 38 'интересных' флагов в char_state -- забывался
каждый раз, когда movement добавлял новый. У пользователя обнаружилось
что 'определение яда' (kDetectPoison) в state-panel не было, хотя на
шмотке этот аффект был указан.

Заменил на FlagData::sprintbits с канонической таблицей affected_bits[]
из движка: имена 1:1 с in-game (команда 'аффекты', look) и автоматически
подхватываются новые флаги. 'ничего' (nothing_string) фильтруется в
пустую строку.
@kvirund kvirund force-pushed the claude/vibrant-raman-695d14 branch from 4d5404d to e395154 Compare May 1, 2026 14:55
kvirund added 4 commits May 1, 2026 16:56
…е через словарь EAffect

m_waffect_flags на предметах хранит биты в нумерации EWeaponAffect
(kDetectPoison = bit 27), а словарь dictionaries/affect_flags.yaml
использует нумерацию EAffect (kDetectPoison: 32). Старый loader брал
индекс из словаря и пихал в SetEWeaponAffectFlag -- для kDetectPoison
получался не бит 27, а бит 32, который в EWeaponAffect соответствует
kDisguising. weapon_affect[]-таблица потом проектировала kDisguising
в EAffect::kDisguise -- и носитель деревянной палицы получал
маскировку вместо определения яда.

Конвертер convert_to_yaml.py пишет YAML по правильной таблице
WEAPON_AFFECT_FLAGS (см. строку 655 convert_to_yaml.py), значит
сами YAML-файлы корректны: 'kDetectPoison' там реально означает
бит 27. Чинится только loader: ITEM_BY_NAME<EWeaponAffect>(name)
возвращает enum-bit напрямую, без словаря.

UNUSED_NN ветка сохранена для битов без имени в текущей сборке.

Симптом замечен в симуляторе на vnum 106 (деревянная палица):
flags_list = 'маскировка|...' вместо 'определение яда'.
…ect, а не через словарь EAffect"

This reverts commit fc45a57.
…AFFECT_FLAGS

В .obj файлах биты affect_flags хранятся в нумерации EWeaponAffect
(m_waffect_flags на CObjectPrototype), а парсер брал имена из таблицы
AFFECT_FLAGS (EAffect-имена). Результат: YAML на диске содержал
'kDetectPoison' там, где реально был бит 32 = EWeaponAffect::kDisguising,
и engine YAML loader потом грузил его как kDetectPoison через словарь
EAffect, попадал в бит 32 m_waffect_flags == kDisguising, а
weapon_affect[]-таблица отображала kDisguising в EAffect::kDisguise --
носитель получал 'маскировку'. Имя в YAML и наблюдаемый эффект
расходились.

Сменили parse_ascii_flags(parts[0], AFFECT_FLAGS) на WEAPON_AFFECT_FLAGS
(уже определена строкой 655). YAML теперь честный: 'kDisguising' там,
где бит 32, и т. д.

Связанные правки в engine/db/yaml_world_data_source.cpp -- следующий
коммит.
Парный фикс к converter/convert_to_yaml.py: при загрузке предмета
из YAML резолвим имя в YAML через ITEM_BY_NAME<EWeaponAffect>(name) --
не через словарь dictionaries/affect_flags.yaml (который использует
EAffect-нумерацию).

Также чиним симметричный экспортёр (запись YAML обратно): идём по
битам m_waffect_flags и берём имена через NAME_BY_ITEM<EWeaponAffect>,
а не через ConvertFlagsToNames(..., 'affect_flags'). Иначе round-trip
конвертера терял бы данные: бит 27 (kDetectPoison в EWeaponAffect)
после load+save становился бы 'kHorse' (имя из EAffect-словаря для
бита 27).

Старые YAML-имена, которых нет в EWeaponAffect (kDetectInvisible vs
kDetectInvisibility, kStopFight, kSilence и т. п. -- эхо старого
конвертера), мы более не пытаемся загрузить под видом weapon-affect:
ITEM_BY_NAME .at() бросает out_of_range, ловим и пишем syslog,
бит просто пропускаем. Лучше потерять один сомнительный флаг, чем
уронить boot всего мира. После того как мир будет перегенерирован
свежим конвертером (предыдущий коммит) -- неподходящих имён не
останется.

Симптом, на котором поймано: предмет vnum 106 (деревянная палица)
с YAML 'affect_flags: [kDetectPoison]' давал носителю 'маскировку',
а не 'определение яда'. После пары конвертер+loader YAML содержит
правильное 'kDisguising', и загруженный эффект совпадает с тем, что
было в проде на легаси-парсере.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant