squad-proto/src/debug/harness.cpp

2834 lines
147 KiB
C++
Raw Normal View History

Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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>
Версия профиля и миграция (E1) Формат профиля ломался дважды за три вехи — когда сумка стала общей и когда появились улучшения. Оба раза старый файл читался молча и частично, а игрок видел это как «схрон куда-то делся». Впереди аффиксы, которые сломают формат третий раз, поэтому версия заводится до них, а не после. Правило чтения несимметрично намеренно: файл СТАРШЕ игры — читается с миграцией (версии нет = формат 1) файл НОВЕЕ игры — не читается вовсе, с объяснением в сообщении Второе важнее первого. Молча обнулить профиль, записанный более новой сборкой, значит стереть десятки часов; отказ читать всего лишь просит не запускать старую сборку. Проверяется обеими сторонами в --descend: поддельный профиль формата 1 читается и сохраняет глубину и схрон, поддельный профиль из будущего отвергается. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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"
Дерево улучшений вместо четырёх кнопок (M32) Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни на что не влиял, и через две вылазки экран превращался в кнопку "продолжить". Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии, снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят предыдущий в этой же ветви. Свободная сетка читалась бы как список; цепочка читается как путь. Ни один узел не заводит нового канала влияния: каждый кладёт своё число туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый же "особенный" узел со своей веткой в коде превратил бы SyncLoadout в свалку. ЭКОНОМИКА. --tool economy поймал, что первая версия дерева стоит 9% одного прохода 1..100: опыт растёт с глубиной квадратично, а дерево с фиксированными ценами выкупается целиком за половину глубокого прохода, и выбор кончается к сороковому этажу навсегда. Введено общее удорожание: каждая вложенная точка поднимает цену всех следующих (цена = база · 1.5^уровень · (1 + 0.15 · вложено)). Полное дерево стоит 34 млн против 41 млн, заложенных в дизайне, то есть около шести с половиной полных проходов. Старые покупки из профиля читаются, но не применяются: потраченный на них опыт возвращается в банк. Молча съесть чужие двадцать часов нельзя. Проверка меряет ПРОВОДКУ по всем пяти ветвям, а не таблицу узлов: дерево: куплено уровней 45, закрытый узел закрыт урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22 парирование 0.34 -> 0.49, свет 420 -> 620 Попутно: в проекте hollow бегуны и хваты уехали с первых этажей глубже - там одиночка с ножом гиб на первом же этаже по той же причине, по которой это было в основном проекте. Формат профиля 7: строки tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:24:50 +07:00
#include "sim/tree.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;
Дерево улучшений вместо четырёх кнопок (M32) Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни на что не влиял, и через две вылазки экран превращался в кнопку "продолжить". Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии, снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят предыдущий в этой же ветви. Свободная сетка читалась бы как список; цепочка читается как путь. Ни один узел не заводит нового канала влияния: каждый кладёт своё число туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый же "особенный" узел со своей веткой в коде превратил бы SyncLoadout в свалку. ЭКОНОМИКА. --tool economy поймал, что первая версия дерева стоит 9% одного прохода 1..100: опыт растёт с глубиной квадратично, а дерево с фиксированными ценами выкупается целиком за половину глубокого прохода, и выбор кончается к сороковому этажу навсегда. Введено общее удорожание: каждая вложенная точка поднимает цену всех следующих (цена = база · 1.5^уровень · (1 + 0.15 · вложено)). Полное дерево стоит 34 млн против 41 млн, заложенных в дизайне, то есть около шести с половиной полных проходов. Старые покупки из профиля читаются, но не применяются: потраченный на них опыт возвращается в банк. Молча съесть чужие двадцать часов нельзя. Проверка меряет ПРОВОДКУ по всем пяти ветвям, а не таблицу узлов: дерево: куплено уровней 45, закрытый узел закрыт урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22 парирование 0.34 -> 0.49, свет 420 -> 620 Попутно: в проекте hollow бегуны и хваты уехали с первых этажей глубже - там одиночка с ножом гиб на первом же этаже по той же причине, по которой это было в основном проекте. Формат профиля 7: строки tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:24:50 +07:00
for (int i = 0; i < TREE_NODES; i += 7) profile.node[i] = 1 + (i % 3);
Отряд растёт с одного оперативника до пяти (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.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(), "схрон");
Дерево улучшений вместо четырёх кнопок (M32) Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни на что не влиял, и через две вылазки экран превращался в кнопку "продолжить". Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии, снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят предыдущий в этой же ветви. Свободная сетка читалась бы как список; цепочка читается как путь. Ни один узел не заводит нового канала влияния: каждый кладёт своё число туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый же "особенный" узел со своей веткой в коде превратил бы SyncLoadout в свалку. ЭКОНОМИКА. --tool economy поймал, что первая версия дерева стоит 9% одного прохода 1..100: опыт растёт с глубиной квадратично, а дерево с фиксированными ценами выкупается целиком за половину глубокого прохода, и выбор кончается к сороковому этажу навсегда. Введено общее удорожание: каждая вложенная точка поднимает цену всех следующих (цена = база · 1.5^уровень · (1 + 0.15 · вложено)). Полное дерево стоит 34 млн против 41 млн, заложенных в дизайне, то есть около шести с половиной полных проходов. Старые покупки из профиля читаются, но не применяются: потраченный на них опыт возвращается в банк. Молча съесть чужие двадцать часов нельзя. Проверка меряет ПРОВОДКУ по всем пяти ветвям, а не таблицу узлов: дерево: куплено уровней 45, закрытый узел закрыт урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22 парирование 0.34 -> 0.49, свет 420 -> 620 Попутно: в проекте hollow бегуны и хваты уехали с первых этажей глубже - там одиночка с ножом гиб на первом же этаже по той же причине, по которой это было в основном проекте. Формат профиля 7: строки tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:24:50 +07:00
for (int i = 0; i < TREE_NODES; ++i)
same(back.node[i] == profile.node[i], TreeNodeAt(i).name);
Редкости и аффиксы: находка стала решением (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);
}
Версия профиля и миграция (E1) Формат профиля ломался дважды за три вехи — когда сумка стала общей и когда появились улучшения. Оба раза старый файл читался молча и частично, а игрок видел это как «схрон куда-то делся». Впереди аффиксы, которые сломают формат третий раз, поэтому версия заводится до них, а не после. Правило чтения несимметрично намеренно: файл СТАРШЕ игры — читается с миграцией (версии нет = формат 1) файл НОВЕЕ игры — не читается вовсе, с объяснением в сообщении Второе важнее первого. Молча обнулить профиль, записанный более новой сборкой, значит стереть десятки часов; отказ читать всего лишь просит не запускать старую сборку. Проверяется обеими сторонами в --descend: поддельный профиль формата 1 читается и сохраняет глубину и схрон, поддельный профиль из будущего отвергается. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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;
}
Версия профиля и миграция (E1) Формат профиля ломался дважды за три вехи — когда сумка стала общей и когда появились улучшения. Оба раза старый файл читался молча и частично, а игрок видел это как «схрон куда-то делся». Впереди аффиксы, которые сломают формат третий раз, поэтому версия заводится до них, а не после. Правило чтения несимметрично намеренно: файл СТАРШЕ игры — читается с миграцией (версии нет = формат 1) файл НОВЕЕ игры — не читается вовсе, с объяснением в сообщении Второе важнее первого. Молча обнулить профиль, записанный более новой сборкой, значит стереть десятки часов; отказ читать всего лишь просит не запускать старую сборку. Проверяется обеими сторонами в --descend: поддельный профиль формата 1 читается и сохраняет глубину и схрон, поддельный профиль из будущего отвергается. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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;
}
Крафт стал выбором: закалка, отпечаток, направленные операции (M33) Прежние пять операций были СЛЕПЫМИ: перековка бросала всё заново, прививка ставила случайный тег, отсечение снимало последний аффикс, возвышение поднимало случайный. Игрок не принимал ни одного решения - жал кнопку и смотрел, что выпало. Это не крафт, это второй бросок предмета за материалы. Теперь половина операций направленные: игрок показывает пальцем, какой аффикс трогать, и платит за это вдвое - вот это уже решение ("перебросить всё дёшево или снять один дорого"). Два новых глагола: ЗАКАЛКА двигает рулон выбранного аффикса к максимуму его тира на треть остатка. Дёшево, понятно, повторяемо - то самое "доработать почти хорошую вещь". Тир не трогает: за ступень платят возвышением. ОТПЕЧАТОК переносит лучший аффикс с ДРУГОЙ вещи, сжигая её. Именно он превращает хлам из схрона в ресурс, а не в мусор, который лежит. Правило "один тег один раз" им не обходится, бюджет проверяется. В мастерской: клик по вещи кладёт её на верстак, повторный клик по той же назначает донором (помечается кровью - он сгорит, и видеть это надо ДО нажатия). Строки аффиксов стали кнопками: по ним выбирается цель. Проверка меряет НАПРАВЛЕННОСТЬ, а не факт срабатывания: направленность: отсечение по цели, возвышение по цели, закалка двигает рулон, отпечаток перенёс и сжёг донора Отсечение пробуется на СРЕДНЕМ аффиксе из трёх: операция, которая "работает", снимая последний, прошла бы и слепую версию. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:37:07 +07:00
// 4.5. НАПРАВЛЕННОСТЬ. Половина операций работает по ВЫБРАННОМУ аффиксу,
// и это главное, чего не было в первой версии крафта: там игрок
// жал кнопку и смотрел, что выпало.
{
ItemInstance aim(ItemId::VEST);
aim.rarity = Rarity::PROTOTYPE;
aim.affix[aim.count++] = Affix{AffixTag::HEALTH, 2, 0, 10.0f};
aim.affix[aim.count++] = Affix{AffixTag::ARMOR, 2, 0, 0.05f};
aim.affix[aim.count++] = Affix{AffixTag::CARRY, 2, 0, 0.40f};
// Отсечение снимает именно средний, а не последний.
const CraftResult cut = ApplyCraft(CraftOp::EXCISE, aim, pr.material, 40, rng, 1);
const bool cutRight = cut == CraftResult::OK && aim.Count() == 2 &&
aim.affix[0].tag == AffixTag::HEALTH &&
aim.affix[1].tag == AffixTag::CARRY;
// Возвышение поднимает именно указанный.
const int tierBefore = aim.affix[1].tier;
const CraftResult up = ApplyCraft(CraftOp::EXALT, aim, pr.material, 40, rng, 1);
const bool upRight = up == CraftResult::OK && aim.affix[1].tier == tierBefore + 1 &&
aim.affix[0].tier == 2;
// Закалка двигает рулон вверх, не трогая тир.
const float rollBefore = aim.affix[0].value;
const int tierKept = aim.affix[0].tier;
const CraftResult tmp = ApplyCraft(CraftOp::TEMPER, aim, pr.material, 40, rng, 0);
const bool tempRight = tmp == CraftResult::OK && aim.affix[0].value > rollBefore &&
aim.affix[0].tier == tierKept;
// Отпечаток переносит аффикс донора и сжигает его.
ItemInstance donor(ItemId::PLATE);
donor.rarity = Rarity::RELIC;
donor.affix[donor.count++] = Affix{AffixTag::SPEED, 4, 0, 0.12f};
const int haveBefore = aim.Count();
const CraftResult imp =
ApplyCraft(CraftOp::IMPRINT, aim, pr.material, 40, rng, 0, &donor);
const bool impRight = imp == CraftResult::OK && aim.Count() == haveBefore + 1 &&
donor.Empty();
std::printf("направленность: отсечение %s, возвышение %s, закалка %s, отпечаток %s\n",
cutRight ? "по цели" : "МИМО", upRight ? "по цели" : "МИМО",
tempRight ? "двигает рулон" : "НЕ РАБОТАЕТ",
impRight ? "перенёс и сжёг донора" : "НЕ СРАБОТАЛ");
if (!cutRight || !upRight)
{
std::printf(" [FAIL] направленная операция трогает не тот аффикс\n");
++bad;
}
if (!tempRight)
{
std::printf(" [FAIL] закалка не двигает рулон или трогает тир\n");
++bad;
}
if (!impRight)
{
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
// 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;
}
}
Дерево улучшений вместо четырёх кнопок (M32) Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни на что не влиял, и через две вылазки экран превращался в кнопку "продолжить". Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии, снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят предыдущий в этой же ветви. Свободная сетка читалась бы как список; цепочка читается как путь. Ни один узел не заводит нового канала влияния: каждый кладёт своё число туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый же "особенный" узел со своей веткой в коде превратил бы SyncLoadout в свалку. ЭКОНОМИКА. --tool economy поймал, что первая версия дерева стоит 9% одного прохода 1..100: опыт растёт с глубиной квадратично, а дерево с фиксированными ценами выкупается целиком за половину глубокого прохода, и выбор кончается к сороковому этажу навсегда. Введено общее удорожание: каждая вложенная точка поднимает цену всех следующих (цена = база · 1.5^уровень · (1 + 0.15 · вложено)). Полное дерево стоит 34 млн против 41 млн, заложенных в дизайне, то есть около шести с половиной полных проходов. Старые покупки из профиля читаются, но не применяются: потраченный на них опыт возвращается в банк. Молча съесть чужие двадцать часов нельзя. Проверка меряет ПРОВОДКУ по всем пяти ветвям, а не таблицу узлов: дерево: куплено уровней 45, закрытый узел закрыт урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22 парирование 0.34 -> 0.49, свет 420 -> 620 Попутно: в проекте hollow бегуны и хваты уехали с первых этажей глубже - там одиночка с ножом гиб на первом же этаже по той же причине, по которой это было в основном проекте. Формат профиля 7: строки tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:24:50 +07:00
// -------------------------------------------------------------------------
// Дерево улучшений. Проверяется ПРОВОДКА до боя, а не таблица узлов: узел,
// который красиво нарисован и ничего не делает, выглядит снаружи ровно как
// работающий. На этом уже дважды попались вес и стихии.
//
// Берётся по узлу из каждой ветви, вкачивается до потолка, и сверяются
// числа НА БОЙЦЕ и в вылазке — до и после.
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;
const Profile saved = pr;
game.NewRun(1);
game.Step(FIXED_DT);
const Agent& a0 = game.squad.agents[0];
const float dmgBare = a0.gun.def.damage;
const float hpBare = a0.maxHp;
const float speedBare = a0.speedMul;
const float parryBare = a0.melee.parry;
const float lightBare = game.squad.lightCharge;
// Ветвь ствола до «глубоких карманов», ветвь клинка до парирования,
// снабжение до фонаря, отряд до здоровья. Цепочка соблюдается: узел
// открывается предыдущим, и проверка обязана идти тем же путём, каким
// идёт игрок.
pr.bankedXp = 1e9f;
int bought = 0;
const int path[] = {
TreeIndex(Branch::GUN, 0), TreeIndex(Branch::GUN, 1),
TreeIndex(Branch::GUN, 2), TreeIndex(Branch::BLADE, 0),
TreeIndex(Branch::BLADE, 1), TreeIndex(Branch::SUPPLY, 0),
TreeIndex(Branch::SQUAD, 0), TreeIndex(Branch::SQUAD, 1),
TreeIndex(Branch::SQUAD, 2),
};
for (const int n : path)
for (int r = 0; r < TreeNodeAt(n).maxRank; ++r)
if (pr.BuyNode(n)) ++bought;
// Закрытый узел не покупается ни за какие деньги: цепочка — это правило,
// а не подсказка.
const int locked = TreeIndex(Branch::ELEMENT, 5);
const bool boughtLocked = pr.BuyNode(locked);
game.NewRun(1);
game.Step(FIXED_DT);
const Agent& a1 = game.squad.agents[0];
const TreeBonus tb = pr.Tree();
std::printf("\nдерево: куплено уровней %d, закрытый узел %s\n"
" урон ствола %.1f -> %.1f, здоровье %.0f -> %.0f,"
" скорость %.2f -> %.2f\n"
" парирование %.2f -> %.2f, свет %.0f -> %.0f\n",
bought, boughtLocked ? "КУПИЛСЯ" : "закрыт", double(dmgBare),
double(a1.gun.def.damage), double(hpBare), double(a1.maxHp), double(speedBare),
double(a1.speedMul), double(parryBare), double(a1.melee.parry),
double(lightBare), double(game.squad.lightCharge));
if (bought <= 0)
{
std::printf(" [FAIL] ни один узел не купился при полном банке\n");
++bad;
}
if (boughtLocked)
{
std::printf(" [FAIL] закрытый узел купился — цепочка ветви не держит\n");
++bad;
}
if (a1.gun.def.damage <= dmgBare)
{
std::printf(" [FAIL] ветвь ствола не дошла до оружия\n");
++bad;
}
if (a1.maxHp <= hpBare)
{
std::printf(" [FAIL] ветвь отряда не дошла до здоровья\n");
++bad;
}
if (a1.speedMul <= speedBare)
{
std::printf(" [FAIL] ветвь отряда не дошла до скорости\n");
++bad;
}
if (a1.melee.parry <= parryBare)
{
std::printf(" [FAIL] ветвь клинка не дошла до парирования\n");
++bad;
}
if (game.squad.lightCharge <= lightBare)
{
std::printf(" [FAIL] ветвь снабжения не дошла до фонаря\n");
++bad;
}
if (tb.gunDamage <= 0.0f || tb.health <= 0.0f)
{
std::printf(" [FAIL] сумма дерева пустая при купленных узлах\n");
++bad;
}
pr = saved;
game.NewRun(1);
}
Свет как расходник: заряд тратится за путь, а не за время (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;
Дерево улучшений вместо четырёх кнопок (M32) Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни на что не влиял, и через две вылазки экран превращался в кнопку "продолжить". Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии, снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят предыдущий в этой же ветви. Свободная сетка читалась бы как список; цепочка читается как путь. Ни один узел не заводит нового канала влияния: каждый кладёт своё число туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый же "особенный" узел со своей веткой в коде превратил бы SyncLoadout в свалку. ЭКОНОМИКА. --tool economy поймал, что первая версия дерева стоит 9% одного прохода 1..100: опыт растёт с глубиной квадратично, а дерево с фиксированными ценами выкупается целиком за половину глубокого прохода, и выбор кончается к сороковому этажу навсегда. Введено общее удорожание: каждая вложенная точка поднимает цену всех следующих (цена = база · 1.5^уровень · (1 + 0.15 · вложено)). Полное дерево стоит 34 млн против 41 млн, заложенных в дизайне, то есть около шести с половиной полных проходов. Старые покупки из профиля читаются, но не применяются: потраченный на них опыт возвращается в банк. Молча съесть чужие двадцать часов нельзя. Проверка меряет ПРОВОДКУ по всем пяти ветвям, а не таблицу узлов: дерево: куплено уровней 45, закрытый узел закрыт урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22 парирование 0.34 -> 0.49, свет 420 -> 620 Попутно: в проекте hollow бегуны и хваты уехали с первых этажей глубже - там одиночка с ножом гиб на первом же этаже по той же причине, по которой это было в основном проекте. Формат профиля 7: строки tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:24:50 +07:00
// Узлы: боезапас (ветвь ствола) и здоровье (ветвь отряда).
const int nodeAmmo = TreeIndex(Branch::GUN, 2);
const int nodeHealth = TreeIndex(Branch::SQUAD, 0);
// Цепочка: до «глубоких карманов» надо взять два узла над ними.
pr.BuyNode(TreeIndex(Branch::GUN, 0));
pr.BuyNode(TreeIndex(Branch::GUN, 1));
const bool boughtAmmo = pr.BuyNode(nodeAmmo);
const bool boughtVigor = pr.BuyNode(nodeHealth);
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 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;
Дерево улучшений вместо четырёх кнопок (M32) Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни на что не влиял, и через две вылазки экран превращался в кнопку "продолжить". Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии, снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят предыдущий в этой же ветви. Свободная сетка читалась бы как список; цепочка читается как путь. Ни один узел не заводит нового канала влияния: каждый кладёт своё число туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый же "особенный" узел со своей веткой в коде превратил бы SyncLoadout в свалку. ЭКОНОМИКА. --tool economy поймал, что первая версия дерева стоит 9% одного прохода 1..100: опыт растёт с глубиной квадратично, а дерево с фиксированными ценами выкупается целиком за половину глубокого прохода, и выбор кончается к сороковому этажу навсегда. Введено общее удорожание: каждая вложенная точка поднимает цену всех следующих (цена = база · 1.5^уровень · (1 + 0.15 · вложено)). Полное дерево стоит 34 млн против 41 млн, заложенных в дизайне, то есть около шести с половиной полных проходов. Старые покупки из профиля читаются, но не применяются: потраченный на них опыт возвращается в банк. Молча съесть чужие двадцать часов нельзя. Проверка меряет ПРОВОДКУ по всем пяти ветвям, а не таблицу узлов: дерево: куплено уровней 45, закрытый узел закрыт урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22 парирование 0.34 -> 0.49, свет 420 -> 620 Попутно: в проекте hollow бегуны и хваты уехали с первых этажей глубже - там одиночка с ножом гиб на первом же этаже по той же причине, по которой это было в основном проекте. Формат профиля 7: строки tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:24:50 +07:00
const int first = TreeIndex(Branch::GUN, 0);
const int lvlBefore = profile.NodeRank(first);
UiActivate(ui, game, UiPart::UPGRADE_ITEM, first);
const bool bought = profile.NodeRank(first) > lvlBefore;
Пункт 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
Правки по игре: начало, ставка, наёмники (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;
}