feat(simulator): автономный симулятор баланса (#2967)#3220
Open
kvirund wants to merge 56 commits into
Open
Conversation
Автономный симулятор баланса (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
eff89a0 to
9d42fa9
Compare
…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 элементах он бессмыслен. Просто нагляднее без него.
Класс 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
После того как 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) фильтруется в пустую строку.
4d5404d to
e395154
Compare
…е через словарь 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', и загруженный эффект совпадает с тем, что было в проде на легаси-парсере.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Автономный симулятор баланса (issue #2967)
Что это
Отдельная сборочная цель
mud-sim— исполняемый файл, который линкует тот же движок, что иcircle, но запускается без сетевого слоя. Грузит мир, по YAML-сценарию программно создаёт участников, гоняет бой в цикле и пишет JSONL-журнал событий. Вся логика боя/магии/аффектов — настоящая, общая с продом; меняется только обвязка.Цель — давать имплементеру и иммам данные о балансе, которые из живой игры не достать: матрицы урона по статам и владениям, сравнение классов, вклад чармисов, точные цифры на каждый swing, без шума регенерации и поведения игроков.
Сборка
-DHAVE_YAML=ONобязателен: симулятор парсит YAML-сценарии, движок при этом грузит мир в YAML-формате.Подготовка мира
Симулятор работает с любым миром, переданным через
-d. Один раз нужно подготовить:Базовый прогон: PC против моба
Минимальный сценарий — игрок-богатырь без оружия бьёт моба автоатакой:
Параметры:
seed— зерно RNG для воспроизводимости (по умолчанию 0).rounds— число боевых раундов; один раунд = одноpulse_violence.output— путь к выходному JSONL.attacker/victim—type: playerилиtype: mob.Каст заклинаний
Поле
actionпереключает режим. По умолчаниюmelee(автоатака). Для каста:Симулятор каждый раунд ждёт
kGlobalCooldownиwait_stateпосле прошлого спелла, потом вызывает движковыйCastSpell(минуяDoCast, чтобы избежать ветки отложенного каста черезSetCast). Память заклинаний иwait_stateуважаются как в реальной игре.Имя заклинания — точно как в
lib/cfg/spells.xml(русское). Если класс знает это заклинание на этом уровне — каст пройдёт.Чармисы / поднятая нежить
Никакого
kCharmне кастуется. Подчинённые описываются прямо в участнике и приходят с ним уже лояльными:Каждый 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для обоих участников:Работает через тот же
SpawnParticipant/SetFighting, без отдельной ветки кода.Mob vs Mob
«Спарринг мобов» — оба
type: mob:Перебивка статов и HP
Любой стат (str/dex/con/int/wis/cha) и
max_hitможно переопределить:Что не указано — остаётся как назначил движок: для PC
CharacterBuilder.make_basic_playerставит все статы по 25, для моба — берётся прототип.max_hit, если задан — побеждает дефолтную выдачуINT_MAX/4(которую симулятор иначе ставит, чтобы участник пережил все раунды).Стартовая экипировка
Каждый предмет создаётся через
world_objects.create_from_prototype_by_vnumи автонадевается:kWeapon):kWield→kBoth→kHold(какequip_start_outfitдля noob outfits);find_eq_pos.Что не нашло слот — остаётся в инвентаре. После надевания пересчитывается
affect_total, чтобы applies от предмета (например,kPhysicDamagePercentот меча) попали вadd_abils.Алиасы имён классов
Чтобы YAML был ASCII-friendly, поддерживаются латинские и английские алиасы вместо русских имён из
pc_*.xml:Русские имена тоже работают, если YAML записан в KOI8-R.
Матричный прогон по статам
Главный кейс из задачи — таблица «урон vs значение стата». Достаточно прогнать одинаковый сценарий N раз с разными значениями и подать вывод в
tools/balance_simulator_matrix.py:На выходе график «средний урон за каст vs мудрость», на котором видно плато до wisdom 30 и линейный рост дальше — настоящая балансная формула шаровой молнии.
События в JSONL
Один JSON на строку, ключи в фиксированном порядке (
ts,name, потом атрибуты по алфавиту). Совместимо с OTLP log record schema.namedamageDamage::Processпослеset_hitattacker_name,victim_name,dam,real_dam,over_dam,victim_hp_after,dmg_type,crit,spell_id,skill_id,attacker_is_charmie,attacker_master_namemisshit()attacker_name,victim_name,reason ∈ {auto_5pct, level_diff, thac0_roll}affect_addedaffect_to_charпослеaffect_totaltarget_name,spell_id,duration,modifier,location,bitvectoraffect_removedRemoveAffectFromCharпередAffectRemovechar_staterole,target_name,hp,max_hp,move,max_move,position,in_room,spellpower_add_pct,physdam_add_pct,affects_count,aff_silence,aff_charmed,aff_sleeproundround,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.Воспроизводимость
Игнорируется
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достаточна.inborn=1), и автоматические по уровню (inborn=0 slot=0), и слотовые (inborn=0 slot>=1) — последние без оглядки на лимит слотов. То есть симулятор играет «идеально прокачанным» персонажем, а реальный игрок ограничен числом слотов и должен выбирать. Числа в матрицах — это «верхняя граница dpr класса при идеальной прокачке».race: <название>— отдельный пункт.OtlpLogsSinkотложен (изначально планировался поверхOtelLogSender, пользователь снизил приоритет).Контрольные прогоны
kPunch+kLeftHit(вторая рука)spellpower_add_pct = 0kPowerMagic +50%подтверждён ровно