Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).
Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.
Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.
Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».
Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.
Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.
Документация — CLAUDE.md как оглавление, содержание в docs/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
|
|
|
|
#include "debug/harness.h"
|
|
|
|
|
|
|
|
|
|
|
|
#include <chrono>
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
#include <filesystem>
|
2026-08-14 01:16:08 +07:00
|
|
|
|
#include <fstream>
|
Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).
Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.
Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.
Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».
Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.
Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.
Документация — CLAUDE.md как оглавление, содержание в docs/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
|
|
|
|
#include <cstdarg>
|
Материалы и мастерская: вещь можно доработать (M27)
Аффиксы сделали находку решением, но решением одноразовым: выпало как
выпало. Материалы дают второй способ получить нужное — не «повезло», а
«доработал». Пять материалов, пять операций, экран WORKSHOP.
Главное правило экономики — материал добывается ТОЛЬКО В СВОЕЙ ПОЛОСЕ:
био-ядра с плотских этажей, крио с мёрзлых. Это единственная причина
возвращаться на средние глубины, когда рекорд ушёл вниз; без полос весь
контент, кроме последнего акта, умирает в день прохождения.
Материалы — валюта, а не предметы: не занимают ячеек, не весят, в схроне
не лежат. Но теряются как добыча: зачисляются только при эвакуации.
Три уточнения операций, каждое против конкретного обхода: перековка
меняет только значения (иначе это новый бросок предмета в обход
редкости), прививка не ставит уже стоящий тег (иначе «+урон» шесть раз),
возвышается случайный аффикс (иначе все предметы сойдутся к одному).
--tool craft поймал, что материалы бесплатны. С твари падало по её
danger, и вылазка на восьмидесятом этаже приносила тысячу ядер при цене
доводки вещи в шестьсот: 0.6 вылазки на предмет вместо восьми. Правило
заменено на «одно ядро с мелочи, два с крупной» — стало 6.2.
Сам инструмент при этом тоже считал неверно: брал доход из бюджета
опасности этажа, хотя на этаж влезает не больше MAX_LEVEL_SPAWNS тварей.
Бюджет уходит не в количество тварей, а в их силу, и доход упирается в
потолок расстановки с сорокового этажа. Глубина даёт не количество
материалов, а качество предметов — и вместе с ним цену доводки.
Залом замерен на тысяче попыток: рассыпалось 11.6% при заявленных 12%,
плата снимается всегда, слот теряется всегда. «У меня трижды не
рассыпалось» не доказательство ни в какую сторону.
Пункт меню WORKSHOP проверен --ui-check — той самой проверкой, которая
появилась вчера из-за пункта UPGRADES, молча закрывавшего игру.
Формат профиля 5: строка mat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:07:46 +07:00
|
|
|
|
#include <cmath>
|
Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).
Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.
Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.
Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».
Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.
Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.
Документация — CLAUDE.md как оглавление, содержание в docs/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
|
|
|
|
#include <cstdio>
|
Пункт UPGRADES выходил из игры; меню теперь проверяется (M26)
Жалоба была «крашится при открытии апгрейдов». Не крашилось: пункт
закрывал игру. В обработчике главного меню стоял `default: ui.quit`, а
экран улучшений добавили четвёртым пунктом — обработчик о нём просто не
узнал. Снаружи мгновенное закрытие окна неотличимо от падения.
Починка на две строки, но интереснее причина, по которой это прожило
четыре вехи: в проекте каждая механика меряется прогоном с кодом
возврата, а меню не проверял никто. Все инструменты смотрят на
СИМУЛЯЦИЮ.
Поэтому заведён --ui-check: проходит по всем пунктам меню и требует
одного — нажатие ВЕДЁТ КУДА-ТО, а из игры выходит ровно один пункт,
QUIT. Плюс проверяет, что кнопки экрана улучшений действительно покупают.
Окно при этом не открывается: UiActivate ничего из raylib не трогает.
Проверка сперва запущена на СЛОМАННОЙ сборке и дала код 1 с диагнозом
«пункт UPGRADES не делает ничего», и только после этого объявлена
рабочей. Проверка, которую не видели падающей, ещё ничего не проверяет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:51:58 +07:00
|
|
|
|
#include <cstring>
|
Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).
Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.
Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.
Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».
Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.
Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.
Документация — CLAUDE.md как оглавление, содержание в docs/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
|
|
|
|
|
|
|
|
|
|
#include "game.h"
|
Личинки, парирование и инструмент --wall (M30)
--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами
через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке.
Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление
тварей и живёт полторы минуты без управления. Первая стена: одиночка D7,
трое D30, пятеро D30 - петля работает.
И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в
том числе незанятые. Строка писалась, когда отряд всегда был впятером;
после появления ростера одиночка выходил на этаж с четырьмя невидимыми
телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже,
100 -> 41" был боем впятером.
ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом
проигрывает двум бредущим: 210 здоровья против ножа это девять секунд
работы под ответным уроном. Первые этажи населены личинками (55 здоровья,
7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый.
Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре
меняет, кто выходит, а не числа у одного и того же.
ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она
выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её
двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка.
Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в
замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь.
Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает
повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть
размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары.
Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это
его удар, просто по летящей цели.
Проверка меряет размен целиком, включая обратную половину правила:
"стоя столбом не отбивает". Без неё условие про соприкосновение
существовало бы только в комментарии.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:04:35 +07:00
|
|
|
|
#include "sim/affix.h"
|
Материалы и мастерская: вещь можно доработать (M27)
Аффиксы сделали находку решением, но решением одноразовым: выпало как
выпало. Материалы дают второй способ получить нужное — не «повезло», а
«доработал». Пять материалов, пять операций, экран WORKSHOP.
Главное правило экономики — материал добывается ТОЛЬКО В СВОЕЙ ПОЛОСЕ:
био-ядра с плотских этажей, крио с мёрзлых. Это единственная причина
возвращаться на средние глубины, когда рекорд ушёл вниз; без полос весь
контент, кроме последнего акта, умирает в день прохождения.
Материалы — валюта, а не предметы: не занимают ячеек, не весят, в схроне
не лежат. Но теряются как добыча: зачисляются только при эвакуации.
Три уточнения операций, каждое против конкретного обхода: перековка
меняет только значения (иначе это новый бросок предмета в обход
редкости), прививка не ставит уже стоящий тег (иначе «+урон» шесть раз),
возвышается случайный аффикс (иначе все предметы сойдутся к одному).
--tool craft поймал, что материалы бесплатны. С твари падало по её
danger, и вылазка на восьмидесятом этаже приносила тысячу ядер при цене
доводки вещи в шестьсот: 0.6 вылазки на предмет вместо восьми. Правило
заменено на «одно ядро с мелочи, два с крупной» — стало 6.2.
Сам инструмент при этом тоже считал неверно: брал доход из бюджета
опасности этажа, хотя на этаж влезает не больше MAX_LEVEL_SPAWNS тварей.
Бюджет уходит не в количество тварей, а в их силу, и доход упирается в
потолок расстановки с сорокового этажа. Глубина даёт не количество
материалов, а качество предметов — и вместе с ним цену доводки.
Залом замерен на тысяче попыток: рассыпалось 11.6% при заявленных 12%,
плата снимается всегда, слот теряется всегда. «У меня трижды не
рассыпалось» не доказательство ни в какую сторону.
Пункт меню WORKSHOP проверен --ui-check — той самой проверкой, которая
появилась вчера из-за пункта UPGRADES, молча закрывавшего игру.
Формат профиля 5: строка mat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:07:46 +07:00
|
|
|
|
#include "sim/craft.h"
|
Личинки, парирование и инструмент --wall (M30)
--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами
через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке.
Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление
тварей и живёт полторы минуты без управления. Первая стена: одиночка D7,
трое D30, пятеро D30 - петля работает.
И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в
том числе незанятые. Строка писалась, когда отряд всегда был впятером;
после появления ростера одиночка выходил на этаж с четырьмя невидимыми
телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже,
100 -> 41" был боем впятером.
ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом
проигрывает двум бредущим: 210 здоровья против ножа это девять секунд
работы под ответным уроном. Первые этажи населены личинками (55 здоровья,
7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый.
Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре
меняет, кто выходит, а не числа у одного и того же.
ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она
выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её
двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка.
Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в
замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь.
Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает
повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть
размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары.
Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это
его удар, просто по летящей цели.
Проверка меряет размен целиком, включая обратную половину правила:
"стоя столбом не отбивает". Без неё условие про соприкосновение
существовало бы только в комментарии.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:04:35 +07:00
|
|
|
|
#include "sim/enemy.h"
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
#include "sim/loot.h"
|
Триггеры: четыре условных эффекта на аффиксах (M28)
ON_HIT поджигает цель, ON_KILL даёт рывок скорости стаками, ON_HURT
обжигает того, кто ударил, ON_DOWN даёт отряду ярость за павшего.
Отдельным тегом аффикса, то есть по тем же правилам, что и всё
остальное: платит по тиру, влезает в бюджет, катается и прививается.
СДЕЛАНО НЕ ТАК, КАК ПРЕДЛАГАЛ ДИЗАЙН. В docs/21-damage.md §4 стояло:
журнал боевых событий уже есть, вешайте триггеры на него. Звучит
правильно, и сделать так нельзя: sim/events.h это канал «симуляция ->
эффекты», односторонний по устройству, и в headless-прогонах его нет
вовсе. Начни правила боя его читать — игра станет вести себя по-разному
с окном и без, то есть --accept начнёт мерить не то, что меряет.
Триггеры срабатывают в месте урона. Опасение «два канала разойдутся»
снято иначе: второго канала никто и не завёл, есть четыре точки рядом с
тем уроном, который триггер обслуживает.
Четыре из девяти, и это не «пока»: каждый эффект обязан класть своё
число в уже существующий канал влияния. Крита в игре нет, площадного
урона нет, выдачи предметов на входе нет — заводить их ради строк
таблицы значит завести пять мёртвых веток. Причины выписаны в документе.
Три ограничителя проверяются числами в --descend:
триггеры: аффикс доехал да, ON_HIT поджёг стаков 1, откат 0.9 с
(за него наложено 0), ON_KILL стаков 1, скорость 1.06
рекурсия: за один шаг стаков прибавлялось максимум 1
Первая строка про проводку: аффикс с триггером легко сделать так, что он
лежит в предмете, показывается в подсказке и не делает ничего.
Проверка сперва брала targets[0] и проходила на descent по везению: на
hollow первым в пуле стоял ящик, а обстановка не горит и убийством не
считается. Теперь цель ищется по виду и ставится в свободную клетку с
прямой видимостью — иначе замеряется планировка этажа, а не триггеры.
Шаг симуляции 3.7 мкс из 16666. --accept PASS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:32:12 +07:00
|
|
|
|
#include "sim/trigger.h"
|
Пункт UPGRADES выходил из игры; меню теперь проверяется (M26)
Жалоба была «крашится при открытии апгрейдов». Не крашилось: пункт
закрывал игру. В обработчике главного меню стоял `default: ui.quit`, а
экран улучшений добавили четвёртым пунктом — обработчик о нём просто не
узнал. Снаружи мгновенное закрытие окна неотличимо от падения.
Починка на две строки, но интереснее причина, по которой это прожило
четыре вехи: в проекте каждая механика меряется прогоном с кодом
возврата, а меню не проверял никто. Все инструменты смотрят на
СИМУЛЯЦИЮ.
Поэтому заведён --ui-check: проходит по всем пунктам меню и требует
одного — нажатие ВЕДЁТ КУДА-ТО, а из игры выходит ровно один пункт,
QUIT. Плюс проверяет, что кнопки экрана улучшений действительно покупают.
Окно при этом не открывается: UiActivate ничего из raylib не трогает.
Проверка сперва запущена на СЛОМАННОЙ сборке и дала код 1 с диагнозом
«пункт UPGRADES не делает ничего», и только после этого объявлена
рабочей. Проверка, которую не видели падающей, ещё ничего не проверяет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:51:58 +07:00
|
|
|
|
#include "ui/menu.h"
|
Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).
Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.
Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.
Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».
Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.
Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.
Документация — CLAUDE.md как оглавление, содержание в docs/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
|
|
|
|
|
|
|
|
|
|
// Перевод экранных осей ввода в мировые — единственное место,
|
|
|
|
|
|
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
#include "engine/view2d.h"
|
Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).
Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.
Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.
Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».
Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.
Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.
Документация — CLAUDE.md как оглавление, содержание в docs/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
|
|
|
|
|
|
|
|
|
|
namespace
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
|
|
|
|
// Мини-форматтер для строк вердикта (raylib в этот модуль не тащим).
|
|
|
|
|
|
const char* Fmt(const char* fmt, ...)
|
|
|
|
|
|
{
|
|
|
|
|
|
static char buf[4][256];
|
|
|
|
|
|
static int slot = 0;
|
|
|
|
|
|
slot = (slot + 1) % 4;
|
|
|
|
|
|
|
|
|
|
|
|
va_list args;
|
|
|
|
|
|
va_start(args, fmt);
|
|
|
|
|
|
vsnprintf(buf[slot], sizeof(buf[slot]), fmt, args);
|
|
|
|
|
|
va_end(args);
|
|
|
|
|
|
return buf[slot];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- маршрут для --headless ---------------------------------------------------
|
|
|
|
|
|
struct Waypoint
|
|
|
|
|
|
{
|
|
|
|
|
|
float x, y;
|
|
|
|
|
|
float hold; // сколько стоять по прибытии, сек
|
|
|
|
|
|
int formation; // -1 = не менять
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const Waypoint kRoute[] = {
|
|
|
|
|
|
{24.0f, 26.0f, 8.0f, 0}, // стоячий бой на арене у кучки из 5
|
|
|
|
|
|
{31.0f, 20.0f, 6.0f, -1}, // вплотную к кучке
|
|
|
|
|
|
{31.0f, 16.5f, 0.0f, 1}, // ко входу в северный коридор (2 тайла)
|
|
|
|
|
|
{31.0f, 11.5f, 2.0f, -1}, // сквозь коридор
|
|
|
|
|
|
{24.5f, 7.5f, 8.0f, 0}, // бой в северной комнате
|
|
|
|
|
|
{31.0f, 11.5f, 0.0f, -1},
|
|
|
|
|
|
{31.0f, 17.5f, 2.0f, -1}, // обратно на арену
|
|
|
|
|
|
{20.0f, 29.0f, 4.0f, 2},
|
|
|
|
|
|
{16.0f, 31.0f, 0.0f, 1}, // ко входу в западный коридор
|
|
|
|
|
|
{11.0f, 31.0f, 2.0f, -1}, // сквозь него
|
|
|
|
|
|
{ 8.5f, 38.5f, 8.0f, 0}, // бой в западной комнате
|
|
|
|
|
|
{11.0f, 31.0f, 0.0f, -1},
|
|
|
|
|
|
{17.0f, 31.0f, 2.0f, -1},
|
|
|
|
|
|
{24.0f, 26.0f, 6.0f, 0},
|
|
|
|
|
|
};
|
|
|
|
|
|
constexpr int kRouteCount = int(sizeof(kRoute) / sizeof(kRoute[0]));
|
|
|
|
|
|
constexpr float kArriveDist = 0.7f;
|
|
|
|
|
|
constexpr float kWaypointTimeout = 20.0f;
|
|
|
|
|
|
|
|
|
|
|
|
// Мировое направление -> экранные оси: ровно то, что делает игрок клавишами.
|
|
|
|
|
|
Vec2 MoveInputToward(const Game& g, Vec2 goal)
|
|
|
|
|
|
{
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
return Normalized(v2d::WorldToScreen(Normalized(goal - g.squad.anchor)));
|
Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).
Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.
Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.
Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».
Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.
Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.
Документация — CLAUDE.md как оглавление, содержание в docs/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
// Наблюдатель: собирает то, что нельзя увидеть в одном кадре.
|
|
|
|
|
|
// * сколько агент непрерывно торчит в чужой линии огня (критерий 11.4);
|
|
|
|
|
|
// * как часто меняется postPos (критерий 11.5 — «нет дёрганья»).
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
struct Watcher
|
|
|
|
|
|
{
|
|
|
|
|
|
float residence[NUM_AGENTS] = {}; // текущее время внутри чужой линии
|
|
|
|
|
|
float maxResidence = 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
// «Дёрганье» = решатель перекоммитил postPos раньше, чем истёк commitTime.
|
|
|
|
|
|
// Коммит виден по скачку commitTimer вверх. Смена состояния отряда сбрасывает
|
|
|
|
|
|
// отсчёт: это новая обстановка, а не дрожание.
|
|
|
|
|
|
float prevCommitTimer[NUM_AGENTS] = {};
|
|
|
|
|
|
float sinceCommit[NUM_AGENTS] = {};
|
|
|
|
|
|
bool hadCommit[NUM_AGENTS] = {};
|
|
|
|
|
|
float minCommitInterval = 1e9f;
|
|
|
|
|
|
int commits = 0;
|
|
|
|
|
|
int jitterViolations = 0;
|
|
|
|
|
|
AgentState prevState = AgentState::HOLDING;
|
|
|
|
|
|
|
|
|
|
|
|
// Граница сценария. Накопленные максимумы и счётчики остаются (они про весь
|
|
|
|
|
|
// прогон), а вот «когда был прошлый коммит» — нет: следующий сценарий это
|
|
|
|
|
|
// другой Game с другим отрядом, и мерить между ними интервал бессмысленно.
|
|
|
|
|
|
// Без этого первый же коммит нового сценария выглядел дёрганьем.
|
|
|
|
|
|
void BeginScenario()
|
|
|
|
|
|
{
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
hadCommit[i] = false;
|
|
|
|
|
|
sinceCommit[i] = 0.0f;
|
|
|
|
|
|
prevCommitTimer[i] = 0.0f;
|
|
|
|
|
|
residence[i] = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
prevState = AgentState::HOLDING;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void Observe(const Game& g, float dt)
|
|
|
|
|
|
{
|
|
|
|
|
|
const bool stateChanged = (g.squad.state != prevState);
|
|
|
|
|
|
prevState = g.squad.state;
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Agent& a = g.squad.agents[i];
|
|
|
|
|
|
|
|
|
|
|
|
// Только линии огня: 11.4 про них, сектор клинка резервируется
|
|
|
|
|
|
// заведомо шире опасной зоны (см. ai/lane_registry.h).
|
|
|
|
|
|
const bool inside = g.lanes.CountBlocked(i, a.pos, a.bodyRadius, true) > 0;
|
|
|
|
|
|
residence[i] = inside ? residence[i] + dt : 0.0f;
|
|
|
|
|
|
maxResidence = std::max(maxResidence, residence[i]);
|
|
|
|
|
|
|
|
|
|
|
|
if (stateChanged) hadCommit[i] = false;
|
|
|
|
|
|
|
|
|
|
|
|
sinceCommit[i] += dt;
|
|
|
|
|
|
if (a.commitTimer > prevCommitTimer[i] + 1e-4f) // произошёл коммит
|
|
|
|
|
|
{
|
|
|
|
|
|
++commits;
|
|
|
|
|
|
if (hadCommit[i])
|
|
|
|
|
|
{
|
|
|
|
|
|
minCommitInterval = std::min(minCommitInterval, sinceCommit[i]);
|
|
|
|
|
|
if (sinceCommit[i] < g_tune.commitTime - FIXED_DT)
|
|
|
|
|
|
{
|
|
|
|
|
|
++jitterViolations;
|
|
|
|
|
|
std::printf(" JITTER #%d dt=%.3f state=%s melee=%d prio=%d lane=%d "
|
|
|
|
|
|
"post(%.2f,%.2f) t=%.2f\n",
|
|
|
|
|
|
i, double(sinceCommit[i]),
|
|
|
|
|
|
g.squad.state == AgentState::ADVANCING ? "ADV" : "HOLD",
|
|
|
|
|
|
int(a.IsMelee()), int(a.priorityLane), int(a.hasLane),
|
|
|
|
|
|
double(a.postPos.x), double(a.postPos.y),
|
|
|
|
|
|
double(g.simTime));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
hadCommit[i] = true;
|
|
|
|
|
|
sinceCommit[i] = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
prevCommitTimer[i] = a.commitTimer;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
int CountLanes(const Game& g)
|
|
|
|
|
|
{
|
|
|
|
|
|
int n = 0;
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|
|
|
|
|
if (g.squad.agents[i].hasLane) ++n;
|
|
|
|
|
|
return n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void PrintVerdict(const char* name, bool pass, const char* detail)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf("[%s] %-52s %s\n", pass ? "PASS" : "FAIL", name, detail);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
// --headless: маршрут + метрики
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
int RunHeadless(const HarnessOptions& opt)
|
|
|
|
|
|
{
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.enableSolver = opt.solver;
|
|
|
|
|
|
game.enableLaneAvoid = opt.laneAvoid;
|
|
|
|
|
|
game.squad.meleeCount = opt.meleeCount; // до Init: состав раздаётся там
|
|
|
|
|
|
game.squad.pistolCount = opt.pistolCount;
|
|
|
|
|
|
game.Init();
|
|
|
|
|
|
|
|
|
|
|
|
Watcher watch;
|
|
|
|
|
|
const int totalSteps = int(opt.seconds / FIXED_DT);
|
|
|
|
|
|
int wp = 0;
|
|
|
|
|
|
bool holding = false;
|
|
|
|
|
|
float phaseClock = 0.0f;
|
|
|
|
|
|
int lastFormation = -1;
|
|
|
|
|
|
double simSeconds = 0.0;
|
|
|
|
|
|
float nextReport = 10.0f;
|
|
|
|
|
|
float worstSettle = -1.0f;
|
|
|
|
|
|
float worstHoldUptime = 1e9f;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("headless: %.0f s, solver=%d laneAvoid=%d melee=%d/%d\n", double(opt.seconds),
|
|
|
|
|
|
int(opt.solver), int(opt.laneAvoid), game.squad.meleeCount, NUM_AGENTS);
|
|
|
|
|
|
std::printf(" t friendly shots kills uptime%% settle lanes\n");
|
|
|
|
|
|
|
|
|
|
|
|
for (int step = 0; step < totalSteps; ++step)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Waypoint& p = kRoute[wp];
|
|
|
|
|
|
const float distToGoal = Distance(game.squad.anchor, {p.x, p.y});
|
|
|
|
|
|
|
|
|
|
|
|
InputState in;
|
|
|
|
|
|
if (!holding) in.moveScreen = MoveInputToward(game, {p.x, p.y});
|
|
|
|
|
|
if (p.formation >= 0 && p.formation != lastFormation)
|
|
|
|
|
|
{
|
|
|
|
|
|
in.setFormation = p.formation;
|
|
|
|
|
|
lastFormation = p.formation;
|
|
|
|
|
|
}
|
|
|
|
|
|
game.in = in;
|
|
|
|
|
|
|
|
|
|
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
simSeconds += std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
|
|
|
|
|
watch.Observe(game, FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
phaseClock += FIXED_DT;
|
|
|
|
|
|
if (!holding)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (distToGoal < kArriveDist || phaseClock > kWaypointTimeout)
|
|
|
|
|
|
{
|
|
|
|
|
|
holding = true;
|
|
|
|
|
|
phaseClock = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (phaseClock >= p.hold)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (p.hold >= 4.0f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" hold @ (%.0f,%.0f) %.0fs -> lanes %d/%d uptime %.1f%% settle %.2f\n",
|
|
|
|
|
|
double(p.x), double(p.y), double(p.hold), CountLanes(game), NUM_AGENTS,
|
|
|
|
|
|
double(game.metrics.uptimeAvg), double(game.metrics.lastSettle));
|
|
|
|
|
|
worstHoldUptime = std::min(worstHoldUptime, game.metrics.uptimeAvg);
|
|
|
|
|
|
|
|
|
|
|
|
if (opt.probe)
|
|
|
|
|
|
{
|
|
|
|
|
|
const SolverContext ctx = game.MakeContext();
|
|
|
|
|
|
std::printf(" живых мишеней %d/%d\n", CountAliveTargets(game.targets),
|
|
|
|
|
|
NUM_TARGETS);
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Agent& a = game.squad.agents[i];
|
|
|
|
|
|
const int nearest = NearestAliveTarget(a.pos, ctx, 1e9f);
|
|
|
|
|
|
const float d =
|
|
|
|
|
|
(nearest >= 0) ? Distance(a.pos, game.targets[nearest].pos) : -1.0f;
|
|
|
|
|
|
std::printf(" #%d pos(%.1f,%.1f) lane=%d t=%d ally=%d wall=%d "
|
|
|
|
|
|
"nearest=%.1f post(%.1f,%.1f) uptime=%.0f%%\n",
|
|
|
|
|
|
i, double(a.pos.x), double(a.pos.y), int(a.hasLane),
|
|
|
|
|
|
a.currentTargetId, int(a.targetBlockedByAlly),
|
|
|
|
|
|
int(a.targetBlockedByWall), double(d), double(a.postPos.x),
|
|
|
|
|
|
double(a.postPos.y), double(game.metrics.uptimePct[i]));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
holding = false;
|
|
|
|
|
|
phaseClock = 0.0f;
|
|
|
|
|
|
wp = (wp + 1) % kRouteCount;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
worstSettle = std::max(worstSettle, game.metrics.lastSettle);
|
|
|
|
|
|
|
|
|
|
|
|
if (game.simTime >= nextReport)
|
|
|
|
|
|
{
|
|
|
|
|
|
nextReport += 10.0f;
|
|
|
|
|
|
std::printf("%6.1f %8d %6d %6d %7.1f %6.2f %d/%d\n", double(game.simTime),
|
|
|
|
|
|
game.metrics.friendlyHits, game.metrics.shotsFired,
|
|
|
|
|
|
game.metrics.targetsKilled, double(game.metrics.uptimeAvg),
|
|
|
|
|
|
double(game.metrics.lastSettle), CountLanes(game), NUM_AGENTS);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const double avgUs = (totalSteps > 0) ? (simSeconds * 1e6 / totalSteps) : 0.0;
|
|
|
|
|
|
std::printf("---\nFRIENDLY_HITS = %d\nshots = %d kills = %d\n", game.metrics.friendlyHits,
|
|
|
|
|
|
game.metrics.shotsFired, game.metrics.targetsKilled);
|
|
|
|
|
|
std::printf("FIRE UPTIME avg = %.1f%% (по агентам:", double(game.metrics.uptimeAvg));
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i) std::printf(" %.0f", double(game.metrics.uptimePct[i]));
|
|
|
|
|
|
std::printf(")\nхудший uptime на стоянке = %.1f%%\n", double(worstHoldUptime));
|
|
|
|
|
|
std::printf("SETTLE last = %.2f s, worst = %.2f s\n", double(game.metrics.lastSettle),
|
|
|
|
|
|
double(worstSettle));
|
|
|
|
|
|
if (game.squad.meleeCount > 0)
|
|
|
|
|
|
std::printf("MELEE: в зоне удара %.1f%% времени, взмахов %d, попаданий %d, оборвано %d\n",
|
|
|
|
|
|
double(game.metrics.meleeUptimeAvg), game.metrics.meleeSwings,
|
|
|
|
|
|
game.metrics.meleeHits, game.metrics.meleeAborts);
|
|
|
|
|
|
std::printf("макс. время в чужой линии = %.2f s, дёрганий postPos = %d\n",
|
|
|
|
|
|
double(watch.maxResidence), watch.jitterViolations);
|
|
|
|
|
|
std::printf("шаг симуляции в среднем = %.1f us (бюджет кадра 16666 us)\n", avgUs);
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < game.metrics.hitLogCount; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
const FriendlyHitEvent& e = game.metrics.hitLog[i];
|
|
|
|
|
|
std::printf(" friendly hit t=%.2f #%d -> #%d @ (%.1f,%.1f), жертва %s\n", double(e.time),
|
|
|
|
|
|
e.shooter, e.victim, double(e.pos.x), double(e.pos.y),
|
|
|
|
|
|
e.victimAdvancing ? "ADVANCING" : "HOLDING");
|
|
|
|
|
|
}
|
|
|
|
|
|
return game.metrics.friendlyHits == 0 ? 0 : 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
// --accept: сценарии из раздела 11
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
int RunAccept()
|
|
|
|
|
|
{
|
|
|
|
|
|
bool allPass = true;
|
|
|
|
|
|
Watcher watch; // общий: коммиты postPos по всем сценариям
|
|
|
|
|
|
Watcher arenaWatch; // открытая арена: там и меряется критерий 11.4
|
|
|
|
|
|
|
|
|
|
|
|
// Критерии 11.1 / 11.6 / 11.7 / 11.8 описывают ПРАВИЛА СТРЕЛЬБЫ и меряются
|
|
|
|
|
|
// на отряде из пяти стрелков — так же, как их формулировал спек. Клинок
|
|
|
|
|
|
// проверяется отдельно (блок M7 ниже) и в трёхминутном прогоне 11.2, где
|
|
|
|
|
|
// состав отряда обычный, боевой.
|
|
|
|
|
|
const int kSpecSquadMelee = 0;
|
|
|
|
|
|
const int kSpecSquadPistol = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// === 11.1 + 11.8: отряд в куче на открытой арене, игрок останавливается ====
|
|
|
|
|
|
{
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.squad.meleeCount = kSpecSquadMelee;
|
|
|
|
|
|
game.squad.pistolCount = kSpecSquadPistol;
|
|
|
|
|
|
game.Init();
|
|
|
|
|
|
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
|
|
|
|
|
game.PlaceSquad({28.0f, 23.5f}, true); // кучка из 5 мишеней в радиусе
|
|
|
|
|
|
|
|
|
|
|
|
// 0.4 с движения, чтобы отряд был в ADVANCING, потом игрок отпускает клавиши
|
|
|
|
|
|
for (int i = 0; i < int(0.4f / FIXED_DT); ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
game.in = InputState{};
|
|
|
|
|
|
game.in.moveScreen = {0.0f, 1.0f};
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
float clock = 0.0f;
|
|
|
|
|
|
float settle4 = -1.0f;
|
|
|
|
|
|
for (int i = 0; i < int(25.0f / FIXED_DT); ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
game.in = InputState{};
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
watch.Observe(game, FIXED_DT);
|
|
|
|
|
|
arenaWatch.Observe(game, FIXED_DT);
|
|
|
|
|
|
clock += FIXED_DT;
|
|
|
|
|
|
if (settle4 < 0.0f && CountLanes(game) >= 4) settle4 = clock;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const bool p1 = (settle4 >= 0.0f && settle4 <= 1.5f);
|
|
|
|
|
|
const bool p8 = (game.metrics.uptimeAvg >= 80.0f);
|
|
|
|
|
|
allPass = allPass && p1 && p8;
|
|
|
|
|
|
PrintVerdict("11.1 >=4/5 линий за <=1.5 с после остановки", p1,
|
|
|
|
|
|
Fmt("settle=%.2f с, линий сейчас %d/5", double(settle4),
|
|
|
|
|
|
CountLanes(game)));
|
|
|
|
|
|
PrintVerdict("11.8 FIRE UPTIME в стоячем бою >= 80%", p8,
|
|
|
|
|
|
Fmt("uptime=%.1f%%", double(game.metrics.uptimeAvg)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 11.6: узкий проход в 2 тайла =========================================
|
|
|
|
|
|
// Коридор: стена y=13..15, проём x=30..31 -> движение вдоль -y, поперёк = x.
|
|
|
|
|
|
{
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.squad.meleeCount = kSpecSquadMelee;
|
|
|
|
|
|
game.squad.pistolCount = kSpecSquadPistol;
|
|
|
|
|
|
game.Init();
|
|
|
|
|
|
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
|
|
|
|
|
game.PlaceSquad({31.0f, 19.0f}, false);
|
|
|
|
|
|
Watcher corridorWatch;
|
|
|
|
|
|
|
|
|
|
|
|
double lateralSum = 0.0; // усреднённый разброс поперёк/вдоль движения
|
|
|
|
|
|
double longitudinalSum = 0.0;
|
|
|
|
|
|
int maxInside = 0;
|
|
|
|
|
|
// Считаются только выстрелы ВПЕРЁД, по ходу движения: именно там между
|
|
|
|
|
|
// стрелком и целью может оказаться свой. Выстрел назад, в оставленную
|
|
|
|
|
|
// позади комнату, к правилу «не стрелять сквозь своего» отношения не
|
|
|
|
|
|
// имеет — впереди по такой линии никого из отряда нет по построению.
|
|
|
|
|
|
int rearShotsInside = 0; // вперёд стреляет НЕ передний — вот это важно
|
|
|
|
|
|
int frontShotsInside = 0;
|
|
|
|
|
|
int backwardShots = 0; // для отчёта: сколько огня ушло назад
|
|
|
|
|
|
int throughAllyShots = 0; // выстрелов, прошедших через тело союзника
|
|
|
|
|
|
int samples = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Две фазы: сначала входим в проход, потом ВСТАЁМ и меряем.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Мерить на ходу нельзя: пока игрок тянет курсор, тот всегда обгоняет
|
|
|
|
|
|
// отряд (anchorSpeed 3.3 против moveSpeed 1.75), натяжение поводка
|
|
|
|
|
|
// упирается в упор и включается спринт, а в спринте отряд по замыслу не
|
|
|
|
|
|
// стреляет вовсе (Game::FireWeapons). Критерий 11.6 проверял бы пустоту.
|
|
|
|
|
|
// Боевая ситуация в проходе — это именно «вошли и работаем»: тогда
|
|
|
|
|
|
// передний держит линию, а задние упираются в его спину.
|
|
|
|
|
|
// Меряем ВСЮ проводку через горловину, а не только стоянку в ней.
|
|
|
|
|
|
// Проход глубиной три тайла, а бойцов пятеро — колонна физически длиннее
|
|
|
|
|
|
// самого прохода и целиком внутрь него не помещается никогда. Поэтому
|
|
|
|
|
|
// область замера это горловина ПЛЮС подходы с обеих сторон: именно на
|
|
|
|
|
|
// этом отрезке видно, вытянулся отряд в нитку или пошёл шеренгой.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Порог входа в шеренгу узкий по построению: сам проём шириной два тайла.
|
|
|
|
|
|
const int totalSteps = int(26.0f / FIXED_DT);
|
|
|
|
|
|
const int advanceSteps = int(16.0f / FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < totalSteps; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
game.in = InputState{};
|
|
|
|
|
|
// Ведём отряд сквозь проход и оставляем работать по северной кучке.
|
|
|
|
|
|
if (i < advanceSteps) game.in.moveScreen = MoveInputToward(game, {31.0f, 9.0f});
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
watch.Observe(game, FIXED_DT);
|
|
|
|
|
|
corridorWatch.Observe(game, FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
// Кто сейчас в створе прохода (горловина + подходы).
|
|
|
|
|
|
int inside[NUM_AGENTS];
|
|
|
|
|
|
int insideCount = 0;
|
|
|
|
|
|
for (int k = 0; k < NUM_AGENTS; ++k)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Vec2 p = game.squad.agents[k].pos;
|
|
|
|
|
|
if (p.y > 9.0f && p.y < 19.0f && p.x > 28.0f && p.x < 34.0f)
|
|
|
|
|
|
inside[insideCount++] = k;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (insideCount < 2) continue;
|
|
|
|
|
|
|
|
|
|
|
|
++samples;
|
|
|
|
|
|
maxInside = std::max(maxInside, insideCount);
|
|
|
|
|
|
|
|
|
|
|
|
float xMin = 1e9f, xMax = -1e9f, yMin = 1e9f, yMax = -1e9f;
|
|
|
|
|
|
float frontY = 1e9f;
|
|
|
|
|
|
for (int n = 0; n < insideCount; ++n)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Vec2 p = game.squad.agents[inside[n]].pos;
|
|
|
|
|
|
xMin = std::min(xMin, p.x); xMax = std::max(xMax, p.x);
|
|
|
|
|
|
yMin = std::min(yMin, p.y); yMax = std::max(yMax, p.y);
|
|
|
|
|
|
frontY = std::min(frontY, p.y);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Колонна = вытянуты вдоль движения сильнее, чем поперёк.
|
|
|
|
|
|
lateralSum += double(xMax - xMin);
|
|
|
|
|
|
longitudinalSum += double(yMax - yMin);
|
|
|
|
|
|
|
|
|
|
|
|
for (int n = 0; n < insideCount; ++n)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Agent& a = game.squad.agents[inside[n]];
|
|
|
|
|
|
if (!a.fired) continue;
|
|
|
|
|
|
const Vec2 tp = (a.currentTargetId >= 0) ? game.targets[a.currentTargetId].pos : Vec2{};
|
|
|
|
|
|
|
|
|
|
|
|
// НАСТОЯЩАЯ проверка правила, независимая от реализации: берём
|
|
|
|
|
|
// отрезок реально сделанного выстрела и заново, своей геометрией,
|
|
|
|
|
|
// смотрим, не задевает ли он тело союзника. Порог физический
|
|
|
|
|
|
// (тело + полуширина трассы), без запаса safetyMargin: выстрел
|
|
|
|
|
|
// впритирку, но мимо тела, правило не нарушает.
|
|
|
|
|
|
const Vec2 muzzle = a.MuzzleTowards(tp);
|
|
|
|
|
|
for (int k = 0; k < NUM_AGENTS; ++k)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (k == inside[n]) continue;
|
|
|
|
|
|
const Agent& ally = game.squad.agents[k];
|
|
|
|
|
|
if (DistancePointToSegment(ally.pos, muzzle, tp) <
|
|
|
|
|
|
ally.bodyRadius + g_tune.laneHalfWidth)
|
|
|
|
|
|
++throughAllyShots;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Разбивка ниже — только для отчёта. Задний, стреляющий ПОД УГЛОМ
|
|
|
|
|
|
// мимо переднего, ничего не нарушает: проход шириной 2 тайла даёт
|
|
|
|
|
|
// законные диагональные линии, и это подтверждается проверкой выше.
|
|
|
|
|
|
const bool forward = Dot(tp - a.pos, game.squad.forward) > 0.0f;
|
|
|
|
|
|
const bool rear = a.pos.y > frontY + 0.5f;
|
|
|
|
|
|
if (!forward) { ++backwardShots; continue; }
|
|
|
|
|
|
if (rear) ++rearShotsInside;
|
|
|
|
|
|
else ++frontShotsInside;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const double lateralAvg = (samples > 0) ? lateralSum / samples : 0.0;
|
|
|
|
|
|
const double longAvg = (samples > 0) ? longitudinalSum / samples : 0.0;
|
|
|
|
|
|
|
|
|
|
|
|
// Что здесь проверяется на самом деле.
|
|
|
|
|
|
//
|
|
|
|
|
|
// «Задние не стреляют» — это СЛЕДСТВИЕ правила «не стрелять сквозь
|
|
|
|
|
|
// своего», а не отдельное правило. В проходе шириной 2 тайла у заднего
|
|
|
|
|
|
// бойца есть законные диагональные линии мимо переднего, и считать их
|
|
|
|
|
|
// нарушением — значит требовать от отряда быть глупее, чем он есть.
|
|
|
|
|
|
// Поэтому считаем не «кто стрелял», а инвариант: НИ ОДИН выстрел в
|
|
|
|
|
|
// проходе не прошёл через тело союзника (перепроверено своей геометрией
|
|
|
|
|
|
// по факту выстрела) и ни одного попадания по своим.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Плюс форма: отряд обязан вытянуться в колонну, а не идти шеренгой.
|
|
|
|
|
|
// Пороги с запасом: строгое неравенство на маленьких числах — монетка,
|
|
|
|
|
|
// а не тест.
|
|
|
|
|
|
const bool column = longAvg > lateralAvg * 1.5;
|
|
|
|
|
|
const bool clean = (throughAllyShots == 0) && (game.metrics.friendlyHits == 0);
|
|
|
|
|
|
const bool enough = (frontShotsInside + rearShotsInside) >= 4;
|
|
|
|
|
|
const bool p6 = (samples > 0) && (maxInside >= 2) && column && clean && enough;
|
|
|
|
|
|
allPass = allPass && p6;
|
|
|
|
|
|
PrintVerdict("11.6 в проходе колонна, огонь не идёт сквозь своих", p6,
|
|
|
|
|
|
Fmt("до %d агентов в проходе, поперёк %.2f / вдоль %.2f тайла, "
|
|
|
|
|
|
"выстрелов вперёд передний/хвост %d/%d (назад %d), "
|
|
|
|
|
|
"сквозь своего %d, FRIENDLY_HITS=%d",
|
|
|
|
|
|
maxInside, lateralAvg, longAvg, frontShotsInside, rearShotsInside,
|
|
|
|
|
|
backwardShots, throughAllyShots, game.metrics.friendlyHits));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 11.7: ручной режим, назначенная цель =================================
|
|
|
|
|
|
{
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.squad.meleeCount = kSpecSquadMelee;
|
|
|
|
|
|
game.squad.pistolCount = kSpecSquadPistol;
|
|
|
|
|
|
game.Init();
|
|
|
|
|
|
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
|
|
|
|
|
game.PlaceSquad({28.0f, 23.5f}, true);
|
|
|
|
|
|
|
|
|
|
|
|
// Включаем ручной режим и наводим вектор прицела на кучку.
|
|
|
|
|
|
InputState toggle;
|
|
|
|
|
|
toggle.toggleAimMode = true;
|
|
|
|
|
|
game.in = toggle;
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
int stepsWithAssigned = 0;
|
|
|
|
|
|
for (int i = 0; i < int(30.0f / FIXED_DT); ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
game.in = InputState{};
|
|
|
|
|
|
// Медленно крутим прицел — Tab каждые 5 секунд.
|
|
|
|
|
|
game.in.aimTurn = (i < int(3.0f / FIXED_DT)) ? 1.0f : 0.0f;
|
|
|
|
|
|
game.in.cycleTarget = (i % int(5.0f / FIXED_DT)) == 0;
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
watch.Observe(game, FIXED_DT);
|
|
|
|
|
|
if (game.aim.assignedTargetId >= 0) ++stepsWithAssigned;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const bool p7 = (game.metrics.friendlyHits == 0) && (stepsWithAssigned > 0);
|
|
|
|
|
|
allPass = allPass && p7;
|
|
|
|
|
|
PrintVerdict("11.7 ручной режим не стреляет сквозь своих", p7,
|
|
|
|
|
|
Fmt("FRIENDLY_HITS=%d, назначенная цель была %.0f%% времени",
|
|
|
|
|
|
game.metrics.friendlyHits,
|
|
|
|
|
|
100.0 * double(stepsWithAssigned) / (30.0 / FIXED_DT)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === M7: ближний бой ======================================================
|
|
|
|
|
|
// Смешанный отряд у кучки из пяти мишеней. Проверяется ровно то, что
|
|
|
|
|
|
// требовалось от механики: клинок доходит и бьёт, никого из своих при этом
|
|
|
|
|
|
// не задевает, и каждый класс живёт в СВОЁМ круге вокруг диска.
|
|
|
|
|
|
{
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.Init(); // состав по умолчанию: 2 клинка
|
|
|
|
|
|
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
|
|
|
|
|
game.PlaceSquad({28.0f, 23.5f}, true);
|
|
|
|
|
|
|
|
|
|
|
|
float maxMeleeFromAnchor = 0.0f;
|
|
|
|
|
|
float maxRangedFromAnchor = 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < int(30.0f / FIXED_DT); ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
game.in = InputState{};
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
watch.Observe(game, FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
for (int k = 0; k < NUM_AGENTS; ++k)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Agent& a = game.squad.agents[k];
|
|
|
|
|
|
const float d = Distance(a.pos, game.squad.anchor);
|
|
|
|
|
|
if (a.IsMelee()) maxMeleeFromAnchor = std::max(maxMeleeFromAnchor, d);
|
|
|
|
|
|
else maxRangedFromAnchor = std::max(maxRangedFromAnchor, d);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const Metrics& m = game.metrics;
|
|
|
|
|
|
// Порог круга с запасом на инерцию: цель может быть у самой границы,
|
|
|
|
|
|
// и боец имеет право дотянуться до неё, а не тормозить по линейке.
|
|
|
|
|
|
const float meleeLimit = g_tune.meleeCohesionRadius + 1.2f;
|
|
|
|
|
|
const float rangedLimit = g_tune.cohesionRadius + 1.6f;
|
|
|
|
|
|
|
|
|
|
|
|
const bool pM1 = (m.meleeHits > 0) && (m.meleeSwings > 0);
|
|
|
|
|
|
const bool pM2 = (m.friendlyHits == 0);
|
|
|
|
|
|
const bool pM3 = (maxMeleeFromAnchor <= meleeLimit) &&
|
|
|
|
|
|
(maxRangedFromAnchor <= rangedLimit) &&
|
|
|
|
|
|
(maxRangedFromAnchor < maxMeleeFromAnchor);
|
|
|
|
|
|
allPass = allPass && pM1 && pM2 && pM3;
|
|
|
|
|
|
|
|
|
|
|
|
PrintVerdict("M7.1 клинок доходит до цели и наносит урон", pM1,
|
|
|
|
|
|
Fmt("взмахов %d, попаданий %d, оборвано своими %d, убито %d; "
|
|
|
|
|
|
"связка %s=%d %s=%d %s=%d",
|
|
|
|
|
|
m.meleeSwings, m.meleeHits, m.meleeAborts, m.targetsKilled,
|
|
|
|
|
|
MELEE_COMBO[0].name, m.meleeComboSwings[0], MELEE_COMBO[1].name,
|
|
|
|
|
|
m.meleeComboSwings[1], MELEE_COMBO[2].name, m.meleeComboSwings[2]));
|
|
|
|
|
|
const bool pM4 = m.meleeMaxHitExcessDeg <= 12.0f;
|
|
|
|
|
|
allPass = allPass && pM4;
|
|
|
|
|
|
PrintVerdict("M7.4 лезвие не достаёт за пределы своей дуги", pM4,
|
|
|
|
|
|
Fmt("максимум за сектором %.1f град (лимит 12)",
|
|
|
|
|
|
double(m.meleeMaxHitExcessDeg)));
|
|
|
|
|
|
PrintVerdict("M7.2 ближний бой не задевает своих", pM2,
|
|
|
|
|
|
Fmt("FRIENDLY_HITS=%d при %d взмахах", m.friendlyHits, m.meleeSwings));
|
|
|
|
|
|
PrintVerdict("M7.3 у каждого класса свой круг вокруг диска", pM3,
|
|
|
|
|
|
Fmt("стрелки до %.2f (лимит %.2f), клинки до %.2f (лимит %.2f)",
|
|
|
|
|
|
double(maxRangedFromAnchor), double(rangedLimit),
|
|
|
|
|
|
double(maxMeleeFromAnchor), double(meleeLimit)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 11.2: три минуты непрерывного боя во всех режимах =====================
|
|
|
|
|
|
{
|
|
|
|
|
|
HarnessOptions opt;
|
|
|
|
|
|
opt.seconds = 180.0f; // состав боевой: смешанный отряд, как в игре
|
|
|
|
|
|
std::printf("--- прогон 180 с по маршруту (критерий 11.2) ---\n");
|
|
|
|
|
|
const int rc = RunHeadless(opt);
|
|
|
|
|
|
const bool p2 = (rc == 0);
|
|
|
|
|
|
allPass = allPass && p2;
|
|
|
|
|
|
PrintVerdict("11.2 FRIENDLY_HITS = 0 за 3 минуты", p2, p2 ? "0 попаданий" : "см. журнал");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 11.4 + 11.5 ==========================================================
|
|
|
|
|
|
// 11.4 меряется на открытой арене: в проходе 2 тайла разойтись физически
|
|
|
|
|
|
// некуда, и это ровно тот геометрический тест-кейс, о котором говорит спек.
|
|
|
|
|
|
const bool p4 = arenaWatch.maxResidence <= 0.4f;
|
|
|
|
|
|
const bool p5 = watch.jitterViolations == 0;
|
|
|
|
|
|
allPass = allPass && p4 && p5;
|
|
|
|
|
|
PrintVerdict("11.4 агент уходит с чужой линии за ~0.3 с", p4,
|
|
|
|
|
|
Fmt("на арене максимум %.2f с (по всем сценариям %.2f с)",
|
|
|
|
|
|
double(arenaWatch.maxResidence), double(watch.maxResidence)));
|
|
|
|
|
|
PrintVerdict("11.5 нет дёрганья postPos чаще commitTime", p5,
|
|
|
|
|
|
Fmt("коммитов %d, нарушений %d, минимальный интервал %.2f с", watch.commits,
|
|
|
|
|
|
watch.jitterViolations,
|
|
|
|
|
|
double(watch.minCommitInterval > 1e8f ? 0.0f : watch.minCommitInterval)));
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nИТОГ: %s\n", allPass ? "все проверяемые критерии PASS" : "есть FAIL");
|
|
|
|
|
|
return allPass ? 0 : 1;
|
|
|
|
|
|
}
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
// Убрать с этажа всех тварей НАСОВСЕМ.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Насовсем — ключевое слово. Просто `alive = false` не работает: у мишени есть
|
|
|
|
|
|
// откат воскрешения, и с нулевым таймером она оживает тем же шагом. Проверки,
|
|
|
|
|
|
// которым нужно спокойное поле (лечение вне схватки, расход света на стоянке),
|
|
|
|
|
|
// на этом молча меряли бой.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Поймано на глубине 30 и только там: на восьмом этаже тварей мало, и
|
|
|
|
|
|
// ближайшая воскресшая случайно оказывалась дальше девяти тайлов. Проверка
|
|
|
|
|
|
// проходила по УДАЧЕ, а не потому, что поле было чистым.
|
|
|
|
|
|
void ClearField(Game& game)
|
|
|
|
|
|
{
|
|
|
|
|
|
for (Target& t : game.targets)
|
|
|
|
|
|
{
|
|
|
|
|
|
t.alive = false;
|
|
|
|
|
|
t.respawnTimer = 1e9f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
// --descend N: забег на N этажей без окна.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Проверяет ровно то, что нельзя проверить ни скриншотом, ни `--check`:
|
|
|
|
|
|
// цепочку «спуск -> пересборка мира -> перенос отряда». Ошибка здесь не
|
|
|
|
|
|
// падает и не рисуется — она выглядит как отряд, приехавший вниз с пустыми
|
|
|
|
|
|
// руками, и заметить её на глаз почти невозможно.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Симуляция не шагает: здесь важна сборка, а не бой.
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Свой профиль в памяти, не с диска: прогон не имеет права трогать чужой
|
|
|
|
|
|
// схрон, а без профиля не проверить ни эвакуацию, ни чекпоинты.
|
|
|
|
|
|
Profile profile;
|
|
|
|
|
|
profile.ResetLoadouts();
|
|
|
|
|
|
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.combatMode = true;
|
|
|
|
|
|
game.rooms = &rooms;
|
|
|
|
|
|
game.catalog = &catalog;
|
|
|
|
|
|
game.profile = &profile;
|
|
|
|
|
|
game.levelSeed = seed;
|
|
|
|
|
|
game.Init();
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("%-6s %-7s %7s %6s %5s %6s %s\n", "DEPTH", "THEME", "MONSTER", "DANGER", "LOOT",
|
|
|
|
|
|
"EXIT", "SQUAD");
|
|
|
|
|
|
|
|
|
|
|
|
int bad = 0;
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
// Средний путь до спуска по всем этажам забега. Нужен свету: сравнивать
|
|
|
|
|
|
// заряд с длиной ОДНОГО этажа — значит мерить не баланс, а везение сида,
|
|
|
|
|
|
// а этажи одной глубины различаются вдвое.
|
|
|
|
|
|
int exitSum = 0;
|
|
|
|
|
|
int exitCount = 0;
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
for (int i = 0; i < floors; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
const GeneratedLevel& l = game.level;
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
if (l.exitDistance > 0) { exitSum += l.exitDistance; ++exitCount; }
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
|
|
|
|
|
|
if (!game.levelGenerated)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf("[FAIL] этаж %d не собрался\n", game.depth);
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Сумки считаются каждый этаж: именно они переезжают вниз, и именно
|
|
|
|
|
|
// их пропажа была бы тихой. Вместе с ними — НАГРУЗКА: без неё не видно,
|
|
|
|
|
|
// связывает вес хоть что-то или он остался числом в каталоге.
|
|
|
|
|
|
const int bags = game.squad.bag.Count();
|
|
|
|
|
|
float maxLoad = 0.0f;
|
|
|
|
|
|
int overloaded = 0;
|
|
|
|
|
|
for (int a = 0; a < NUM_AGENTS; ++a)
|
|
|
|
|
|
{
|
|
|
|
|
|
const float load = game.squad.agents[a].load;
|
|
|
|
|
|
if (load > maxLoad) maxLoad = load;
|
|
|
|
|
|
if (load > CARRY_LIMIT) ++overloaded;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Патроны — главный ресурс вылазки, и в прогоне он обязан стоять
|
|
|
|
|
|
// столбцом: «дошли до восьмого» само по себе не говорит, было ли чем
|
|
|
|
|
|
// стрелять на восьмом.
|
|
|
|
|
|
int ammoLeft = 0;
|
|
|
|
|
|
for (int a = 0; a < NUM_AGENTS; ++a)
|
|
|
|
|
|
ammoLeft += std::max(0, game.squad.agents[a].ammoReserve);
|
|
|
|
|
|
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
// Сколько находок на полу ВЫКАТАНЫ. Отдельно от `--tool drops`: тот
|
|
|
|
|
|
// меряет саму кривую на десятках тысяч бросков, а этот столбец
|
|
|
|
|
|
// показывает, что бросок вообще случился на настоящем этаже — то есть
|
|
|
|
|
|
// что генератор лута и аффиксы соединены.
|
|
|
|
|
|
int rolled = 0;
|
|
|
|
|
|
for (const FloorItem& fi : game.floorItems)
|
|
|
|
|
|
if (fi.id.Count() > 0) ++rolled;
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f"
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
" (перегруз %d), патронов %d, света на %.0f тайлов, с аффиксами %d/%d\n",
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
l.floor.depth, ThemeName(l.floor.theme), l.monstersPlaced,
|
|
|
|
|
|
double(l.dangerSpent), int(l.loot.size()), l.exitDistance,
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft,
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
double(game.squad.lightCharge / std::max(0.01f, LightDrain(l.floor.depth))),
|
|
|
|
|
|
rolled, int(game.floorItems.size()));
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
|
|
|
|
|
|
// Свет обязан ехать вниз вместе с отрядом, как патроны и сумки: заряд,
|
|
|
|
|
|
// обновляющийся на каждом этаже, — это не ресурс вылазки, а украшение
|
|
|
|
|
|
// на этаж. Прогон спуска не шагает симуляцией, поэтому сам заряд тут не
|
|
|
|
|
|
// тратится: срезаем его руками на первом этаже и смотрим внизу.
|
|
|
|
|
|
if (i == 0) game.squad.lightCharge -= 60.0f;
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
|
|
|
|
|
|
if (l.exitDistance <= 0) { std::printf(" [FAIL] спуск совпал со стартом\n"); ++bad; }
|
|
|
|
|
|
if (l.spawnsDropped > 0 || l.spawnsUnreachable > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] срезано %d, недостижимо %d\n", l.spawnsDropped,
|
|
|
|
|
|
l.spawnsUnreachable);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Кладём каждому в сумку по предмету перед спуском: если перенос
|
|
|
|
|
|
// сломан, на следующем этаже сумки окажутся пустыми, и это видно
|
|
|
|
|
|
// в столбце прямо здесь.
|
|
|
|
|
|
// Кладём в ОБЩУЮ сумку по предмету на бойца перед спуском: если
|
|
|
|
|
|
// перенос сломан, на следующем этаже сумка окажется пустой.
|
|
|
|
|
|
for (int a = 0; a < NUM_AGENTS; ++a)
|
|
|
|
|
|
game.squad.bag.Take(ItemId::BANDAGE, game.squad.Carriers(), 0.0f);
|
|
|
|
|
|
|
|
|
|
|
|
if (i + 1 < floors) game.Descend();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Сколько довезли до низа — считается ДО опытов ниже: они меняют содержимое
|
|
|
|
|
|
// сумки, и посчитав после, проверка «доехало ли нажитое» сравнивала бы
|
|
|
|
|
|
// сумку с собственноручно разложенной сумкой, то есть проходила бы всегда.
|
|
|
|
|
|
const int bagsBefore = game.squad.bag.Count();
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Расходники. Проверяется правило, а не побочные цифры прогона: спуск ни с
|
|
|
|
|
|
// кем не воюет, поэтому патроны сами по себе не кончатся, и «столбец не
|
|
|
|
|
|
// изменился» не значит ничего.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Условия ставятся руками: у бойца пусто в стволе и в запасе, в сумке пачка
|
|
|
|
|
|
// патронов и бинт, здоровье срезано, живых тварей рядом нет. Дальше — шаг
|
|
|
|
|
|
// симуляции, и оба расходника обязаны сработать сами.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Agent& a = game.squad.agents[0];
|
|
|
|
|
|
Inventory* inv = game.world.Get<Inventory>(a.self);
|
|
|
|
|
|
SquadBag& bag = game.squad.bag;
|
|
|
|
|
|
|
|
|
|
|
|
// Опыт разбирает сумку под себя, а после него проверяется эвакуация —
|
|
|
|
|
|
// то есть ровно то, что он разобрал. Инструмент обязан вернуть мир
|
|
|
|
|
|
// как было, иначе следующая проверка меряет его следы.
|
|
|
|
|
|
const SquadBag savedBag = bag;
|
|
|
|
|
|
const float savedHp = a.hp;
|
|
|
|
|
|
if (!inv)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf("\n [FAIL] у бойца нет инвентаря — расходники не проверить\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
// Лечиться можно только вне схватки, поэтому поле зачищается.
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
ClearField(game);
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
|
|
|
|
|
|
bag.Clear();
|
|
|
|
|
|
bag.slots[0] = ItemId::AMMO;
|
|
|
|
|
|
bag.slots[1] = ItemId::BANDAGE;
|
|
|
|
|
|
// Ствол в сумке обязателен: патроны нужны тому, у кого есть из чего
|
|
|
|
|
|
// стрелять, а в руках у этого бойца клинок. Без него проверка
|
|
|
|
|
|
// мерила бы не правило, а свою же расчищенную сумку.
|
|
|
|
|
|
bag.slots[2] = ItemId::RIFLE;
|
|
|
|
|
|
|
|
|
|
|
|
a.alive = true;
|
|
|
|
|
|
a.hp = a.maxHp * 0.3f;
|
|
|
|
|
|
a.ammoReserve = 0;
|
|
|
|
|
|
a.gun.ammo = 0;
|
|
|
|
|
|
a.gun.reloadTimer = 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
const float hpBefore = a.hp;
|
|
|
|
|
|
for (int k = 0; k < 8; ++k) game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
const bool loaded = a.ammoReserve >= AMMO_BOX_ROUNDS;
|
|
|
|
|
|
const bool healed = a.hp > hpBefore;
|
|
|
|
|
|
const bool spent = bag.slots[0] == ItemId::NONE && bag.slots[1] == ItemId::NONE;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nрасходники: патроны %s, лечение %s (%.0f -> %.0f), из сумки ушли %s\n",
|
|
|
|
|
|
loaded ? "набиты" : "НЕ набиты", healed ? "сработало" : "НЕ сработало",
|
|
|
|
|
|
double(hpBefore), double(a.hp), spent ? "обе" : "не все");
|
|
|
|
|
|
|
|
|
|
|
|
if (!loaded)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] пачка патронов не пошла в запас — стрелять нечем\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!healed)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] бинт не применился: расходники снова мёртвый груз\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!spent)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] потраченное осталось в сумке\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// И главное правило: перезарядка тянет ИЗ ЗАПАСА. Прогон спуска ни
|
|
|
|
|
|
// с кем не воюет, поэтому столбец «патронов» в таблице выше стоит
|
|
|
|
|
|
// на месте — и без этой проверки бесконечный боезапас выглядел бы
|
|
|
|
|
|
// ровно так же.
|
|
|
|
|
|
EquipWeapon(game.world, a, ItemId::RIFLE);
|
|
|
|
|
|
game.Step(FIXED_DT); // SyncLoadout переносит ствол на бойца
|
|
|
|
|
|
|
|
|
|
|
|
const int reserveBefore = a.ammoReserve;
|
|
|
|
|
|
a.gun.ammo = 0;
|
|
|
|
|
|
for (int k = 0; k < 200 && a.ammoReserve == reserveBefore; ++k) game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
const int spentRounds = reserveBefore - a.ammoReserve;
|
|
|
|
|
|
std::printf("перезарядка: запас %d -> %d (магазин %d)\n", reserveBefore,
|
|
|
|
|
|
a.ammoReserve, a.gun.def.magazine);
|
|
|
|
|
|
if (spentRounds <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] магазин набился ниоткуда — боезапас бесконечен\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Мир возвращается как был: дальше проверяется эвакуация, то есть
|
|
|
|
|
|
// ровно то содержимое сумки, которое опыт только что разобрал.
|
|
|
|
|
|
bag = savedBag;
|
|
|
|
|
|
a.hp = savedHp;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Свет. Проверяется ПРАВИЛО целиком: стоять бесплатно -> идти дорого ->
|
|
|
|
|
|
// погас, и обзор упал -> батарея из сумки встала сама.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Первая проверка тут главная, и она не про баланс, а про жанр: заряд,
|
|
|
|
|
|
// тающий на стоянке, — это таймер, которого в игре нет. Написать такое
|
|
|
|
|
|
// случайно очень легко (одна строка в Step с dt вместо пути), а заметить
|
|
|
|
|
|
// на глаз невозможно: цифра в HUD тает одинаково.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Столбец в таблице выше для этого не годится вовсе: прогон спуска не
|
|
|
|
|
|
// шагает симуляцией, и заряд в нём не тратится совсем.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Мир возвращается как был: дальше проверяется эвакуация, и она обязана
|
|
|
|
|
|
// считать сумку отряда, а не сумку измеряющего.
|
|
|
|
|
|
{
|
|
|
|
|
|
Agent& a = game.squad.agents[0];
|
|
|
|
|
|
const SquadBag savedBag = game.squad.bag;
|
|
|
|
|
|
const float savedLight = game.squad.lightCharge;
|
|
|
|
|
|
const int exitTiles = game.level.exitDistance;
|
|
|
|
|
|
|
|
|
|
|
|
// 0. Доехал ли заряд, срезанный на первом этаже, до низа.
|
|
|
|
|
|
if (game.squad.lightCharge >= LIGHT_FULL)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf("\n [FAIL] заряд обновился при спуске — свет не ресурс вылазки\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. СТОЯТЬ БЕСПЛАТНО. Ввода нет — отряд топчется в строю, и это не
|
|
|
|
|
|
// должно стоить ничего заметного.
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
//
|
|
|
|
|
|
// Поле зачищается насовсем: иначе на глубоких этажах отряд посреди
|
|
|
|
|
|
// «стоянки» отбивается от тварей, бегает и жжёт свет, а проверка
|
|
|
|
|
|
// объявляет это таймером.
|
|
|
|
|
|
ClearField(game);
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
game.in = InputState{};
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
|
|
|
|
|
|
// Отряд сначала ДОХОДИТ до своих мест в строю: после высадки он ещё
|
|
|
|
|
|
// расходится по слотам, и мерить «стоянку» на этом — мерить ходьбу.
|
|
|
|
|
|
for (int k = 0; k < 60; ++k) game.Step(FIXED_DT);
|
|
|
|
|
|
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
const float standBefore = game.squad.lightCharge;
|
|
|
|
|
|
for (int k = 0; k < 120; ++k) game.Step(FIXED_DT);
|
|
|
|
|
|
const float standCost = standBefore - game.squad.lightCharge;
|
|
|
|
|
|
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
// Порог — В ТАЙЛАХ, а не в заряде. Заряд за один и тот же шаг стоит
|
|
|
|
|
|
// тем дороже, чем глубже (LightDrain), и порог в единицах заряда
|
|
|
|
|
|
// объявлял бы таймером обычное топтание в строю на шестидесятом этаже.
|
|
|
|
|
|
const float standTiles = standCost / std::max(0.01f, LightDrain(game.depth));
|
|
|
|
|
|
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
// 2. ИДТИ ДОРОГО, и цена считается за путь. Гоним отряд в одну сторону
|
|
|
|
|
|
// и делим потраченное на пройденное.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Путь копится ПОШАГОВО, а не берётся как расстояние между началом и
|
|
|
|
|
|
// концом. Первая версия делила на смещение и показала 1.55 за тайл
|
|
|
|
|
|
// при 1.10 в каталоге: отряд шёл не по прямой (стена, строй), и
|
|
|
|
|
|
// инструмент мерил цену продвижения, а не цену шага. Число выглядело
|
|
|
|
|
|
// как расхождение с правилом, хотя расходился только измеритель.
|
|
|
|
|
|
// Гоняем по четырём сторонам, а не в одну: отряд стоит там, где его
|
|
|
|
|
|
// высадил спуск, и в любую отдельную сторону может упереться в стену
|
|
|
|
|
|
// через полтора тайла. Тогда вся выборка — это топтание в строю.
|
|
|
|
|
|
const Vec2 from = game.squad.Centroid();
|
|
|
|
|
|
const float walkBefore = game.squad.lightCharge;
|
|
|
|
|
|
|
|
|
|
|
|
float path = 0.0f;
|
|
|
|
|
|
Vec2 prev = from;
|
|
|
|
|
|
const Vec2 dirs[4] = {{1.0f, 0.0f}, {0.0f, 1.0f}, {-1.0f, 0.0f}, {0.0f, -1.0f}};
|
|
|
|
|
|
for (const Vec2& d : dirs)
|
|
|
|
|
|
{
|
|
|
|
|
|
game.in.moveScreen = d;
|
|
|
|
|
|
for (int k = 0; k < 150; ++k)
|
|
|
|
|
|
{
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
const Vec2 now = game.squad.Centroid();
|
|
|
|
|
|
path += Distance(now, prev);
|
|
|
|
|
|
prev = now;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const float walkCost = walkBefore - game.squad.lightCharge;
|
|
|
|
|
|
const float perTile = (path > 0.5f) ? walkCost / path : 0.0f;
|
|
|
|
|
|
const float progress = Distance(game.squad.Centroid(), from);
|
|
|
|
|
|
game.in = InputState{};
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Погас — обзор падает. Меряется на живом бойце, а не по константе:
|
|
|
|
|
|
// константу можно завести и не подключить.
|
|
|
|
|
|
game.squad.bag.Clear();
|
|
|
|
|
|
game.squad.lightCharge = 0.0f;
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
const float sightDark = a.sightMul;
|
|
|
|
|
|
|
|
|
|
|
|
game.squad.lightCharge = LIGHT_FULL;
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
const float sightLit = a.sightMul;
|
|
|
|
|
|
|
|
|
|
|
|
// 4. Батарея из сумки встаёт сама, как бинт.
|
|
|
|
|
|
game.squad.lightCharge = 0.0f;
|
|
|
|
|
|
game.squad.bag.slots[0] = ItemId::BATTERY;
|
|
|
|
|
|
for (int k = 0; k < 8; ++k) game.Step(FIXED_DT);
|
|
|
|
|
|
const bool relit = game.squad.lightCharge > 0.0f;
|
|
|
|
|
|
const bool spentBattery = game.squad.bag.slots[0] == ItemId::NONE;
|
|
|
|
|
|
|
|
|
|
|
|
// Хватает ли заряда на этаж — та самая калибровка, без которой свет
|
|
|
|
|
|
// либо украшение, либо игра в темноте с первого этажа.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Считается в ЭТАЖАХ, а не в тайлах: «заряда на 383 тайла» ни о чём не
|
|
|
|
|
|
// говорит, пока рядом нет длины этажа. И берётся СРЕДНЯЯ длина по
|
|
|
|
|
|
// забегу — на одном этаже путь до спуска скачет вдвое от сида, и
|
|
|
|
|
|
// порог, выставленный по одному, ловил бы удачу, а не баланс.
|
|
|
|
|
|
const float reach = LIGHT_FULL / std::max(0.01f, LightDrain(game.depth));
|
|
|
|
|
|
const float avgExit = (exitCount > 0) ? float(exitSum) / float(exitCount) : 0.0f;
|
|
|
|
|
|
const float floorsPerCharge = (avgExit > 1.0f) ? reach / avgExit : 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nсвет: стоянка %.1f, ход %.2f за тайл при %.2f в каталоге (прошли %.1f,"
|
|
|
|
|
|
" продвинулись %.1f)\n"
|
|
|
|
|
|
" глубина %d: заряда на %.0f тайлов, это %.1f прямых пути до спуска"
|
|
|
|
|
|
" (средний %.0f, здешний %d)\n"
|
|
|
|
|
|
" обзор %.2f -> %.2f в темноте, батарея %s\n",
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
double(standTiles), double(perTile), double(LightDrain(game.depth)),
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
double(path), double(progress),
|
|
|
|
|
|
game.depth, double(reach), double(floorsPerCharge), double(avgExit),
|
|
|
|
|
|
exitTiles,
|
|
|
|
|
|
double(sightLit), double(sightDark),
|
|
|
|
|
|
relit ? (spentBattery ? "встала сама" : "встала, но осталась в сумке")
|
|
|
|
|
|
: "НЕ встала");
|
|
|
|
|
|
|
|
|
|
|
|
// Расход обязан совпасть с числом из каталога. Разойдись он — значит,
|
|
|
|
|
|
// свет тратит что-то ещё, кроме пути, и таймер вернулся боком.
|
|
|
|
|
|
const float expected = LightDrain(game.depth);
|
|
|
|
|
|
if (perTile > 0.0f && std::fabs(perTile - expected) > expected * 0.05f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] расход %.2f за тайл против %.2f в каталоге — свет тратит"
|
|
|
|
|
|
" не только путь\n", double(perTile), double(expected));
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
if (standTiles > 0.05f)
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
{
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
std::printf(" [FAIL] отряд на стоянке прожёг света на %.2f тайла за две секунды —"
|
|
|
|
|
|
" это таймер, а таймера в игре нет\n", double(standTiles));
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (perTile <= 0.0f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] ход не тратит света — фонарь декоративен\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Нижняя граница — по ЗДЕШНЕМУ этажу: на нём отряд стоит сейчас, и не
|
|
|
|
|
|
// дойти до спуска в свету он не должен ни на одном сиде.
|
|
|
|
|
|
if (reach < float(exitTiles))
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] заряда (%.0f тайлов) не хватает даже на дорогу до спуска"
|
|
|
|
|
|
" (%d) — этаж непроходим в свету\n", double(reach), exitTiles);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Верхняя — по среднему. Полная зачистка этажа — это два-три прямых
|
|
|
|
|
|
// пути, поэтому шесть прямых путей на заряд это уже «полтора этажа с
|
|
|
|
|
|
// осмотром», и дальше свет перестаёт быть решением.
|
|
|
|
|
|
if (floorsPerCharge > 6.0f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] заряда хватает на %.1f пути до спуска — свет ничего"
|
|
|
|
|
|
" не связывает\n", double(floorsPerCharge));
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (sightDark >= sightLit * 0.6f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] темнота не режет обзор: %.2f против %.2f\n",
|
|
|
|
|
|
double(sightDark), double(sightLit));
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!relit || !spentBattery)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] батарея не сработала сама — свет чинить нечем\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
game.squad.bag = savedBag;
|
|
|
|
|
|
game.squad.lightCharge = savedLight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Аффиксы. Проверяется ЦЕПОЧКА до бойца, а не сам бросок: бросок меряется
|
|
|
|
|
|
// отдельно и на десятках тысяч образцов (`--tool drops`), а здесь важно
|
|
|
|
|
|
// другое — доходит ли выкатанное число до того, кто дерётся.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Ровно тут ломались все прежние вехи: механика есть, число в каталоге
|
|
|
|
|
|
// есть, а до правил боя оно не доезжает, и снаружи это выглядит как
|
|
|
|
|
|
// работающая система (docs/07-milestones.md).
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Agent& a = game.squad.agents[0];
|
|
|
|
|
|
Inventory* inv = game.world.Get<Inventory>(a.self);
|
|
|
|
|
|
if (!inv)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf("\n [FAIL] у бойца нет инвентаря — аффиксы не проверить\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
const Inventory savedInv = *inv;
|
|
|
|
|
|
|
|
|
|
|
|
// Голое состояние: карабин без рулонов.
|
|
|
|
|
|
EquipWeapon(game.world, a, ItemId::RIFLE);
|
|
|
|
|
|
inv->weapon = ItemInstance(ItemId::RIFLE);
|
|
|
|
|
|
inv->armor = ItemInstance(ItemId::JACKET);
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
const float dmgBare = a.gun.def.damage;
|
|
|
|
|
|
const float hpBare = a.maxHp;
|
|
|
|
|
|
const float armorBare = a.armor;
|
|
|
|
|
|
const float kinBare = a.gun.def.elem.share[int(Element::KIN)];
|
|
|
|
|
|
|
|
|
|
|
|
// Тот же карабин, но с рулонами. Числа выставлены руками: проверка
|
|
|
|
|
|
// про ПРОВОДКУ, и случайный бросок сделал бы её плавающей.
|
|
|
|
|
|
ItemInstance rifle(ItemId::RIFLE);
|
|
|
|
|
|
rifle.rarity = Rarity::PROTOTYPE;
|
|
|
|
|
|
rifle.affix[rifle.count++] = Affix{AffixTag::DAMAGE, 4, 0, 0.50f};
|
|
|
|
|
|
rifle.affix[rifle.count++] = Affix{AffixTag::PEN, 3, 0, 0.20f};
|
|
|
|
|
|
rifle.affix[rifle.count++] = Affix{AffixTag::CONVERT, 5, uint8_t(Element::CRY), 0.40f};
|
|
|
|
|
|
|
|
|
|
|
|
ItemInstance jacket(ItemId::JACKET);
|
|
|
|
|
|
jacket.rarity = Rarity::MARKED;
|
|
|
|
|
|
jacket.affix[jacket.count++] = Affix{AffixTag::HEALTH, 6, 0, 40.0f};
|
|
|
|
|
|
jacket.affix[jacket.count++] = Affix{AffixTag::ARMOR, 4, 0, 0.15f};
|
|
|
|
|
|
|
|
|
|
|
|
inv->weapon = rifle;
|
|
|
|
|
|
inv->armor = jacket;
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
const float dmgRolled = a.gun.def.damage;
|
|
|
|
|
|
const float hpRolled = a.maxHp;
|
|
|
|
|
|
const float armorRolled = a.armor;
|
|
|
|
|
|
const float cryRolled = a.gun.def.elem.share[int(Element::CRY)];
|
|
|
|
|
|
const float kinRolled = a.gun.def.elem.share[int(Element::KIN)];
|
|
|
|
|
|
const float penRolled = a.pen;
|
|
|
|
|
|
|
|
|
|
|
|
// Сумма долей стихий обязана остаться единицей: конверсия ЗАБИРАЕТ
|
|
|
|
|
|
// часть удара, а не добавляет седьмую долю сверху.
|
|
|
|
|
|
float mixSum = 0.0f;
|
|
|
|
|
|
for (int e = 0; e < ELEMENT_COUNT; ++e) mixSum += a.gun.def.elem.share[e];
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nаффиксы: урон %.1f -> %.1f, здоровье %.0f -> %.0f, броня %.2f -> %.2f,"
|
|
|
|
|
|
" пробитие %.2f\n"
|
|
|
|
|
|
" стихии карабина: KIN %.2f -> %.2f, CRY %.2f, сумма долей %.3f\n",
|
|
|
|
|
|
double(dmgBare), double(dmgRolled), double(hpBare), double(hpRolled),
|
|
|
|
|
|
double(armorBare), double(armorRolled), double(penRolled),
|
|
|
|
|
|
double(kinBare), double(kinRolled), double(cryRolled), double(mixSum));
|
|
|
|
|
|
|
|
|
|
|
|
if (dmgRolled <= dmgBare * 1.01f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] аффикс урона не дошёл до оружия\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hpRolled <= hpBare || armorRolled <= armorBare)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] аффиксы брони не дошли до бойца\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (penRolled <= 0.0f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] пробитие не дошло до правил урона\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cryRolled <= 0.0f || kinRolled >= kinBare)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] конверсия не изменила смесь стихий\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mixSum < 0.995f || mixSum > 1.005f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] сумма долей стихий %.3f: конверсия добавила урон вместо"
|
|
|
|
|
|
" того, чтобы его перевести\n", double(mixSum));
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Мир возвращается как был: ниже ещё считается переноска и пишется
|
|
|
|
|
|
// профиль, и делать это на подложенном снаряжении нельзя.
|
|
|
|
|
|
*inv = savedInv;
|
|
|
|
|
|
EquipWeapon(game.world, a, inv->weapon);
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз»
|
|
|
|
|
|
// выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами,
|
|
|
|
|
|
// и по ним вес выглядит втрое меньше настоящего. Мерить правило по такой
|
|
|
|
|
|
// цифре — верный способ выставить пороги, которые ничего не ограничивают.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
// Один боец несёт ствол и общую сумку целиком: доля на одного — это
|
|
|
|
|
|
// худший случай, тот самый, в котором отряд остался вдвоём или один.
|
|
|
|
|
|
const float gun = ItemDefOf(ItemId::RIFLE).weight;
|
|
|
|
|
|
SquadBag probe;
|
|
|
|
|
|
|
|
|
|
|
|
// Три находки среднего веса — отряд, который взял нужное и ушёл.
|
|
|
|
|
|
probe.Take(ItemId::SABER, 1, gun);
|
|
|
|
|
|
probe.Take(ItemId::LANTERN, 1, gun);
|
|
|
|
|
|
probe.Take(ItemId::VEST, 1, gun);
|
|
|
|
|
|
const float light = gun + probe.Load();
|
|
|
|
|
|
|
|
|
|
|
|
// Добираем до отказа: ячейки кончатся или кончится переноска.
|
|
|
|
|
|
int added = 0;
|
|
|
|
|
|
while (probe.Take(ItemId::PLATE, 1, gun)) ++added;
|
|
|
|
|
|
const float heavy = gun + probe.Load();
|
|
|
|
|
|
|
|
|
|
|
|
const bool freeAtLight = CarryMul(light) > 0.999f;
|
|
|
|
|
|
const bool slowAtHeavy = CarryMul(heavy) < 0.95f;
|
|
|
|
|
|
const bool refused = probe.FreeSlot() >= 0; // отказ по ВЕСУ, а не по ячейкам
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nпереноска: налегке %.1f (скорость %.0f%%), с грузом %.1f "
|
|
|
|
|
|
"(скорость %.0f%%), плит влезло %d\n",
|
|
|
|
|
|
double(light), double(CarryMul(light) * 100.0f), double(heavy),
|
|
|
|
|
|
double(CarryMul(heavy) * 100.0f), added);
|
|
|
|
|
|
|
|
|
|
|
|
if (!freeAtLight)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] обычное снаряжение уже тормозит отряд\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!slowAtHeavy)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] полная сумка не стоит ничего — вес не связывает\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!refused)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] предел переноски не сработал: остановили ячейки, не вес\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// Итог забега снимается ЗДЕСЬ, пока он ещё правда. Ниже идут проверки,
|
|
|
|
|
|
// которые начинают новую вылазку (найм, улучшения) и обнуляют счётчик
|
|
|
|
|
|
// этажей: отчитываться цифрой, которую испортил измеряющий, уже приходилось
|
|
|
|
|
|
// дважды — сперва с улучшениями, потом с размером отряда.
|
Материалы и мастерская: вещь можно доработать (M27)
Аффиксы сделали находку решением, но решением одноразовым: выпало как
выпало. Материалы дают второй способ получить нужное — не «повезло», а
«доработал». Пять материалов, пять операций, экран WORKSHOP.
Главное правило экономики — материал добывается ТОЛЬКО В СВОЕЙ ПОЛОСЕ:
био-ядра с плотских этажей, крио с мёрзлых. Это единственная причина
возвращаться на средние глубины, когда рекорд ушёл вниз; без полос весь
контент, кроме последнего акта, умирает в день прохождения.
Материалы — валюта, а не предметы: не занимают ячеек, не весят, в схроне
не лежат. Но теряются как добыча: зачисляются только при эвакуации.
Три уточнения операций, каждое против конкретного обхода: перековка
меняет только значения (иначе это новый бросок предмета в обход
редкости), прививка не ставит уже стоящий тег (иначе «+урон» шесть раз),
возвышается случайный аффикс (иначе все предметы сойдутся к одному).
--tool craft поймал, что материалы бесплатны. С твари падало по её
danger, и вылазка на восьмидесятом этаже приносила тысячу ядер при цене
доводки вещи в шестьсот: 0.6 вылазки на предмет вместо восьми. Правило
заменено на «одно ядро с мелочи, два с крупной» — стало 6.2.
Сам инструмент при этом тоже считал неверно: брал доход из бюджета
опасности этажа, хотя на этаж влезает не больше MAX_LEVEL_SPAWNS тварей.
Бюджет уходит не в количество тварей, а в их силу, и доход упирается в
потолок расстановки с сорокового этажа. Глубина даёт не количество
материалов, а качество предметов — и вместе с ним цену доводки.
Залом замерен на тысяче попыток: рассыпалось 11.6% при заявленных 12%,
плата снимается всегда, слот теряется всегда. «У меня трижды не
рассыпалось» не доказательство ни в какую сторону.
Пункт меню WORKSHOP проверен --ui-check — той самой проверкой, которая
появилась вчера из-за пункта UPGRADES, молча закрывавшего игру.
Формат профиля 5: строка mat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:07:46 +07:00
|
|
|
|
// Материалы, добытые за вылазку, обязаны зачисляться ТОЛЬКО при эвакуации.
|
|
|
|
|
|
// Кладём известные числа перед выходом и сверяем после: это та же ставка,
|
|
|
|
|
|
// что и с добычей, и проверяется она тем же способом.
|
|
|
|
|
|
const int matBefore[MATERIAL_COUNT] = {profile.material[0], profile.material[1],
|
|
|
|
|
|
profile.material[2], profile.material[3],
|
|
|
|
|
|
profile.material[4]};
|
|
|
|
|
|
for (int m = 0; m < MATERIAL_COUNT; ++m) game.runMaterials[m] = 3 + m;
|
|
|
|
|
|
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
const int floorsDone = game.floorsCleared + 1;
|
|
|
|
|
|
const float xpEarned = game.xp;
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
// Эвакуация с последнего этажа. Без этого шага «спуск работает» ничего не
|
|
|
|
|
|
// значит: вся суть экстракшена в том, что происходит на выходе.
|
|
|
|
|
|
game.Extract();
|
|
|
|
|
|
const RunReport& r = game.report;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nэвакуация: вынесено %d, потеряно %d, выживших %d, чекпоинт D%d\n",
|
|
|
|
|
|
r.itemsBrought, r.itemsLost, r.survivors, r.checkpoint);
|
|
|
|
|
|
|
|
|
|
|
|
if (r.outcome != RunOutcome::EXTRACTED)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] исход не эвакуация\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
Материалы и мастерская: вещь можно доработать (M27)
Аффиксы сделали находку решением, но решением одноразовым: выпало как
выпало. Материалы дают второй способ получить нужное — не «повезло», а
«доработал». Пять материалов, пять операций, экран WORKSHOP.
Главное правило экономики — материал добывается ТОЛЬКО В СВОЕЙ ПОЛОСЕ:
био-ядра с плотских этажей, крио с мёрзлых. Это единственная причина
возвращаться на средние глубины, когда рекорд ушёл вниз; без полос весь
контент, кроме последнего акта, умирает в день прохождения.
Материалы — валюта, а не предметы: не занимают ячеек, не весят, в схроне
не лежат. Но теряются как добыча: зачисляются только при эвакуации.
Три уточнения операций, каждое против конкретного обхода: перековка
меняет только значения (иначе это новый бросок предмета в обход
редкости), прививка не ставит уже стоящий тег (иначе «+урон» шесть раз),
возвышается случайный аффикс (иначе все предметы сойдутся к одному).
--tool craft поймал, что материалы бесплатны. С твари падало по её
danger, и вылазка на восьмидесятом этаже приносила тысячу ядер при цене
доводки вещи в шестьсот: 0.6 вылазки на предмет вместо восьми. Правило
заменено на «одно ядро с мелочи, два с крупной» — стало 6.2.
Сам инструмент при этом тоже считал неверно: брал доход из бюджета
опасности этажа, хотя на этаж влезает не больше MAX_LEVEL_SPAWNS тварей.
Бюджет уходит не в количество тварей, а в их силу, и доход упирается в
потолок расстановки с сорокового этажа. Глубина даёт не количество
материалов, а качество предметов — и вместе с ним цену доводки.
Залом замерен на тысяче попыток: рассыпалось 11.6% при заявленных 12%,
плата снимается всегда, слот теряется всегда. «У меня трижды не
рассыпалось» не доказательство ни в какую сторону.
Пункт меню WORKSHOP проверен --ui-check — той самой проверкой, которая
появилась вчера из-за пункта UPGRADES, молча закрывавшего игру.
Формат профиля 5: строка mat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:07:46 +07:00
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
int credited = 0;
|
|
|
|
|
|
for (int m = 0; m < MATERIAL_COUNT; ++m)
|
|
|
|
|
|
if (profile.material[m] == matBefore[m] + 3 + m) ++credited;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("материалы: зачислено видов %d из %d (%d/%d/%d/%d/%d)\n", credited,
|
|
|
|
|
|
MATERIAL_COUNT, profile.material[0], profile.material[1], profile.material[2],
|
|
|
|
|
|
profile.material[3], profile.material[4]);
|
|
|
|
|
|
if (credited != MATERIAL_COUNT)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] добытое за вылазку не доехало до профиля\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
// Вынесено считается по ВСЕМУ снаряжению, а не только по сумкам, поэтому
|
|
|
|
|
|
// проверяем неравенством: меньше содержимого сумок быть не может.
|
|
|
|
|
|
if (r.itemsBrought < bagsBefore)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] в сумках было %d, до профиля доехало %d\n", bagsBefore,
|
|
|
|
|
|
r.itemsBrought);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (profile.maxDepth < game.depth)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] глубина %d не записалась в профиль (%d)\n", game.depth,
|
|
|
|
|
|
profile.maxDepth);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Круговой прогон профиля через ЗАПИСЬ. Здесь он не для полноты: запись
|
|
|
|
|
|
// профиля — единственный путь, которым игра может стереть всё нажитое, и
|
|
|
|
|
|
// ошибка в нём выглядит как «схрон куда-то делся», а не как падение.
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string tmp =
|
|
|
|
|
|
(std::filesystem::temp_directory_path() / "squad_profile_roundtrip.txt").string();
|
|
|
|
|
|
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// Прогон делается на НАБИТОМ профиле, а не на том, что получился сам.
|
|
|
|
|
|
// Пустое поле сходится с пустым при любой поломке записи: улучшения
|
|
|
|
|
|
// стояли в нулях весь прогон, и проверка «0 == 0» три вехи подряд
|
|
|
|
|
|
// подтверждала работу того, чего в файле не было вовсе.
|
|
|
|
|
|
const Profile saved = profile;
|
|
|
|
|
|
for (int u = 0; u < int(Upgrade::COUNT); ++u) profile.upgrade[u] = 1 + u % UPGRADE_MAX_LEVEL;
|
|
|
|
|
|
profile.squadSize = 3;
|
|
|
|
|
|
profile.runs = 11;
|
|
|
|
|
|
profile.extractions = 7;
|
|
|
|
|
|
profile.wipes = 4;
|
|
|
|
|
|
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
// И ВЕЩЬ С АФФИКСАМИ — в снаряжении и в схроне. Без неё круговой прогон
|
|
|
|
|
|
// сверял бы предметы без рулонов, то есть ровно ту часть формата,
|
|
|
|
|
|
// которая работала и раньше.
|
|
|
|
|
|
ItemInstance relic(ItemId::MAGNUM);
|
|
|
|
|
|
relic.rarity = Rarity::RELIC;
|
|
|
|
|
|
relic.affix[relic.count++] = Affix{AffixTag::DAMAGE, 7, 0, 0.6321f};
|
|
|
|
|
|
relic.affix[relic.count++] = Affix{AffixTag::CONVERT, 5, uint8_t(Element::VOID), 0.2718f};
|
|
|
|
|
|
relic.affix[relic.count++] = Affix{AffixTag::AMMO, 3, 0, 24.0f};
|
|
|
|
|
|
profile.loadout[0].weapon = relic;
|
|
|
|
|
|
profile.StashPush(relic);
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
std::string why;
|
|
|
|
|
|
Profile back;
|
|
|
|
|
|
if (!profile.Save(tmp, &why))
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] профиль не пишется: %s\n", why.c_str());
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (!back.Load(tmp, &why))
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] профиль не читается обратно: %s\n", why.c_str());
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// Сверяется ВСЁ, что есть в профиле, а не три поля из десяти.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Прежняя версия проверки смотрела глубину, схрон и снаряжение — и
|
|
|
|
|
|
// ровно поэтому три вехи не замечала, что `Save` не писала ни
|
|
|
|
|
|
// версию, ни улучшения. Купленное действовало до конца запуска и
|
|
|
|
|
|
// молча пропадало, а круговой прогон при этом бодро сходился.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Правило на будущее: круговой прогон обязан сверять поля СПИСКОМ,
|
|
|
|
|
|
// а список — расти вместе с форматом. Выборочная сверка проверяет
|
|
|
|
|
|
// не формат, а память того, кто её писал.
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
int diff = 0;
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
auto same = [&](bool ok, const char* what) {
|
|
|
|
|
|
if (ok) return;
|
|
|
|
|
|
std::printf(" [FAIL] не пережило запись: %s\n", what);
|
|
|
|
|
|
++diff;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
same(back.version == PROFILE_VERSION, "версия формата");
|
|
|
|
|
|
same(back.maxDepth == profile.maxDepth, "глубина");
|
|
|
|
|
|
same(back.squadSize == profile.squadSize, "размер отряда");
|
|
|
|
|
|
same(back.runs == profile.runs, "счётчик вылазок");
|
|
|
|
|
|
same(back.extractions == profile.extractions, "счётчик эвакуаций");
|
|
|
|
|
|
same(back.wipes == profile.wipes, "счётчик вайпов");
|
|
|
|
|
|
same(int(back.bankedXp) == int(profile.bankedXp), "опыт в банке");
|
|
|
|
|
|
same(back.StashCount() == profile.StashCount(), "схрон");
|
|
|
|
|
|
for (int u = 0; u < int(Upgrade::COUNT); ++u)
|
|
|
|
|
|
same(back.upgrade[u] == profile.upgrade[u], UpgradeName(Upgrade(u)));
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
// Предметы сверяются ЦЕЛИКОМ, с рулонами. Сравнение через ItemId
|
|
|
|
|
|
// здесь молчаливо прошло бы: неявная конверсия видит только вид
|
|
|
|
|
|
// вещи, и реликт, доехавший до профиля обычным, выглядел бы как
|
|
|
|
|
|
// совпадение (sim/items.h).
|
|
|
|
|
|
auto sameItem = [&](const ItemInstance& x, const ItemInstance& y, const char* what) {
|
|
|
|
|
|
bool ok = (x.id == y.id) && (x.rarity == y.rarity) && (x.count == y.count);
|
|
|
|
|
|
for (int k = 0; ok && k < x.Count(); ++k)
|
|
|
|
|
|
ok = x.affix[k].tag == y.affix[k].tag && x.affix[k].tier == y.affix[k].tier &&
|
|
|
|
|
|
x.affix[k].param == y.affix[k].param &&
|
|
|
|
|
|
std::fabs(x.affix[k].value - y.affix[k].value) < 0.001f;
|
|
|
|
|
|
same(ok, what);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
for (int a = 0; a < NUM_AGENTS; ++a)
|
|
|
|
|
|
{
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
sameItem(profile.loadout[a].weapon, back.loadout[a].weapon, "оружие бойца");
|
|
|
|
|
|
sameItem(profile.loadout[a].armor, back.loadout[a].armor, "броня бойца");
|
|
|
|
|
|
sameItem(profile.loadout[a].trinket, back.loadout[a].trinket, "карман бойца");
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
}
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
for (int k = 0; k < profile.StashCount() && k < back.StashCount(); ++k)
|
|
|
|
|
|
sameItem(profile.stash[size_t(k)], back.stash[size_t(k)], "вещь из схрона");
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; }
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
else std::printf("профиль: запись и чтение сходятся по всем полям\n");
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// Профиль возвращается как был: дальше он ещё нужен настоящим.
|
|
|
|
|
|
profile = saved;
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
std::filesystem::remove(tmp, ec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 01:16:08 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Версия профиля. Формат ломался дважды за три вехи, и оба раза старый файл
|
|
|
|
|
|
// читался молча и частично. Проверяются оба направления: старый читается с
|
|
|
|
|
|
// миграцией, файл из будущего НЕ читается вовсе.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::filesystem::path old = std::filesystem::temp_directory_path() / "t2d_v1.txt";
|
|
|
|
|
|
{
|
|
|
|
|
|
std::ofstream f(old);
|
|
|
|
|
|
// Формат 1: версии нет, у бойца шесть посумочных лексем.
|
|
|
|
|
|
f << "maxdepth 7\nruns 3\nxp 4200\n";
|
|
|
|
|
|
f << "agent 0 rifle vest charm bandage stimpack - - - -\n";
|
|
|
|
|
|
f << "stash saber plate\n";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Profile v1;
|
|
|
|
|
|
std::string why;
|
|
|
|
|
|
const bool readOld = v1.Load(old.string(), &why);
|
|
|
|
|
|
|
|
|
|
|
|
const std::filesystem::path future =
|
|
|
|
|
|
std::filesystem::temp_directory_path() / "t2d_future.txt";
|
|
|
|
|
|
{
|
|
|
|
|
|
std::ofstream f(future);
|
|
|
|
|
|
f << "version " << (PROFILE_VERSION + 1) << "\nmaxdepth 99\n";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Profile vNext;
|
|
|
|
|
|
std::string whyFuture;
|
|
|
|
|
|
const bool readFuture = vNext.Load(future.string(), &whyFuture);
|
|
|
|
|
|
|
|
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
std::filesystem::remove(old, ec);
|
|
|
|
|
|
std::filesystem::remove(future, ec);
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nпрофиль: старый формат %s (глубина %d, схрон %d), из будущего %s\n",
|
|
|
|
|
|
readOld ? "прочитан" : "НЕ прочитан", v1.maxDepth, v1.StashCount(),
|
|
|
|
|
|
readFuture ? "ПРОЧИТАН" : "отвергнут");
|
|
|
|
|
|
|
|
|
|
|
|
if (!readOld || v1.maxDepth != 7 || v1.StashCount() != 2)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] миграция со старого формата потеряла данные: %s\n", why.c_str());
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (readFuture)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] профиль новее игры прочитан — запись поверх сотрёт чужое\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// Старый профиль играл впятером, и строки squad в нём нет. Дать такому
|
|
|
|
|
|
// игроку одного оперативника значило бы отобрать четверых, которых он
|
|
|
|
|
|
// не терял, — а выглядело бы это как «игра съела отряд».
|
|
|
|
|
|
if (readOld && v1.squadSize != NUM_AGENTS)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] миграция отобрала бойцов: %d вместо %d\n", v1.squadSize,
|
|
|
|
|
|
NUM_AGENTS);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
2026-08-14 01:16:08 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
Триггеры: четыре условных эффекта на аффиксах (M28)
ON_HIT поджигает цель, ON_KILL даёт рывок скорости стаками, ON_HURT
обжигает того, кто ударил, ON_DOWN даёт отряду ярость за павшего.
Отдельным тегом аффикса, то есть по тем же правилам, что и всё
остальное: платит по тиру, влезает в бюджет, катается и прививается.
СДЕЛАНО НЕ ТАК, КАК ПРЕДЛАГАЛ ДИЗАЙН. В docs/21-damage.md §4 стояло:
журнал боевых событий уже есть, вешайте триггеры на него. Звучит
правильно, и сделать так нельзя: sim/events.h это канал «симуляция ->
эффекты», односторонний по устройству, и в headless-прогонах его нет
вовсе. Начни правила боя его читать — игра станет вести себя по-разному
с окном и без, то есть --accept начнёт мерить не то, что меряет.
Триггеры срабатывают в месте урона. Опасение «два канала разойдутся»
снято иначе: второго канала никто и не завёл, есть четыре точки рядом с
тем уроном, который триггер обслуживает.
Четыре из девяти, и это не «пока»: каждый эффект обязан класть своё
число в уже существующий канал влияния. Крита в игре нет, площадного
урона нет, выдачи предметов на входе нет — заводить их ради строк
таблицы значит завести пять мёртвых веток. Причины выписаны в документе.
Три ограничителя проверяются числами в --descend:
триггеры: аффикс доехал да, ON_HIT поджёг стаков 1, откат 0.9 с
(за него наложено 0), ON_KILL стаков 1, скорость 1.06
рекурсия: за один шаг стаков прибавлялось максимум 1
Первая строка про проводку: аффикс с триггером легко сделать так, что он
лежит в предмете, показывается в подсказке и не делает ничего.
Проверка сперва брала targets[0] и проходила на descent по везению: на
hollow первым в пуле стоял ящик, а обстановка не горит и убийством не
считается. Теперь цель ищется по виду и ставится в свободную клетку с
прямой видимостью — иначе замеряется планировка этажа, а не триггеры.
Шаг симуляции 3.7 мкс из 16666. --accept PASS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:32:12 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Триггеры. Проверяется то же, что у реакций: не «срабатывает ли», а ТРИ
|
|
|
|
|
|
// ОГРАНИЧИТЕЛЯ, без которых система условных эффектов вешает игру
|
|
|
|
|
|
// (docs/21-damage.md §4.1).
|
|
|
|
|
|
//
|
|
|
|
|
|
// Отдельно — что триггер вообще доезжает до боя. Аффикс с триггером легко
|
|
|
|
|
|
// сделать так, что он лежит в предмете, показывается в подсказке и не
|
|
|
|
|
|
// делает ничего: ровно так дважды умирали вес и стихии.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Agent& a = game.squad.agents[0];
|
|
|
|
|
|
Inventory* inv = game.world.Get<Inventory>(a.self);
|
|
|
|
|
|
if (!inv)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf("\n [FAIL] у бойца нет инвентаря — триггеры не проверить\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
const Inventory savedInv = *inv;
|
|
|
|
|
|
const SquadBag savedBag = game.squad.bag;
|
|
|
|
|
|
|
|
|
|
|
|
// Оружие с гарантированным ON_HIT: шанс единица, чтобы мерить
|
|
|
|
|
|
// ПРОВОДКУ, а не везение.
|
|
|
|
|
|
ItemInstance rifle(ItemId::RIFLE);
|
|
|
|
|
|
rifle.rarity = Rarity::RELIC;
|
|
|
|
|
|
rifle.affix[rifle.count++] =
|
|
|
|
|
|
Affix{AffixTag::TRIGGER, 5, uint8_t(TriggerKind::ON_HIT), 1.0f};
|
|
|
|
|
|
rifle.affix[rifle.count++] =
|
|
|
|
|
|
Affix{AffixTag::TRIGGER, 5, uint8_t(TriggerKind::ON_KILL), 1.0f};
|
|
|
|
|
|
*inv = Inventory{};
|
|
|
|
|
|
inv->weapon = rifle;
|
|
|
|
|
|
EquipWeapon(game.world, a, inv->weapon);
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
const bool wired = a.mods.trigger[int(TriggerKind::ON_HIT)] > 0.0f &&
|
|
|
|
|
|
a.mods.trigger[int(TriggerKind::ON_KILL)] > 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
// 1. ON_HIT доезжает до цели: ставим тварь под ствол и смотрим,
|
|
|
|
|
|
// появились ли на ней стаки жара. Оружие кинетическое, поэтому
|
|
|
|
|
|
// жар взяться больше неоткуда.
|
|
|
|
|
|
ClearField(game);
|
|
|
|
|
|
|
|
|
|
|
|
// Цель ищется НЕ по нулевому индексу: первым в пуле может стоять
|
|
|
|
|
|
// ящик, а обстановка не горит и убийством не считается. Первая
|
|
|
|
|
|
// версия проверки брала targets[0] и проходила на descent просто
|
|
|
|
|
|
// потому, что там повезло, — на hollow она падала.
|
|
|
|
|
|
int victimIndex = -1;
|
|
|
|
|
|
for (int t = 0; t < int(game.targets.size()); ++t)
|
|
|
|
|
|
if (!game.targets[size_t(t)].IsProp() && !game.targets[size_t(t)].IsDummy())
|
|
|
|
|
|
{
|
|
|
|
|
|
victimIndex = t;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (victimIndex < 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf("\n [FAIL] на этаже нет ни одной твари — триггеры не проверить\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
victimIndex = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Target& victim = game.targets[size_t(victimIndex)];
|
|
|
|
|
|
victim.alive = true;
|
|
|
|
|
|
victim.respawnTimer = 0.0f;
|
|
|
|
|
|
victim.hp = 100000.0f; // чтобы не умерла раньше времени
|
|
|
|
|
|
victim.status.Clear();
|
|
|
|
|
|
|
|
|
|
|
|
// Ставим цель в СВОБОДНУЮ клетку рядом со стрелком и с прямой
|
|
|
|
|
|
// видимостью: иначе пуля уходит в стену, и проверка меряет
|
|
|
|
|
|
// планировку этажа, а не триггеры.
|
|
|
|
|
|
const Vec2 dirs[8] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1},
|
|
|
|
|
|
{1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
|
|
|
|
|
|
victim.pos = a.pos + Vec2{1.5f, 0.0f};
|
|
|
|
|
|
for (const Vec2& d : dirs)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Vec2 spot = a.pos + d * 2.0f;
|
|
|
|
|
|
if (game.map.IsWallAt(spot)) continue;
|
|
|
|
|
|
if (game.map.RaycastBlocked(a.pos, spot)) continue;
|
|
|
|
|
|
victim.pos = spot;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
a.trig.icd[int(TriggerKind::ON_HIT)] = 0.0f;
|
|
|
|
|
|
for (int k = 0; k < 240 && victim.status.stacks[int(Element::INC)] == 0; ++k)
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
const int burned = victim.status.stacks[int(Element::INC)];
|
|
|
|
|
|
|
|
|
|
|
|
// 2. ОТКАТ. Сразу после срабатывания триггер обязан молчать: гасим
|
|
|
|
|
|
// стаки и проверяем, что за полсекунды они не вернулись.
|
|
|
|
|
|
victim.status.Clear();
|
|
|
|
|
|
const float icdLeft = a.trig.icd[int(TriggerKind::ON_HIT)];
|
|
|
|
|
|
for (int k = 0; k < 24; ++k) game.Step(FIXED_DT);
|
|
|
|
|
|
const int duringIcd = victim.status.stacks[int(Element::INC)];
|
|
|
|
|
|
|
|
|
|
|
|
// 3. ON_KILL: добиваем тварь и смотрим стаки рывка.
|
|
|
|
|
|
a.trig.killStacks = 0;
|
|
|
|
|
|
victim.hp = 1.0f;
|
|
|
|
|
|
for (int k = 0; k < 240 && a.trig.killStacks == 0; ++k) game.Step(FIXED_DT);
|
|
|
|
|
|
const int rush = a.trig.killStacks;
|
|
|
|
|
|
|
|
|
|
|
|
// Ещё один шаг ПЕРЕД замером скорости: стак прибавляется в момент
|
|
|
|
|
|
// смерти цели, а статы бойца пересчитываются в начале шага
|
|
|
|
|
|
// (SyncLoadout). Читать скорость сразу — значит читать её до того,
|
|
|
|
|
|
// как рывок вообще применился. Один кадр задержки в игре ни на что
|
|
|
|
|
|
// не влияет, а проверку сбивал.
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
const float speedWithRush = a.speedMul;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nтриггеры: аффикс доехал %s, ON_HIT поджёг стаков %d, откат %.1f с"
|
|
|
|
|
|
" (за него наложено %d), ON_KILL стаков %d, скорость %.2f\n",
|
|
|
|
|
|
wired ? "да" : "НЕТ", burned, double(icdLeft), duringIcd, rush,
|
|
|
|
|
|
double(speedWithRush));
|
|
|
|
|
|
|
|
|
|
|
|
if (!wired)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] триггер с предмета не дошёл до бойца\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (burned <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] ON_HIT не поджёг цель — эффект не доезжает до боя\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (duringIcd > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] ON_HIT сработал внутри отката: ограничителя нет\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (rush <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] ON_KILL не дал рывка\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (rush > 0 && speedWithRush <= 1.0f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] стаки рывка есть, а скорость прежняя\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. ЗАПРЕТ РЕКУРСИИ. Поджиг от триггера сам урона не наносит и
|
|
|
|
|
|
// второй раз триггер не запускает, поэтому за один шаг стаков
|
|
|
|
|
|
// больше одного не появляется. Проверяем в лоб: гасим статусы,
|
|
|
|
|
|
// сбрасываем откат и делаем РОВНО ОДИН шаг с попаданием.
|
|
|
|
|
|
victim.status.Clear();
|
|
|
|
|
|
victim.hp = 100000.0f;
|
|
|
|
|
|
victim.alive = true;
|
|
|
|
|
|
a.trig.icd[int(TriggerKind::ON_HIT)] = 0.0f;
|
|
|
|
|
|
int perStepMax = 0;
|
|
|
|
|
|
for (int k = 0; k < 240; ++k)
|
|
|
|
|
|
{
|
|
|
|
|
|
const int before = victim.status.stacks[int(Element::INC)];
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
const int after = victim.status.stacks[int(Element::INC)];
|
|
|
|
|
|
perStepMax = std::max(perStepMax, after - before);
|
|
|
|
|
|
}
|
|
|
|
|
|
std::printf("рекурсия: за один шаг стаков прибавлялось максимум %d\n", perStepMax);
|
|
|
|
|
|
if (perStepMax > 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] триггер запускает сам себя: %d стаков за шаг\n", perStepMax);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
*inv = savedInv;
|
|
|
|
|
|
EquipWeapon(game.world, a, inv->weapon);
|
|
|
|
|
|
game.squad.bag = savedBag;
|
|
|
|
|
|
game.squad.rageTimer = 0.0f;
|
|
|
|
|
|
a.trig = TriggerState{};
|
|
|
|
|
|
a.trig.rng = 1u;
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Личинки, парирование и инструмент --wall (M30)
--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами
через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке.
Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление
тварей и живёт полторы минуты без управления. Первая стена: одиночка D7,
трое D30, пятеро D30 - петля работает.
И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в
том числе незанятые. Строка писалась, когда отряд всегда был впятером;
после появления ростера одиночка выходил на этаж с четырьмя невидимыми
телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже,
100 -> 41" был боем впятером.
ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом
проигрывает двум бредущим: 210 здоровья против ножа это девять секунд
работы под ответным уроном. Первые этажи населены личинками (55 здоровья,
7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый.
Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре
меняет, кто выходит, а не числа у одного и того же.
ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она
выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её
двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка.
Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в
замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь.
Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает
повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть
размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары.
Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это
его удар, просто по летящей цели.
Проверка меряет размен целиком, включая обратную половину правила:
"стоя столбом не отбивает". Без неё условие про соприкосновение
существовало бы только в комментарии.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:04:35 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Парирование. Проверяется РАЗМЕН, а не факт: удался отбив — урона нет,
|
|
|
|
|
|
// тварь получила ответ, и боец ударил заново, не сбросив откат.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Условие «удары соприкоснулись» проверяется отдельно и с двух сторон:
|
|
|
|
|
|
// в замахе отбить МОЖНО, стоя столбом — НЕЛЬЗЯ. Без второй половины клинок
|
|
|
|
|
|
// превратился бы в щит, который работает, пока стоишь.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Agent& a = game.squad.agents[0];
|
|
|
|
|
|
Inventory* inv = game.world.Get<Inventory>(a.self);
|
|
|
|
|
|
if (!inv)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf("\n [FAIL] у бойца нет инвентаря — парирование не проверить\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
const Inventory savedInv = *inv;
|
|
|
|
|
|
|
|
|
|
|
|
// Нож: самое лёгкое оружие, у него шанс отбива выше всех.
|
|
|
|
|
|
EquipWeapon(game.world, a, ItemId::KNIFE);
|
|
|
|
|
|
inv->weapon = ItemInstance(ItemId::KNIFE);
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
const float knifeParry = a.melee.parry;
|
|
|
|
|
|
|
|
|
|
|
|
EquipWeapon(game.world, a, ItemId::CLEAVER);
|
|
|
|
|
|
inv->weapon = ItemInstance(ItemId::CLEAVER);
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
const float cleaverParry = a.melee.parry;
|
|
|
|
|
|
|
|
|
|
|
|
// Возвращаем нож и ставим гарантированный отбив: проверяется
|
|
|
|
|
|
// ПРОВОДКА размена, а не везение.
|
|
|
|
|
|
EquipWeapon(game.world, a, ItemId::KNIFE);
|
|
|
|
|
|
inv->weapon = ItemInstance(ItemId::KNIFE);
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
ClearField(game);
|
|
|
|
|
|
int foeIndex = -1;
|
|
|
|
|
|
for (int t = 0; t < int(game.targets.size()); ++t)
|
|
|
|
|
|
if (!game.targets[size_t(t)].IsProp() && !game.targets[size_t(t)].IsDummy())
|
|
|
|
|
|
{
|
|
|
|
|
|
foeIndex = t;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool parried = false, riposted = false, cooldownKept = false, standBlocked = true;
|
|
|
|
|
|
if (foeIndex >= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
Target& foe = game.targets[size_t(foeIndex)];
|
|
|
|
|
|
foe.alive = true;
|
|
|
|
|
|
foe.respawnTimer = 0.0f;
|
|
|
|
|
|
foe.hp = 100000.0f;
|
|
|
|
|
|
foe.pos = a.pos + Vec2{0.8f, 0.0f};
|
|
|
|
|
|
|
|
|
|
|
|
a.hp = a.maxHp;
|
|
|
|
|
|
a.melee.parry = 1.0f; // отбив гарантирован
|
|
|
|
|
|
a.melee.state = MeleeState::STRIKE; // удары «соприкоснулись»
|
|
|
|
|
|
a.melee.phase = a.melee.strike;
|
|
|
|
|
|
a.melee.cooldown = 0.42f;
|
|
|
|
|
|
|
|
|
|
|
|
const float hpBefore = a.hp;
|
|
|
|
|
|
const float foeBefore = foe.hp;
|
|
|
|
|
|
const float cdBefore = a.melee.cooldown;
|
|
|
|
|
|
|
|
|
|
|
|
// Бьём по бойцу напрямую тем же путём, каким бьёт тварь.
|
|
|
|
|
|
HurtAgentForTest(a, 0, foe, foeIndex, game.targets, 40.0f);
|
|
|
|
|
|
|
|
|
|
|
|
parried = a.hp >= hpBefore;
|
|
|
|
|
|
riposted = foe.hp < foeBefore;
|
|
|
|
|
|
cooldownKept = std::fabs(a.melee.cooldown - cdBefore) < 0.001f;
|
|
|
|
|
|
|
|
|
|
|
|
// А теперь то же самое, но боец НЕ в ударе.
|
|
|
|
|
|
a.melee.state = MeleeState::IDLE;
|
|
|
|
|
|
a.melee.phase = 0.0f;
|
|
|
|
|
|
const float hpIdle = a.hp;
|
|
|
|
|
|
HurtAgentForTest(a, 0, foe, foeIndex, game.targets, 40.0f);
|
|
|
|
|
|
standBlocked = a.hp < hpIdle; // урон обязан пройти
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nпарирование: нож %.0f%%, тесак %.0f%%; отбив %s, ответ %s,"
|
|
|
|
|
|
" откат %s, стоя столбом %s\n",
|
|
|
|
|
|
double(knifeParry * 100.0f), double(cleaverParry * 100.0f),
|
|
|
|
|
|
parried ? "снял урон" : "НЕ СРАБОТАЛ",
|
|
|
|
|
|
riposted ? "прошёл" : "НЕ ПРОШЁЛ",
|
|
|
|
|
|
cooldownKept ? "не сброшен" : "СБРОШЕН",
|
|
|
|
|
|
standBlocked ? "не отбивает" : "ОТБИВАЕТ");
|
|
|
|
|
|
|
|
|
|
|
|
if (knifeParry <= cleaverParry)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] тесак парирует не хуже ножа — вес ни на что не влияет\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!parried || !riposted)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] размен не состоялся: урон %s, ответ %s\n",
|
|
|
|
|
|
parried ? "снят" : "прошёл", riposted ? "есть" : "нет");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!cooldownKept)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] повторный удар сбросил откат — это не размен,"
|
|
|
|
|
|
" а бесплатная атака\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!standBlocked)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] отбивает, стоя столбом: клинок стал щитом\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
*inv = savedInv;
|
|
|
|
|
|
EquipWeapon(game.world, a, inv->weapon);
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Материалы и мастерская: вещь можно доработать (M27)
Аффиксы сделали находку решением, но решением одноразовым: выпало как
выпало. Материалы дают второй способ получить нужное — не «повезло», а
«доработал». Пять материалов, пять операций, экран WORKSHOP.
Главное правило экономики — материал добывается ТОЛЬКО В СВОЕЙ ПОЛОСЕ:
био-ядра с плотских этажей, крио с мёрзлых. Это единственная причина
возвращаться на средние глубины, когда рекорд ушёл вниз; без полос весь
контент, кроме последнего акта, умирает в день прохождения.
Материалы — валюта, а не предметы: не занимают ячеек, не весят, в схроне
не лежат. Но теряются как добыча: зачисляются только при эвакуации.
Три уточнения операций, каждое против конкретного обхода: перековка
меняет только значения (иначе это новый бросок предмета в обход
редкости), прививка не ставит уже стоящий тег (иначе «+урон» шесть раз),
возвышается случайный аффикс (иначе все предметы сойдутся к одному).
--tool craft поймал, что материалы бесплатны. С твари падало по её
danger, и вылазка на восьмидесятом этаже приносила тысячу ядер при цене
доводки вещи в шестьсот: 0.6 вылазки на предмет вместо восьми. Правило
заменено на «одно ядро с мелочи, два с крупной» — стало 6.2.
Сам инструмент при этом тоже считал неверно: брал доход из бюджета
опасности этажа, хотя на этаж влезает не больше MAX_LEVEL_SPAWNS тварей.
Бюджет уходит не в количество тварей, а в их силу, и доход упирается в
потолок расстановки с сорокового этажа. Глубина даёт не количество
материалов, а качество предметов — и вместе с ним цену доводки.
Залом замерен на тысяче попыток: рассыпалось 11.6% при заявленных 12%,
плата снимается всегда, слот теряется всегда. «У меня трижды не
рассыпалось» не доказательство ни в какую сторону.
Пункт меню WORKSHOP проверен --ui-check — той самой проверкой, которая
появилась вчера из-за пункта UPGRADES, молча закрывавшего игру.
Формат профиля 5: строка mat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:07:46 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Мастерская. Проверяются ПРАВИЛА операций, а не то, что они «есть»:
|
|
|
|
|
|
// каждая обязана менять предмет ровно так, как обещает подпись под ней, и
|
|
|
|
|
|
// списывать за это материалы.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Отдельно и главное — залом. Единственная операция с риском, и риск обязан
|
|
|
|
|
|
// быть настоящим: и вещь теряется, и плата снимается. Меряется он на тысяче
|
|
|
|
|
|
// попыток, потому что «у меня трижды не рассыпалось» — не доказательство.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile& pr = *game.profile;
|
|
|
|
|
|
const int savedMat[MATERIAL_COUNT] = {pr.material[0], pr.material[1], pr.material[2],
|
|
|
|
|
|
pr.material[3], pr.material[4]};
|
|
|
|
|
|
|
|
|
|
|
|
// Заведомо хватает на всё: проверяются правила, а не бедность.
|
|
|
|
|
|
for (int m = 0; m < MATERIAL_COUNT; ++m) pr.material[m] = 100000;
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t rng = 12345u;
|
|
|
|
|
|
ItemInstance it(ItemId::RIFLE);
|
|
|
|
|
|
it.rarity = Rarity::PROTOTYPE;
|
|
|
|
|
|
it.affix[it.count++] = Affix{AffixTag::DAMAGE, 3, 0, 0.30f};
|
|
|
|
|
|
it.affix[it.count++] = Affix{AffixTag::PEN, 2, 0, 0.10f};
|
|
|
|
|
|
it.affix[it.count++] = Affix{AffixTag::SPEED, 2, 0, 0.08f};
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Перековка: значения меняются, теги и тиры — нет.
|
|
|
|
|
|
const ItemInstance before = it;
|
|
|
|
|
|
const int scrapBefore = pr.material[int(MaterialKind::SCRAP)];
|
|
|
|
|
|
const CraftResult rr = ApplyCraft(CraftOp::REFORGE, it, pr.material, 40, rng);
|
|
|
|
|
|
bool tagsKept = it.Count() == before.Count();
|
|
|
|
|
|
bool valueMoved = false;
|
|
|
|
|
|
for (int i = 0; i < it.Count() && tagsKept; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
tagsKept = it.affix[i].tag == before.affix[i].tag &&
|
|
|
|
|
|
it.affix[i].tier == before.affix[i].tier;
|
|
|
|
|
|
if (std::fabs(it.affix[i].value - before.affix[i].value) > 1e-6f) valueMoved = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
const bool paid = pr.material[int(MaterialKind::SCRAP)] < scrapBefore;
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Прививка: аффиксов стало больше, тег новый.
|
|
|
|
|
|
const int countBeforeGraft = it.Count();
|
|
|
|
|
|
const CraftResult gr = ApplyCraft(CraftOp::GRAFT, it, pr.material, 40, rng);
|
|
|
|
|
|
bool grafted = it.Count() == countBeforeGraft + 1;
|
|
|
|
|
|
if (grafted)
|
|
|
|
|
|
{
|
|
|
|
|
|
const AffixTag fresh = it.affix[it.Count() - 1].tag;
|
|
|
|
|
|
for (int i = 0; i < it.Count() - 1; ++i)
|
|
|
|
|
|
if (it.affix[i].tag == fresh) grafted = false; // тег повторился
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Отсечение: аффиксов стало меньше.
|
|
|
|
|
|
const int countBeforeExcise = it.Count();
|
|
|
|
|
|
const CraftResult er = ApplyCraft(CraftOp::EXCISE, it, pr.material, 40, rng);
|
|
|
|
|
|
const bool excised = it.Count() == countBeforeExcise - 1;
|
|
|
|
|
|
|
|
|
|
|
|
// 4. Возвышение: у одного аффикса тир вырос ровно на единицу.
|
|
|
|
|
|
int tierSumBefore = 0;
|
|
|
|
|
|
for (int i = 0; i < it.Count(); ++i) tierSumBefore += it.affix[i].tier;
|
|
|
|
|
|
const CraftResult xr = ApplyCraft(CraftOp::EXALT, it, pr.material, 40, rng);
|
|
|
|
|
|
int tierSumAfter = 0;
|
|
|
|
|
|
for (int i = 0; i < it.Count(); ++i) tierSumAfter += it.affix[i].tier;
|
|
|
|
|
|
const bool exalted = tierSumAfter == tierSumBefore + 1;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nмастерская: перековка %s, прививка %s, отсечение %s, возвышение %s\n",
|
|
|
|
|
|
(rr == CraftResult::OK && tagsKept && valueMoved && paid) ? "ok" : "ПОЛОМКА",
|
|
|
|
|
|
(gr == CraftResult::OK && grafted) ? "ok" : "ПОЛОМКА",
|
|
|
|
|
|
(er == CraftResult::OK && excised) ? "ok" : "ПОЛОМКА",
|
|
|
|
|
|
(xr == CraftResult::OK && exalted) ? "ok" : "ПОЛОМКА");
|
|
|
|
|
|
|
|
|
|
|
|
if (rr != CraftResult::OK || !tagsKept || !valueMoved || !paid)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] перековка: результат %d, теги %s, значения %s, плата %s\n",
|
|
|
|
|
|
int(rr), tagsKept ? "целы" : "СБИТЫ", valueMoved ? "новые" : "ТЕ ЖЕ",
|
|
|
|
|
|
paid ? "снята" : "НЕ СНЯТА");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (gr != CraftResult::OK || !grafted)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] прививка не добавила нового тега\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (er != CraftResult::OK || !excised)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] отсечение не убрало аффикс\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (xr != CraftResult::OK || !exalted)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] возвышение не подняло тир\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5. Залом на тысяче попыток: доля потерь, плата за неудачу и размен.
|
|
|
|
|
|
int destroyed = 0;
|
|
|
|
|
|
int paidTimes = 0;
|
|
|
|
|
|
int slotsLost = 0;
|
|
|
|
|
|
for (int k = 0; k < 1000; ++k)
|
|
|
|
|
|
{
|
|
|
|
|
|
ItemInstance probe(ItemId::VEST);
|
|
|
|
|
|
probe.rarity = Rarity::RELIC;
|
|
|
|
|
|
for (int a = 0; a < 5; ++a)
|
|
|
|
|
|
probe.affix[probe.count++] = Affix{AffixTag::HEALTH, 2, 0, 10.0f};
|
|
|
|
|
|
|
|
|
|
|
|
const int slagBefore = pr.material[int(MaterialKind::SLAG)];
|
|
|
|
|
|
const int countBefore = probe.Count();
|
|
|
|
|
|
const CraftResult fr = ApplyCraft(CraftOp::FRACTURE, probe, pr.material, 40, rng);
|
|
|
|
|
|
if (pr.material[int(MaterialKind::SLAG)] < slagBefore) ++paidTimes;
|
|
|
|
|
|
if (fr == CraftResult::DESTROYED) ++destroyed;
|
|
|
|
|
|
else if (probe.Count() == countBefore - 1) ++slotsLost;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const float rate = float(destroyed) / 1000.0f;
|
|
|
|
|
|
std::printf("залом: рассыпалось %.1f%% при заявленных %.0f%%, платят всегда %s,"
|
|
|
|
|
|
" слот теряется %s\n",
|
|
|
|
|
|
double(rate * 100.0f), double(FRACTURE_RISK * 100.0f),
|
|
|
|
|
|
(paidTimes == 1000) ? "да" : "НЕТ",
|
|
|
|
|
|
(slotsLost + destroyed == 1000) ? "да" : "НЕТ");
|
|
|
|
|
|
|
|
|
|
|
|
if (std::fabs(rate - FRACTURE_RISK) > 0.03f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] риск залома не тот, что обещан подписью\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (paidTimes != 1000)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] за неудавшийся залом не платят — риска нет\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (slotsLost + destroyed != 1000)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] залом не отнял слот: это улучшение без размена\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 6. Бедность: без материалов не проходит ничего.
|
|
|
|
|
|
for (int m = 0; m < MATERIAL_COUNT; ++m) pr.material[m] = 0;
|
|
|
|
|
|
ItemInstance poor(ItemId::RIFLE);
|
|
|
|
|
|
poor.rarity = Rarity::MARKED;
|
|
|
|
|
|
poor.affix[poor.count++] = Affix{AffixTag::DAMAGE, 2, 0, 0.12f};
|
|
|
|
|
|
const CraftResult broke = ApplyCraft(CraftOp::REFORGE, poor, pr.material, 40, rng);
|
|
|
|
|
|
if (broke != CraftResult::NO_MATERIALS)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] операция прошла без материалов\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (int m = 0; m < MATERIAL_COUNT; ++m) pr.material[m] = savedMat[m];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Полосы добычи. Правило «материал добывается только в своей полосе» —
|
|
|
|
|
|
// единственная причина возвращаться на средние глубины, когда рекорд ушёл
|
|
|
|
|
|
// вниз (docs/22-loot.md §5.1). Проверяется прямым опросом таблицы: в игре
|
|
|
|
|
|
// это заметно только часов через двадцать.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
struct Case
|
|
|
|
|
|
{
|
|
|
|
|
|
int depth;
|
|
|
|
|
|
int theme;
|
|
|
|
|
|
const char* what;
|
|
|
|
|
|
};
|
|
|
|
|
|
const Case cases[] = {
|
|
|
|
|
|
{1, 0, "первый этаж, сталь"}, {25, 2, "плоть на 25"}, {25, 0, "сталь на 25"},
|
|
|
|
|
|
{40, 3, "мороз на 40"}, {60, 1, "ржавь на 60"}, {80, 2, "плоть на 80"},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nполосы материалов (с твари danger 2):\n");
|
|
|
|
|
|
int bandBad = 0;
|
|
|
|
|
|
for (const Case& c : cases)
|
|
|
|
|
|
{
|
|
|
|
|
|
const MaterialDrop d = KillDrop(c.depth, c.theme, 2.0f);
|
|
|
|
|
|
std::printf(" %-20s лом %d, био %d, крио %d, осколок %d, шлак %d\n", c.what,
|
|
|
|
|
|
d.amount[0], d.amount[1], d.amount[2], d.amount[3], d.amount[4]);
|
|
|
|
|
|
|
|
|
|
|
|
// Ядра обязаны молчать вне своей темы и выше своей глубины.
|
|
|
|
|
|
if (c.depth < 21 && d.Total() > 0) ++bandBad;
|
|
|
|
|
|
if (c.theme != 2 && d.amount[int(MaterialKind::BIO)] > 0) ++bandBad;
|
|
|
|
|
|
if (c.theme != 3 && d.amount[int(MaterialKind::CRYO)] > 0) ++bandBad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const MaterialDrop prop = PropDrop(1);
|
|
|
|
|
|
std::printf(" %-20s лом %d\n", "разбитый ящик", prop.amount[0]);
|
|
|
|
|
|
if (prop.amount[int(MaterialKind::SCRAP)] <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] обстановка не даёт лома — базовый материал недобываем\n");
|
|
|
|
|
|
++bandBad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (bandBad > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] полосы протекают: материал падает не со своих этажей\n");
|
|
|
|
|
|
bad += bandBad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Размер отряда. Проверяется цепочка «в профиле -> на этаже»: сколько
|
|
|
|
|
|
// человек в профиле, столько и выходит, и ни одного лишнего тела рядом.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Стоит ПЕРЕД проверкой улучшений, потому что та начинает новую вылазку и
|
|
|
|
|
|
// сама же пересобирает отряд.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile& pr = *game.profile;
|
|
|
|
|
|
const int sizeBefore = pr.squadSize;
|
|
|
|
|
|
|
|
|
|
|
|
auto mannedNow = [&]() {
|
|
|
|
|
|
int n = 0;
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|
|
|
|
|
if (game.squad.agents[i].present) ++n;
|
|
|
|
|
|
return n;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
pr.squadSize = 1;
|
|
|
|
|
|
game.NewRun(1);
|
|
|
|
|
|
const int mannedSolo = mannedNow();
|
|
|
|
|
|
const int aliveSolo = game.AliveAgents();
|
|
|
|
|
|
|
|
|
|
|
|
// Переживает ли одиночка встречу с первой тварью. Первый этаж заявлен
|
|
|
|
|
|
// как «полупустая лаборатория, не страшно» (docs/20-gdd.md), и человек
|
|
|
|
|
|
// с ножом обязан её пережить — иначе начало игры непроходимо.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Отряд СТАВИТСЯ рядом с тварью, а не ждёт её. Первая версия проверки
|
|
|
|
|
|
// просто шагала минуту на месте и рапортовала «выжил» — при том, что к
|
|
|
|
|
|
// бойцу не вышел никто: твари замечают с восьми-двенадцати тайлов, а
|
|
|
|
|
|
// высадка от них далеко. Проверка, в которой ничего не происходит,
|
|
|
|
|
|
// проходит всегда.
|
|
|
|
|
|
int engaged = 0;
|
|
|
|
|
|
bool soloLived = true;
|
|
|
|
|
|
float hpStart = 0.0f, hpLeft = 0.0f;
|
|
|
|
|
|
int killed = 0;
|
|
|
|
|
|
{
|
|
|
|
|
|
int victim = -1;
|
|
|
|
|
|
for (int t = 0; t < int(game.targets.size()); ++t)
|
|
|
|
|
|
if (game.targets[size_t(t)].alive && !game.targets[size_t(t)].IsProp())
|
|
|
|
|
|
{
|
|
|
|
|
|
victim = t;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (victim < 0) std::printf("\n [FAIL] на первом этаже нет ни одной твари\n"), ++bad;
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
const int aliveBefore = [&]() {
|
|
|
|
|
|
int n = 0;
|
|
|
|
|
|
for (const Target& t : game.targets)
|
|
|
|
|
|
if (t.alive && !t.IsProp()) ++n;
|
|
|
|
|
|
return n;
|
|
|
|
|
|
}();
|
|
|
|
|
|
|
|
|
|
|
|
game.PlaceSquad(game.targets[size_t(victim)].pos + Vec2{3.0f, 0.0f}, true);
|
|
|
|
|
|
game.Step(FIXED_DT); // SyncLoadout: здоровье по надетому
|
|
|
|
|
|
game.squad.agents[0].hp = game.squad.agents[0].maxHp;
|
|
|
|
|
|
hpStart = game.squad.agents[0].hp;
|
|
|
|
|
|
|
|
|
|
|
|
for (int k = 0; k < 3600; ++k) // минута
|
|
|
|
|
|
{
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
if (!game.squad.agents[0].alive) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int aliveAfter = 0;
|
|
|
|
|
|
for (const Target& t : game.targets)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (t.IsProp()) continue;
|
|
|
|
|
|
if (t.alive) ++aliveAfter;
|
|
|
|
|
|
if (t.aggro) ++engaged;
|
|
|
|
|
|
}
|
|
|
|
|
|
killed = aliveBefore - aliveAfter;
|
|
|
|
|
|
soloLived = game.squad.agents[0].alive;
|
|
|
|
|
|
hpLeft = game.squad.agents[0].hp;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
// Наёмник НАХОДИТСЯ, а не покупается: подкладываем капсулу под ноги
|
|
|
|
|
|
// бойцу и смотрим, встал ли человек в строй и со своими ли числами.
|
|
|
|
|
|
SpawnFloorItem(game.world, ItemInstance(ItemId::CAPSULE), game.squad.agents[0].pos, 0.0f);
|
|
|
|
|
|
for (int k = 0; k < 30 && mannedNow() < 2; ++k) game.Step(FIXED_DT);
|
|
|
|
|
|
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
const int mannedPair = mannedNow();
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
const bool rescued = mannedPair == 2;
|
|
|
|
|
|
const MercStats got = pr.merc[1];
|
|
|
|
|
|
const bool hasStats = got.Any();
|
|
|
|
|
|
const bool inProfile = pr.squadSize == 2;
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
// Потолок: шестого не втиснуть никакой капсулой.
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
pr.squadSize = NUM_AGENTS;
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
const bool overFull = pr.CanRescue();
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
std::printf("\nотряд: в профиле 1 -> на этаже %d (живых %d), капсула подняла до %d\n"
|
|
|
|
|
|
" числа наёмника: hp +%.0f, скорость +%.2f, обзор +%.2f;"
|
|
|
|
|
|
" сверх потолка %s\n"
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
" одиночка с ножом на первом этаже: %s, здоровье %.0f -> %.0f, "
|
|
|
|
|
|
"тварей подошло %d, убил %d\n",
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
mannedSolo, aliveSolo, mannedPair, double(got.hp), double(got.speed),
|
|
|
|
|
|
double(got.sight), overFull ? "ВЛЕЗ" : "отказ",
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
soloLived ? "выжил" : "ПОГИБ", double(hpStart), double(hpLeft), engaged,
|
|
|
|
|
|
killed);
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
if (!inProfile && rescued)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] найденный не записался в профиль\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
|
|
|
|
|
|
if (!soloLived)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] первая же тварь убивает одинокого оперативника — начало"
|
|
|
|
|
|
" игры непроходимо\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (engaged <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] к бойцу никто не подошёл: проверка ничего не измерила\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (soloLived && killed <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] нож не убил ни одной твари за минуту — первый этаж не"
|
|
|
|
|
|
" пройти в принципе\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (mannedSolo != 1 || aliveSolo != 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] одиночка вышел не один: занято %d, живых %d\n", mannedSolo,
|
|
|
|
|
|
aliveSolo);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
if (!rescued)
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
{
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
std::printf(" [FAIL] капсула не поставила человека в строй: занято %d\n",
|
|
|
|
|
|
mannedPair);
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
if (rescued && !hasStats)
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
{
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
std::printf(" [FAIL] у найденного нет своих чисел: это слот, а не человек\n");
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
if (overFull)
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
{
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
std::printf(" [FAIL] шестой влезает — потолок отряда не держит\n");
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pr.squadSize = sizeBefore;
|
|
|
|
|
|
}
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Вайп: половина добычи уцелевает, и редкое теряется чаще.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Меряется на СОТНЕ вайпов, а не на одном: «у меня уцелел реликт» и «у меня
|
|
|
|
|
|
// не уцелел» — одинаково бессмысленные наблюдения. Проверяются два разных
|
|
|
|
|
|
// утверждения, и их легко перепутать:
|
|
|
|
|
|
//
|
|
|
|
|
|
// 1. в среднем доезжает около половины;
|
|
|
|
|
|
// 2. обычное доезжает ЧАЩЕ реликта.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Второе важнее: без него правило вырождается в «теряется половина бинтов».
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile& pr = *game.profile;
|
|
|
|
|
|
const Profile savedProfile = pr;
|
|
|
|
|
|
|
|
|
|
|
|
int commonKept = 0, commonAll = 0;
|
|
|
|
|
|
int relicKept = 0, relicAll = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (int round = 0; round < 100; ++round)
|
|
|
|
|
|
{
|
|
|
|
|
|
pr.stash.clear();
|
|
|
|
|
|
|
|
|
|
|
|
// Сумка отряда: поровну обычного и реликтов, чтобы сравнивать
|
|
|
|
|
|
// доли, а не количества.
|
|
|
|
|
|
game.squad.bag.Clear();
|
|
|
|
|
|
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
ItemInstance it(ItemId::JACKET);
|
|
|
|
|
|
if (i % 2 == 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
it.rarity = Rarity::RELIC;
|
|
|
|
|
|
it.affix[it.count++] = Affix{AffixTag::HEALTH, 5, 0, 20.0f};
|
|
|
|
|
|
++relicAll;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
++commonAll;
|
|
|
|
|
|
}
|
|
|
|
|
|
game.squad.bag.slots[i] = it;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Сид вайпа считается от сида уровня и глубины, поэтому крутим их:
|
|
|
|
|
|
// иначе сто вайпов дали бы сто одинаковых итогов.
|
|
|
|
|
|
game.levelSeed = uint32_t(round) * 2654435761u + 17u;
|
|
|
|
|
|
game.depth = 10 + round % 7;
|
|
|
|
|
|
|
|
|
|
|
|
// Считаем ВСЁ, что отряд нёс, а не только сумку: спасение забирает
|
|
|
|
|
|
// и надетое. Первая версия проверки считала одну сумку и получила
|
|
|
|
|
|
// «обычного доехало 112%» — то есть больше, чем клала.
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Agent& ag = game.squad.agents[i];
|
|
|
|
|
|
if (!ag.present) continue;
|
|
|
|
|
|
if (const Inventory* inv = game.world.Get<Inventory>(ag.self))
|
|
|
|
|
|
{
|
|
|
|
|
|
const ItemInstance worn[3] = {inv->weapon, inv->armor, inv->trinket};
|
|
|
|
|
|
for (const ItemInstance& w : worn)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (w.Empty()) continue;
|
|
|
|
|
|
if (w.rarity == Rarity::RELIC) ++relicAll;
|
|
|
|
|
|
else ++commonAll;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i) game.squad.agents[i].alive = false;
|
|
|
|
|
|
game.WipeRun();
|
|
|
|
|
|
|
|
|
|
|
|
for (const ItemInstance& it : pr.stash)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (it.rarity == Rarity::RELIC) ++relicKept;
|
|
|
|
|
|
else ++commonKept;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const float commonRate = float(commonKept) / float(std::max(1, commonAll));
|
|
|
|
|
|
const float relicRate = float(relicKept) / float(std::max(1, relicAll));
|
|
|
|
|
|
const float total = float(commonKept + relicKept) / float(std::max(1, commonAll + relicAll));
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nвайп: доезжает %.0f%% добычи (обычное %.0f%%, реликты %.0f%%)\n",
|
|
|
|
|
|
double(total * 100.0f), double(commonRate * 100.0f),
|
|
|
|
|
|
double(relicRate * 100.0f));
|
|
|
|
|
|
|
|
|
|
|
|
if (total < 0.35f || total > 0.65f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] после вайпа доезжает %.0f%% — это не «половина»\n",
|
|
|
|
|
|
double(total * 100.0f));
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (relicRate >= commonRate)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] реликты теряются не чаще обычного: %.0f%% против %.0f%%\n",
|
|
|
|
|
|
double(relicRate * 100.0f), double(commonRate * 100.0f));
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pr = savedProfile;
|
|
|
|
|
|
game.squad.bag.Clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Опыт платится за УБИЙСТВА и сразу. Раньше он зачислялся только при
|
|
|
|
|
|
// эвакуации, то есть вайп отнимал ещё и понимание того, что игрок уже
|
|
|
|
|
|
// сделал. Теперь выносить надо ВЕЩИ, а не опыт.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile& pr = *game.profile;
|
|
|
|
|
|
game.NewRun(1);
|
|
|
|
|
|
|
|
|
|
|
|
const float bankBefore = pr.bankedXp;
|
|
|
|
|
|
ClearField(game);
|
|
|
|
|
|
|
|
|
|
|
|
// Убиваем тварь руками: прогон спуска ни с кем не воюет, и ждать
|
|
|
|
|
|
// естественной смерти тут нечего.
|
|
|
|
|
|
int victim = -1;
|
|
|
|
|
|
for (int t = 0; t < int(game.targets.size()); ++t)
|
|
|
|
|
|
if (!game.targets[size_t(t)].IsProp() && !game.targets[size_t(t)].IsDummy())
|
|
|
|
|
|
{
|
|
|
|
|
|
victim = t;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool killed = false;
|
|
|
|
|
|
if (victim >= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
Target& t = game.targets[size_t(victim)];
|
|
|
|
|
|
t.alive = true;
|
|
|
|
|
|
t.countedDead = false;
|
|
|
|
|
|
t.hp = 1.0f;
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
t.hp = 0.0f;
|
|
|
|
|
|
t.alive = false;
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
killed = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const float bankAfter = pr.bankedXp;
|
|
|
|
|
|
std::printf("опыт: банк %.0f -> %.0f за убийство БЕЗ эвакуации\n", double(bankBefore),
|
|
|
|
|
|
double(bankAfter));
|
|
|
|
|
|
|
|
|
|
|
|
if (!killed || bankAfter <= bankBefore)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] опыт за убийство не зачислен — его снова надо выносить\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
|
|
|
|
|
|
// влияет, выглядит точно так же, как работающее улучшение.
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
//
|
|
|
|
|
|
// Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает
|
|
|
|
|
|
// глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в
|
|
|
|
|
|
// одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра
|
|
|
|
|
|
// сломана, а потому что её сломал измеряющий.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile& pr = *game.profile;
|
|
|
|
|
|
const float hpBefore = game.squad.agents[0].maxHp;
|
|
|
|
|
|
const int ammoBefore = int(g_tune.startAmmo);
|
|
|
|
|
|
|
|
|
|
|
|
pr.bankedXp = 10000.0f;
|
|
|
|
|
|
const bool boughtAmmo = pr.Buy(Upgrade::AMMO);
|
|
|
|
|
|
const bool boughtVigor = pr.Buy(Upgrade::VIGOR);
|
|
|
|
|
|
const float leftXp = pr.bankedXp;
|
|
|
|
|
|
|
|
|
|
|
|
// Улучшения раздаются вместе со снаряжением, то есть на новой вылазке.
|
|
|
|
|
|
game.NewRun(1);
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
const float hpAfter = game.squad.agents[0].maxHp;
|
|
|
|
|
|
const int ammoAfter = game.squad.agents[0].ammoReserve;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nулучшения: здоровье %.0f -> %.0f, патроны %d -> %d, "
|
|
|
|
|
|
"опыта осталось %.0f\n",
|
|
|
|
|
|
double(hpBefore), double(hpAfter), ammoBefore, ammoAfter, double(leftXp));
|
|
|
|
|
|
|
|
|
|
|
|
if (!boughtAmmo || !boughtVigor)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] покупка не прошла при полном кошельке\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hpAfter <= hpBefore)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] живучесть куплена, но здоровье не выросло\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (ammoAfter <= ammoBefore)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] запас куплен, но патронов не прибавилось\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (leftXp >= 10000.0f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] опыт не списался: покупка бесплатная\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nитог: этажей %d, опыта %.0f, ошибок %d\n", floorsDone, double(xpEarned), bad);
|
|
|
|
|
|
return bad == 0 ? 0 : 1;
|
|
|
|
|
|
}
|
Пункт UPGRADES выходил из игры; меню теперь проверяется (M26)
Жалоба была «крашится при открытии апгрейдов». Не крашилось: пункт
закрывал игру. В обработчике главного меню стоял `default: ui.quit`, а
экран улучшений добавили четвёртым пунктом — обработчик о нём просто не
узнал. Снаружи мгновенное закрытие окна неотличимо от падения.
Починка на две строки, но интереснее причина, по которой это прожило
четыре вехи: в проекте каждая механика меряется прогоном с кодом
возврата, а меню не проверял никто. Все инструменты смотрят на
СИМУЛЯЦИЮ.
Поэтому заведён --ui-check: проходит по всем пунктам меню и требует
одного — нажатие ВЕДЁТ КУДА-ТО, а из игры выходит ровно один пункт,
QUIT. Плюс проверяет, что кнопки экрана улучшений действительно покупают.
Окно при этом не открывается: UiActivate ничего из raylib не трогает.
Проверка сперва запущена на СЛОМАННОЙ сборке и дала код 1 с диагнозом
«пункт UPGRADES не делает ничего», и только после этого объявлена
рабочей. Проверка, которую не видели падающей, ещё ничего не проверяет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:51:58 +07:00
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
// --ui-check: меню без окна.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Проверяет ровно одно утверждение, зато для каждого пункта: НАЖАТИЕ ВЕДЁТ
|
|
|
|
|
|
// КУДА-ТО. Пункт, который не меняет ни экран, ни состояние игры, — это
|
|
|
|
|
|
// неработающая кнопка, и выглядит она в точности как работающая.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Проверка появилась после конкретной поломки: пункт UPGRADES четыре вехи
|
|
|
|
|
|
// закрывал игру. В обработчике стоял `default: quit`, экран улучшений добавили
|
|
|
|
|
|
// четвёртым пунктом, и обработчик о нём не узнал. Для игрока это выглядело как
|
|
|
|
|
|
// падение при открытии улучшений — то есть как ошибка совсем другого рода.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Раньше поймать это было нечем: все прогоны в проекте меряют СИМУЛЯЦИЮ, а
|
|
|
|
|
|
// меню не проверял никто. Отсюда и правило: у поверхности, по которой игрок
|
|
|
|
|
|
// щёлкает, тоже должен быть код возврата.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Окно не открывается: UiActivate ничего из raylib не трогает, он только
|
|
|
|
|
|
// переключает экраны и зовёт методы игры.
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
int RunUiCheck()
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile profile;
|
|
|
|
|
|
profile.ResetLoadouts();
|
|
|
|
|
|
profile.bankedXp = 100000.0f; // чтобы покупки на экранах реально проходили
|
|
|
|
|
|
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.profile = &profile;
|
|
|
|
|
|
game.Init();
|
|
|
|
|
|
|
|
|
|
|
|
int bad = 0;
|
|
|
|
|
|
const int count = UiMenuItemCount();
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("%-4s %-12s %-10s %-10s %s\n", "N", "ПУНКТ", "ЭКРАН ДО", "ПОСЛЕ", "ИТОГ");
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < count; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
Ui ui;
|
|
|
|
|
|
ui.started = true; // «продолжить» имеет смысл только в начатом забеге
|
|
|
|
|
|
const Screen before = ui.screen;
|
|
|
|
|
|
|
|
|
|
|
|
UiActivate(ui, game, UiPart::MENU_ITEM, i);
|
|
|
|
|
|
|
|
|
|
|
|
const char* name = UiMenuItemName(i);
|
|
|
|
|
|
const bool isQuit = std::strcmp(name, "QUIT") == 0;
|
|
|
|
|
|
const bool moved = ui.screen != before;
|
|
|
|
|
|
const bool ok = isQuit ? (ui.quit && !moved) : (moved && !ui.quit);
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("%-4d %-12s %-10d %-10d %s\n", i, name, int(before), int(ui.screen),
|
|
|
|
|
|
ok ? "ok" : "ПОЛОМКА");
|
|
|
|
|
|
|
|
|
|
|
|
if (!ok)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (ui.quit && !isQuit)
|
|
|
|
|
|
std::printf(" [FAIL] пункт `%s` выходит из игры\n", name);
|
|
|
|
|
|
else if (!moved)
|
|
|
|
|
|
std::printf(" [FAIL] пункт `%s` не делает ничего\n", name);
|
|
|
|
|
|
else
|
|
|
|
|
|
std::printf(" [FAIL] пункт `%s` ведёт себя не как QUIT\n", name);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Экран улучшений — тот самый, из-за которого проверка и появилась.
|
|
|
|
|
|
// Мало открыть его: покупка обязана работать с той же кнопки.
|
|
|
|
|
|
{
|
|
|
|
|
|
Ui ui;
|
|
|
|
|
|
const int lvlBefore = profile.UpgradeLevel(Upgrade::AMMO);
|
|
|
|
|
|
UiActivate(ui, game, UiPart::UPGRADE_ITEM, int(Upgrade::AMMO));
|
|
|
|
|
|
const bool bought = profile.UpgradeLevel(Upgrade::AMMO) > lvlBefore;
|
|
|
|
|
|
|
Правки по игре: начало, ставка, наёмники (M29)
Семь изменений по итогам игры вживую.
НАЧАЛО. Первый этаж встречал одинокого оперативника с ножом тремя тварями,
из которых одна быстрая: бюджет 3.0 и бегуны с первого этажа. Стало 2.2
(ровно два бредущих), бегуны с четвёртого. Света прибавлено: обзор
начинается с 1.30 вместо 1.0, у этажа появилась своя яркость пройденного
(0.42 наверху против 0.13 на дне). Наверху ещё работает освещение, и
хоррор первого этажа в том, что игрок один, а не в том, что не видно
стен. Темнота это инструмент глубины, и тратить её на первый этаж значит
не иметь запаса дальше.
СТАВКА. Опыт платится за убийства и сразу: выносить надо ВЕЩИ. Вайп
больше не отнимает всё - доезжает около половины добычи, и чем вещь реже,
тем охотнее теряется. Прежнее "вайп не возвращает ничего" делало
единственным разумным поведением уход наверх при первой царапине.
ВЫХОД. Аномалия возврата переехала из точки входа к спуску: развилка
"вниз или наверх" теперь там же, где этаж кончился. Обратный путь через
растревоженный этаж ничего не решал - это был ещё раз тот же этаж.
ЧЕКПОИНТЫ через 50 этажей вместо пяти: забег это долгий спуск, а не
перебежка между сохранениями.
НАЁМНИКИ. Кнопка "нанять за опыт" убрана, оперативники находятся в
капсулах на этажах: в среднем раз в сотню этажей и не чаще раза в
двадцать. У каждого свои три прибавки из одного бюджета - разница в
характере, а не в ранге. Покупка была скучной ровно в том смысле, в каком
скучна любая кнопка "стать сильнее": ничего не рассказывает и никуда не
зовёт.
ЗАМЕРЫ. Вайп на сотне повторов: доезжает 52% (обычное 69%, реликты 25%).
Первая версия проверки считала одну сумку и выдала "обычного доехало
112%" - спасение забирает ещё и надетое. Опыт: банк 558 -> 578 за
убийство без эвакуации. Капсула поднимает отряд до двух и даёт наёмнику
его числа.
Формат профиля 6: строки merc и rescued.
Не сделано и обсуждается: ростер сверх пятерых, задания для свободных,
пассивные бонусы от сидящих на базе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:09:26 +07:00
|
|
|
|
std::printf("\nулучшения: покупка %s\n", bought ? "работает" : "НЕ работает");
|
|
|
|
|
|
if (!bought)
|
Пункт UPGRADES выходил из игры; меню теперь проверяется (M26)
Жалоба была «крашится при открытии апгрейдов». Не крашилось: пункт
закрывал игру. В обработчике главного меню стоял `default: ui.quit`, а
экран улучшений добавили четвёртым пунктом — обработчик о нём просто не
узнал. Снаружи мгновенное закрытие окна неотличимо от падения.
Починка на две строки, но интереснее причина, по которой это прожило
четыре вехи: в проекте каждая механика меряется прогоном с кодом
возврата, а меню не проверял никто. Все инструменты смотрят на
СИМУЛЯЦИЮ.
Поэтому заведён --ui-check: проходит по всем пунктам меню и требует
одного — нажатие ВЕДЁТ КУДА-ТО, а из игры выходит ровно один пункт,
QUIT. Плюс проверяет, что кнопки экрана улучшений действительно покупают.
Окно при этом не открывается: UiActivate ничего из raylib не трогает.
Проверка сперва запущена на СЛОМАННОЙ сборке и дала код 1 с диагнозом
«пункт UPGRADES не делает ничего», и только после этого объявлена
рабочей. Проверка, которую не видели падающей, ещё ничего не проверяет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:51:58 +07:00
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] кнопки экрана улучшений не покупают\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nитог: пунктов %d, поломок %d\n", count, bad);
|
|
|
|
|
|
return bad == 0 ? 0 : 1;
|
|
|
|
|
|
}
|
Личинки, парирование и инструмент --wall (M30)
--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами
через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке.
Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление
тварей и живёт полторы минуты без управления. Первая стена: одиночка D7,
трое D30, пятеро D30 - петля работает.
И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в
том числе незанятые. Строка писалась, когда отряд всегда был впятером;
после появления ростера одиночка выходил на этаж с четырьмя невидимыми
телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже,
100 -> 41" был боем впятером.
ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом
проигрывает двум бредущим: 210 здоровья против ножа это девять секунд
работы под ответным уроном. Первые этажи населены личинками (55 здоровья,
7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый.
Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре
меняет, кто выходит, а не числа у одного и того же.
ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она
выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её
двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка.
Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в
замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь.
Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает
повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть
размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары.
Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это
его удар, просто по летящей цели.
Проверка меряет размен целиком, включая обратную половину правила:
"стоя столбом не отбивает". Без неё условие про соприкосновение
существовало бы только в комментарии.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:04:35 +07:00
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
// --wall: где обрывается забег.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Главный вопрос кривой после того, как чекпоинты разошлись на пятьдесят
|
|
|
|
|
|
// этажей: до какой глубины доходит отряд ДАННОЙ силы. Ответ на него нельзя
|
|
|
|
|
|
// получить ни из головы, ни из `--descend` — тот собирает этажи, но ни с кем не
|
|
|
|
|
|
// воюет. Здесь отряд именно дерётся.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Меряется грубо и честно: отряд ставится в середину населённого этажа и живёт
|
|
|
|
|
|
// там полторы минуты без управления. Это НИЖНЯЯ оценка — игрок уводит бойцов,
|
|
|
|
|
|
// выбирает, кого бить первым, и отступает; проверка не умеет ничего из этого.
|
|
|
|
|
|
// Поэтому глубина, на которой отряд перестаёт выживать здесь, — это не «конец
|
|
|
|
|
|
// игры», а место, где игра начинает требовать умения.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Смысл цифр: если граница НЕ растёт с силой отряда, значит вылазки не
|
|
|
|
|
|
// накапливаются, и петля «несколько заходов до чекпоинта» не работает.
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
int RunWall(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int maxDepth)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf("%-7s %-8s %8s %8s %8s %9s\n", "DEPTH", "ОТРЯД", "ТВАРЕЙ", "ВЫЖИЛО", "УБИЛ",
|
|
|
|
|
|
"ЗДОРОВЬЕ");
|
|
|
|
|
|
|
|
|
|
|
|
const int sizes[] = {1, 3, 5};
|
|
|
|
|
|
int lastAlive[3] = {0, 0, 0};
|
|
|
|
|
|
int wall[3] = {0, 0, 0};
|
|
|
|
|
|
|
|
|
|
|
|
for (int si = 0; si < 3; ++si)
|
|
|
|
|
|
{
|
|
|
|
|
|
const int size = sizes[si];
|
|
|
|
|
|
|
|
|
|
|
|
for (int d = 1; d <= maxDepth; d += (d < 10) ? 3 : 10)
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile profile;
|
|
|
|
|
|
profile.ResetLoadouts();
|
|
|
|
|
|
profile.squadSize = size;
|
|
|
|
|
|
// Глубина рекорда — заведомо большая: NewRun режет старт по
|
|
|
|
|
|
// открытым чекпоинтам, а те теперь через пятьдесят этажей. С
|
|
|
|
|
|
// maxDepth = 50 любой запрос глубже первого молча уезжал на первый
|
|
|
|
|
|
// этаж, и проверка мерила два бредущих на каждой строке таблицы.
|
|
|
|
|
|
profile.maxDepth = 100000;
|
|
|
|
|
|
|
|
|
|
|
|
// Снаряжение по глубине: отряд, дошедший до d, одет примерно в то,
|
|
|
|
|
|
// что на d и находят. Без этого проверка мерила бы нож против
|
|
|
|
|
|
// сорокового этажа и объявляла стеной первый же поворот.
|
|
|
|
|
|
uint32_t rng = seed * 7919u + uint32_t(d);
|
|
|
|
|
|
for (int i = 0; i < size; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
Inventory& inv = profile.loadout[i];
|
|
|
|
|
|
inv.weapon = RollItem((i == 0) ? ItemId::SHOTGUN : ItemId::RIFLE, d, rng);
|
|
|
|
|
|
inv.armor = RollItem((d >= 9) ? ItemId::PLATE : ItemId::VEST, d, rng);
|
|
|
|
|
|
inv.trinket = RollItem(ItemId::CHARM, d, rng);
|
|
|
|
|
|
if (i > 0) profile.merc[i] = RollMerc(rng);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.combatMode = true;
|
|
|
|
|
|
game.rooms = &rooms;
|
|
|
|
|
|
game.catalog = &catalog;
|
|
|
|
|
|
game.profile = &profile;
|
|
|
|
|
|
game.levelSeed = seed + uint32_t(d) * 17u;
|
|
|
|
|
|
game.Init();
|
|
|
|
|
|
game.NewRun(d);
|
|
|
|
|
|
|
|
|
|
|
|
// Отряд ставится в гущу: к точке, где стоит больше всего тварей.
|
|
|
|
|
|
// Иначе половина этажа до него просто не дойдёт, и проверка
|
|
|
|
|
|
// измерит планировку.
|
|
|
|
|
|
Vec2 crowd = game.squad.anchor;
|
|
|
|
|
|
float best = -1.0f;
|
|
|
|
|
|
for (const Target& t : game.targets)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!t.alive || t.IsProp() || t.IsDummy()) continue;
|
|
|
|
|
|
float near_ = 0.0f;
|
|
|
|
|
|
for (const Target& o : game.targets)
|
|
|
|
|
|
if (o.alive && !o.IsProp() && DistanceSq(o.pos, t.pos) < 36.0f) near_ += 1.0f;
|
|
|
|
|
|
if (near_ > best)
|
|
|
|
|
|
{
|
|
|
|
|
|
best = near_;
|
|
|
|
|
|
crowd = t.pos;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
game.PlaceSquad(crowd, false);
|
|
|
|
|
|
|
|
|
|
|
|
int monsters = 0;
|
|
|
|
|
|
for (const Target& t : game.targets)
|
|
|
|
|
|
if (t.alive && !t.IsProp() && !t.IsDummy()) ++monsters;
|
|
|
|
|
|
|
|
|
|
|
|
for (int k = 0; k < 5400 && game.AliveAgents() > 0; ++k) game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
int aliveNow = 0;
|
|
|
|
|
|
for (const Target& t : game.targets)
|
|
|
|
|
|
if (t.alive && !t.IsProp() && !t.IsDummy()) ++aliveNow;
|
|
|
|
|
|
|
|
|
|
|
|
float hp = 0.0f, maxHp = 0.0f;
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Agent& a = game.squad.agents[i];
|
|
|
|
|
|
if (!a.present) continue;
|
|
|
|
|
|
hp += std::max(0.0f, a.hp);
|
|
|
|
|
|
maxHp += a.maxHp;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const int survivors = game.AliveAgents();
|
|
|
|
|
|
std::printf("%-7d %-8d %8d %8d %8d %8.0f%%\n", d, size, monsters, survivors,
|
|
|
|
|
|
monsters - aliveNow, double(100.0f * hp / std::max(1.0f, maxHp)));
|
|
|
|
|
|
|
|
|
|
|
|
if (survivors > 0) lastAlive[si] = d;
|
|
|
|
|
|
else if (wall[si] == 0) wall[si] = d;
|
|
|
|
|
|
}
|
|
|
|
|
|
std::printf("\n");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Смотреть надо на ПЕРВУЮ стену, а не на «глубже всего дожили»: на глубоких
|
|
|
|
|
|
// этажах твари разбросаны, и отряд иногда доживает полторы минуты просто
|
|
|
|
|
|
// потому, что до него дошли не все. Последний выживший — шум, первая
|
|
|
|
|
|
// стена — сигнал.
|
|
|
|
|
|
std::printf("первая стена: одиночка D%d, трое D%d, пятеро D%d\n", wall[0], wall[1], wall[2]);
|
|
|
|
|
|
std::printf("глубже всего кто-то дожил (шумно): D%d / D%d / D%d\n", lastAlive[0],
|
|
|
|
|
|
lastAlive[1], lastAlive[2]);
|
|
|
|
|
|
|
|
|
|
|
|
int bad = 0;
|
|
|
|
|
|
if (wall[2] <= wall[0])
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] стена пятерых не глубже стены одиночки — вылазки не\n"
|
|
|
|
|
|
" накапливаются, и петля «несколько заходов до чекпоинта»\n"
|
|
|
|
|
|
" не работает\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (lastAlive[0] < 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] одиночка не переживает даже первый этаж\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
return bad == 0 ? 0 : 1;
|
|
|
|
|
|
}
|
Запуск грузит проект; автопилот --play; строй сжимается в тесноте (M31)
ГЛАВНОЕ. "Дольше пяти секунд не продержаться на первом уровне" - это был
не баланс, а запуск. По умолчанию useRooms был false: обычный запуск exe
собирал ЗАХАРДКОЖЕННУЮ карту прототипа со старой расстановкой тварей и
высаживал туда ОДНОГО оперативника с ножом (размер отряда берётся из
профиля). Игрок попадал не на первый этаж игры, а вообще не в игру.
Теперь проект подключается по умолчанию, а старая карта осталась под
флагом --no-rooms.
--PLAY. Новый прогон: автопилот ведёт отряд к спуску и дерётся по дороге.
Ни --descend, ни --wall не меряли ПРОХОЖДЕНИЕ: первый собирает этажи и ни
с кем не воюет, второй ставит отряд в толпу и запрещает двигаться. Жалоба
же была именно про проход этажа.
Автопилот ходит по полю расстояний до спуска (заливка по проходимым
клеткам), целится на несколько клеток вперёд по спуску поля и правит
курсором - тем же вводом, каким играет человек. Три версии до рабочей:
по прямой упирался в первую стену, по восьми соседям лез в диагональную
щель между углами (тело круглое, не пролезает), по соседней клетке дёргался
на каждой границе.
Одиночка проходит три этажа и гибнет на четвёртом:
1 тварей 3, убил 0, 100% здоровья, света 244 дошёл (78 с)
2 тварей 6, убил 1, 100%, света 155 дошёл (44 с)
3 тварей 6, убил 0, 65%, света 8 дошёл (74 с)
4 тварей 9, убил 5, ВАЙП (37 с)
Свет кончается к третьему этажу: расчёт "заряда на четыре пути до спуска"
считал прямые пути, а живой отряд ходит петлями.
СТРОЙ В ТЕСНОТЕ. Клин шириной в пять тел не проходит в дверь шириной в
клетку: бойцы упираются друг в друга, расталкивание держит их на месте,
отряд встаёт насмерть. Теперь строй сам сжимается в колонну, когда вокруг
тел мало свободных клеток.
ЧЕСТНО: это НЕ вылечило проход пятёрки до конца. Автопилот с полным
отрядом всё ещё застревает на первом этаже там, где одиночка проходит, и
причина не в ширине строя (проверено сжатием в точку и отрядом без
клинков). Похоже, нужна нормальная проводка строя по проходам - это
отдельная веха, а не патч в конце. Оставляю с числами, а не с обещанием.
--accept PASS, все семь проверок зелёные.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 17:20:21 +07:00
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
// --play N: автопилот. Отряд ИДЁТ К СПУСКУ и дерётся по дороге.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Зачем ещё один прогон, когда есть `--descend` и `--wall`: они меряют разные
|
|
|
|
|
|
// куски, и ни один не меряет ИГРУ. `--descend` собирает этажи и ни с кем не
|
|
|
|
|
|
// воюет, `--wall` ставит отряд в толпу и запрещает ему двигаться. А жалоба
|
|
|
|
|
|
// игрока звучит иначе: «дольше пяти секунд не продержаться» — это про то, как
|
|
|
|
|
|
// проходится этаж целиком.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Автопилот тупой намеренно: он ведёт курсор к спуску и всё. Он не отступает,
|
|
|
|
|
|
// не выбирает цели, не лечится вручную (лечение и так автоматическое). Это
|
|
|
|
|
|
// нижняя граница проходимости: игрок сыграет лучше. Если этаж не проходит даже
|
|
|
|
|
|
// автопилот — этаж непроходим.
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors,
|
|
|
|
|
|
int squadSize)
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile profile;
|
|
|
|
|
|
profile.ResetLoadouts();
|
|
|
|
|
|
profile.squadSize = std::max(1, std::min(NUM_AGENTS, squadSize));
|
|
|
|
|
|
profile.maxDepth = 100000; // чекпоинты не должны резать стартовую глубину
|
|
|
|
|
|
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.combatMode = true;
|
|
|
|
|
|
game.rooms = &rooms;
|
|
|
|
|
|
game.catalog = &catalog;
|
|
|
|
|
|
game.profile = &profile;
|
|
|
|
|
|
game.levelSeed = seed;
|
|
|
|
|
|
game.Init();
|
|
|
|
|
|
game.NewRun(1);
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("автопилот: отряд из %d, ведём к спуску, без отступлений\n\n",
|
|
|
|
|
|
profile.squadSize);
|
|
|
|
|
|
std::printf("%-6s %7s %7s %8s %8s %9s %8s %s\n", "DEPTH", "ТВАРЕЙ", "УБИЛ", "ЖИВЫХ",
|
|
|
|
|
|
"ЗДОРОВЬЕ", "ПАТРОНЫ", "СВЕТ", "ИТОГ");
|
|
|
|
|
|
|
|
|
|
|
|
int deepest = 0;
|
|
|
|
|
|
int wipedAt = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (int f = 0; f < floors && game.AliveAgents() > 0; ++f)
|
|
|
|
|
|
{
|
|
|
|
|
|
const int startMonsters = [&]() {
|
|
|
|
|
|
int n = 0;
|
|
|
|
|
|
for (const Target& t : game.targets)
|
|
|
|
|
|
if (t.alive && !t.IsProp() && !t.IsDummy()) ++n;
|
|
|
|
|
|
return n;
|
|
|
|
|
|
}();
|
|
|
|
|
|
|
|
|
|
|
|
// Поле расстояний до спуска. Считается один раз на этаж обычной
|
|
|
|
|
|
// заливкой по проходимым клеткам — тем же способом, каким генератор
|
|
|
|
|
|
// ищет саму точку спуска (engine/level_gen.cpp).
|
|
|
|
|
|
std::vector<int> flow(size_t(MAP_W) * MAP_H, -1);
|
|
|
|
|
|
{
|
|
|
|
|
|
std::vector<int> queue;
|
|
|
|
|
|
queue.reserve(size_t(MAP_W) * MAP_H);
|
|
|
|
|
|
const int ex = int(game.level.exitPos.x), ey = int(game.level.exitPos.y);
|
|
|
|
|
|
if (ex >= 0 && ey >= 0 && ex < MAP_W && ey < MAP_H)
|
|
|
|
|
|
{
|
|
|
|
|
|
flow[size_t(ey * MAP_W + ex)] = 0;
|
|
|
|
|
|
queue.push_back(ey * MAP_W + ex);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (size_t head = 0; head < queue.size(); ++head)
|
|
|
|
|
|
{
|
|
|
|
|
|
const int cur = queue[head];
|
|
|
|
|
|
const int x = cur % MAP_W, y = cur / MAP_W;
|
|
|
|
|
|
|
|
|
|
|
|
// Только четыре соседа, БЕЗ диагоналей. По диагонали между
|
|
|
|
|
|
// двумя углами стен путь есть на сетке, но нет в мире: тело
|
|
|
|
|
|
// круглое и в щель не пролезает. Первая версия автопилота
|
|
|
|
|
|
// ходила по восьми и намертво вставала в первом же таком
|
|
|
|
|
|
// проходе — четыре клетки от старта.
|
|
|
|
|
|
const int step4[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
|
|
|
|
|
for (const auto& d4 : step4)
|
|
|
|
|
|
{
|
|
|
|
|
|
const int dx = d4[0], dy = d4[1];
|
|
|
|
|
|
const int nx = x + dx, ny = y + dy;
|
|
|
|
|
|
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
|
|
|
|
|
|
if (game.map.IsWall(nx, ny)) continue;
|
|
|
|
|
|
int& cell = flow[size_t(ny * MAP_W + nx)];
|
|
|
|
|
|
if (cell >= 0) continue;
|
|
|
|
|
|
cell = flow[size_t(cur)] + 1;
|
|
|
|
|
|
queue.push_back(ny * MAP_W + nx);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Три минуты на этаж — с запасом: путь до спуска редко длиннее полутора.
|
|
|
|
|
|
constexpr int MAX_STEPS = 60 * 180;
|
|
|
|
|
|
bool reached = false;
|
|
|
|
|
|
int steps = 0;
|
|
|
|
|
|
int lastDist = -1; // на сколько клеток отряд был близок к спуску
|
|
|
|
|
|
int stuckFor = 0; // сколько шагов это число не меняется
|
|
|
|
|
|
|
|
|
|
|
|
for (; steps < MAX_STEPS && game.AliveAgents() > 0; ++steps)
|
|
|
|
|
|
{
|
|
|
|
|
|
// Куда идти — берём из ПОЛЯ РАССТОЯНИЙ до спуска, а не по прямой.
|
|
|
|
|
|
// Прямая упирается в первую же стену: у отряда нет поиска пути, он
|
|
|
|
|
|
// умеет только «иди туда». Первая версия автопилота именно так и
|
|
|
|
|
|
// застревала, показывая «этаж непроходим» там, где непроходимым был
|
|
|
|
|
|
// сам автопилот.
|
|
|
|
|
|
// Ведём КУРСОР, а не центр отряда: игрок водит именно его, а центр
|
|
|
|
|
|
// пятерых вообще может оказаться внутри стены, пока сами бойцы
|
|
|
|
|
|
// обходят её с двух сторон. На этом автопилот с пятёркой вставал на
|
|
|
|
|
|
// первом же этаже, хотя одиночка тот же этаж проходил.
|
|
|
|
|
|
const Vec2 c = game.squad.anchor;
|
|
|
|
|
|
const int cx = int(c.x), cy = int(c.y);
|
|
|
|
|
|
const int here = (cx >= 0 && cy >= 0 && cx < MAP_W && cy < MAP_H)
|
|
|
|
|
|
? flow[size_t(cy * MAP_W + cx)]
|
|
|
|
|
|
: -1;
|
|
|
|
|
|
|
|
|
|
|
|
// Цель — ЦЕНТР соседней клетки, а не «ось». По оси курсор дёргается
|
|
|
|
|
|
// между двумя клетками на границе и стоит на месте; по центру он
|
|
|
|
|
|
// идёт ровно.
|
|
|
|
|
|
// Цель — точка на НЕСКОЛЬКО клеток вперёд по спуску поля, а не
|
|
|
|
|
|
// соседняя клетка. По соседней курсор дёргается между двумя
|
|
|
|
|
|
// вариантами на каждой границе и стоит на месте; дальняя точка
|
|
|
|
|
|
// тянет ровно, как рука игрока.
|
|
|
|
|
|
int bestDist = here;
|
|
|
|
|
|
Vec2 goal = c;
|
|
|
|
|
|
{
|
|
|
|
|
|
int wx = cx, wy = cy;
|
|
|
|
|
|
for (int hop = 0; hop < 6; ++hop)
|
|
|
|
|
|
{
|
|
|
|
|
|
int nextX = wx, nextY = wy;
|
|
|
|
|
|
int nextD = flow[size_t(wy * MAP_W + wx)];
|
|
|
|
|
|
const int look4[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
|
|
|
|
|
|
for (const auto& l4 : look4)
|
|
|
|
|
|
{
|
|
|
|
|
|
const int nx = wx + l4[0], ny = wy + l4[1];
|
|
|
|
|
|
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
|
|
|
|
|
|
const int d = flow[size_t(ny * MAP_W + nx)];
|
|
|
|
|
|
if (d < 0 || (nextD >= 0 && d >= nextD)) continue;
|
|
|
|
|
|
nextD = d;
|
|
|
|
|
|
nextX = nx;
|
|
|
|
|
|
nextY = ny;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (nextX == wx && nextY == wy) break;
|
|
|
|
|
|
wx = nextX;
|
|
|
|
|
|
wy = nextY;
|
|
|
|
|
|
bestDist = nextD;
|
|
|
|
|
|
}
|
|
|
|
|
|
goal = Vec2{float(wx) + 0.5f, float(wy) + 0.5f};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Застряли — идём вторым по качеству соседом. Отряд упирается в
|
|
|
|
|
|
// угол или в собственную толпу, и «лучшее направление» перестаёт
|
|
|
|
|
|
// быть проходимым; живой игрок в этот момент просто обходит.
|
|
|
|
|
|
(void)bestDist;
|
|
|
|
|
|
if (here >= 0 && here == lastDist) ++stuckFor;
|
|
|
|
|
|
else stuckFor = 0;
|
|
|
|
|
|
lastDist = here;
|
|
|
|
|
|
|
|
|
|
|
|
game.in = InputState{};
|
|
|
|
|
|
const Vec2 toGoal = goal - c;
|
|
|
|
|
|
if (LengthSq(toGoal) > 1e-4f)
|
|
|
|
|
|
game.in.moveScreen = v2d::WorldDirToScreen(Normalized(toGoal));
|
|
|
|
|
|
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
if (game.atExit)
|
|
|
|
|
|
{
|
|
|
|
|
|
reached = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int aliveNow = 0;
|
|
|
|
|
|
for (const Target& t : game.targets)
|
|
|
|
|
|
if (t.alive && !t.IsProp() && !t.IsDummy()) ++aliveNow;
|
|
|
|
|
|
|
|
|
|
|
|
float hp = 0.0f, maxHp = 0.0f;
|
|
|
|
|
|
int ammo = 0;
|
|
|
|
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
const Agent& a = game.squad.agents[i];
|
|
|
|
|
|
if (!a.present) continue;
|
|
|
|
|
|
hp += std::max(0.0f, a.hp);
|
|
|
|
|
|
maxHp += a.maxHp;
|
|
|
|
|
|
ammo += std::max(0, a.ammoReserve);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const char* verdict = reached ? "дошёл" : (game.AliveAgents() > 0 ? "ЗАСТРЯЛ" : "ВАЙП");
|
|
|
|
|
|
std::printf("%-6d %7d %7d %8d %7.0f%% %9d %7.0f %s (%.0f с)\n", game.depth, startMonsters,
|
|
|
|
|
|
startMonsters - aliveNow, game.AliveAgents(), double(100.0f * hp / std::max(1.0f, maxHp)),
|
|
|
|
|
|
ammo, double(game.squad.lightCharge), verdict, double(steps) * double(FIXED_DT));
|
|
|
|
|
|
|
|
|
|
|
|
if (game.AliveAgents() <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
wipedAt = game.depth;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
deepest = game.depth;
|
|
|
|
|
|
if (!reached) break; // застрял — дальше мерить нечего
|
|
|
|
|
|
game.Descend();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nпройдено этажей: %d", deepest);
|
|
|
|
|
|
if (wipedAt > 0) std::printf(", вайп на D%d", wipedAt);
|
|
|
|
|
|
std::printf("\n");
|
|
|
|
|
|
|
|
|
|
|
|
int bad = 0;
|
|
|
|
|
|
if (deepest < 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] автопилот не проходит ПЕРВЫЙ этаж — игра непроходима\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (wipedAt == 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] отряд гибнет на первом этаже\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
return bad == 0 ? 0 : 1;
|
|
|
|
|
|
}
|