Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).
Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.
Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.
Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».
Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.
Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.
Документация — CLAUDE.md как оглавление, содержание в docs/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
|
|
|
|
#include "debug/harness.h"
|
|
|
|
|
|
|
|
|
|
|
|
#include <chrono>
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
#include <filesystem>
|
2026-08-14 01:16:08 +07:00
|
|
|
|
#include <fstream>
|
Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).
Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.
Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.
Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».
Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.
Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.
Документация — CLAUDE.md как оглавление, содержание в docs/.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
|
|
|
|
#include <cstdarg>
|
|
|
|
|
|
#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"
|
Пункт 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
|
|
|
|
// Итог забега снимается ЗДЕСЬ, пока он ещё правда. Ниже идут проверки,
|
|
|
|
|
|
// которые начинают новую вылазку (найм, улучшения) и обнуляют счётчик
|
|
|
|
|
|
// этажей: отчитываться цифрой, которую испортил измеряющий, уже приходилось
|
|
|
|
|
|
// дважды — сперва с улучшениями, потом с размером отряда.
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Вынесено считается по ВСЕМУ снаряжению, а не только по сумкам, поэтому
|
|
|
|
|
|
// проверяем неравенством: меньше содержимого сумок быть не может.
|
|
|
|
|
|
if (r.itemsBrought < bagsBefore)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] в сумках было %d, до профиля доехало %d\n", bagsBefore,
|
|
|
|
|
|
r.itemsBrought);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (profile.maxDepth < game.depth)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] глубина %d не записалась в профиль (%d)\n", game.depth,
|
|
|
|
|
|
profile.maxDepth);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Круговой прогон профиля через ЗАПИСЬ. Здесь он не для полноты: запись
|
|
|
|
|
|
// профиля — единственный путь, которым игра может стереть всё нажитое, и
|
|
|
|
|
|
// ошибка в нём выглядит как «схрон куда-то делся», а не как падение.
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string tmp =
|
|
|
|
|
|
(std::filesystem::temp_directory_path() / "squad_profile_roundtrip.txt").string();
|
|
|
|
|
|
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// Прогон делается на НАБИТОМ профиле, а не на том, что получился сам.
|
|
|
|
|
|
// Пустое поле сходится с пустым при любой поломке записи: улучшения
|
|
|
|
|
|
// стояли в нулях весь прогон, и проверка «0 == 0» три вехи подряд
|
|
|
|
|
|
// подтверждала работу того, чего в файле не было вовсе.
|
|
|
|
|
|
const Profile saved = profile;
|
|
|
|
|
|
for (int u = 0; u < int(Upgrade::COUNT); ++u) profile.upgrade[u] = 1 + u % UPGRADE_MAX_LEVEL;
|
|
|
|
|
|
profile.squadSize = 3;
|
|
|
|
|
|
profile.runs = 11;
|
|
|
|
|
|
profile.extractions = 7;
|
|
|
|
|
|
profile.wipes = 4;
|
|
|
|
|
|
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
// И ВЕЩЬ С АФФИКСАМИ — в снаряжении и в схроне. Без неё круговой прогон
|
|
|
|
|
|
// сверял бы предметы без рулонов, то есть ровно ту часть формата,
|
|
|
|
|
|
// которая работала и раньше.
|
|
|
|
|
|
ItemInstance relic(ItemId::MAGNUM);
|
|
|
|
|
|
relic.rarity = Rarity::RELIC;
|
|
|
|
|
|
relic.affix[relic.count++] = Affix{AffixTag::DAMAGE, 7, 0, 0.6321f};
|
|
|
|
|
|
relic.affix[relic.count++] = Affix{AffixTag::CONVERT, 5, uint8_t(Element::VOID), 0.2718f};
|
|
|
|
|
|
relic.affix[relic.count++] = Affix{AffixTag::AMMO, 3, 0, 24.0f};
|
|
|
|
|
|
profile.loadout[0].weapon = relic;
|
|
|
|
|
|
profile.StashPush(relic);
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
std::string why;
|
|
|
|
|
|
Profile back;
|
|
|
|
|
|
if (!profile.Save(tmp, &why))
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] профиль не пишется: %s\n", why.c_str());
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (!back.Load(tmp, &why))
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] профиль не читается обратно: %s\n", why.c_str());
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// Сверяется ВСЁ, что есть в профиле, а не три поля из десяти.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Прежняя версия проверки смотрела глубину, схрон и снаряжение — и
|
|
|
|
|
|
// ровно поэтому три вехи не замечала, что `Save` не писала ни
|
|
|
|
|
|
// версию, ни улучшения. Купленное действовало до конца запуска и
|
|
|
|
|
|
// молча пропадало, а круговой прогон при этом бодро сходился.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Правило на будущее: круговой прогон обязан сверять поля СПИСКОМ,
|
|
|
|
|
|
// а список — расти вместе с форматом. Выборочная сверка проверяет
|
|
|
|
|
|
// не формат, а память того, кто её писал.
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
int diff = 0;
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
auto same = [&](bool ok, const char* what) {
|
|
|
|
|
|
if (ok) return;
|
|
|
|
|
|
std::printf(" [FAIL] не пережило запись: %s\n", what);
|
|
|
|
|
|
++diff;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
same(back.version == PROFILE_VERSION, "версия формата");
|
|
|
|
|
|
same(back.maxDepth == profile.maxDepth, "глубина");
|
|
|
|
|
|
same(back.squadSize == profile.squadSize, "размер отряда");
|
|
|
|
|
|
same(back.runs == profile.runs, "счётчик вылазок");
|
|
|
|
|
|
same(back.extractions == profile.extractions, "счётчик эвакуаций");
|
|
|
|
|
|
same(back.wipes == profile.wipes, "счётчик вайпов");
|
|
|
|
|
|
same(int(back.bankedXp) == int(profile.bankedXp), "опыт в банке");
|
|
|
|
|
|
same(back.StashCount() == profile.StashCount(), "схрон");
|
|
|
|
|
|
for (int u = 0; u < int(Upgrade::COUNT); ++u)
|
|
|
|
|
|
same(back.upgrade[u] == profile.upgrade[u], UpgradeName(Upgrade(u)));
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
// Предметы сверяются ЦЕЛИКОМ, с рулонами. Сравнение через ItemId
|
|
|
|
|
|
// здесь молчаливо прошло бы: неявная конверсия видит только вид
|
|
|
|
|
|
// вещи, и реликт, доехавший до профиля обычным, выглядел бы как
|
|
|
|
|
|
// совпадение (sim/items.h).
|
|
|
|
|
|
auto sameItem = [&](const ItemInstance& x, const ItemInstance& y, const char* what) {
|
|
|
|
|
|
bool ok = (x.id == y.id) && (x.rarity == y.rarity) && (x.count == y.count);
|
|
|
|
|
|
for (int k = 0; ok && k < x.Count(); ++k)
|
|
|
|
|
|
ok = x.affix[k].tag == y.affix[k].tag && x.affix[k].tier == y.affix[k].tier &&
|
|
|
|
|
|
x.affix[k].param == y.affix[k].param &&
|
|
|
|
|
|
std::fabs(x.affix[k].value - y.affix[k].value) < 0.001f;
|
|
|
|
|
|
same(ok, what);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
for (int a = 0; a < NUM_AGENTS; ++a)
|
|
|
|
|
|
{
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
sameItem(profile.loadout[a].weapon, back.loadout[a].weapon, "оружие бойца");
|
|
|
|
|
|
sameItem(profile.loadout[a].armor, back.loadout[a].armor, "броня бойца");
|
|
|
|
|
|
sameItem(profile.loadout[a].trinket, back.loadout[a].trinket, "карман бойца");
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
}
|
Редкости и аффиксы: находка стала решением (M25)
Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.
Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.
ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.
Замеры поймали три расхождения дизайн-документа с самим собой:
* формула редкости противоречила таблице рядом с ней: 2.7% находок с
аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
до схождения с таблицей на дне (12% обычных на сотом);
* бюджет пускал восемь высших аффиксов вместо обещанных шести;
* предельная вещь собиралась к семидесятому этажу вместо сотого.
Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.
Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.
Попутно, на глубине 30 и только там:
* «зачистка поля» в проверках не держалась — у твари есть откат
воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
вне схватки и расход света на стоянке молча мерили бой. На восьмом
этаже тварей мало, и проверка проходила по удаче;
* порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
списывается только в ADVANCING: топтание и драка бесплатны, тратит
РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.
Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.
--accept PASS без изменений: в песочнице находок нет вовсе.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:35:00 +07:00
|
|
|
|
for (int k = 0; k < profile.StashCount() && k < back.StashCount(); ++k)
|
|
|
|
|
|
sameItem(profile.stash[size_t(k)], back.stash[size_t(k)], "вещь из схрона");
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; }
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
else std::printf("профиль: запись и чтение сходятся по всем полям\n");
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// Профиль возвращается как был: дальше он ещё нужен настоящим.
|
|
|
|
|
|
profile = saved;
|
|
|
|
|
|
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
std::filesystem::remove(tmp, ec);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 01:16:08 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
// Версия профиля. Формат ломался дважды за три вехи, и оба раза старый файл
|
|
|
|
|
|
// читался молча и частично. Проверяются оба направления: старый читается с
|
|
|
|
|
|
// миграцией, файл из будущего НЕ читается вовсе.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::filesystem::path old = std::filesystem::temp_directory_path() / "t2d_v1.txt";
|
|
|
|
|
|
{
|
|
|
|
|
|
std::ofstream f(old);
|
|
|
|
|
|
// Формат 1: версии нет, у бойца шесть посумочных лексем.
|
|
|
|
|
|
f << "maxdepth 7\nruns 3\nxp 4200\n";
|
|
|
|
|
|
f << "agent 0 rifle vest charm bandage stimpack - - - -\n";
|
|
|
|
|
|
f << "stash saber plate\n";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Profile v1;
|
|
|
|
|
|
std::string why;
|
|
|
|
|
|
const bool readOld = v1.Load(old.string(), &why);
|
|
|
|
|
|
|
|
|
|
|
|
const std::filesystem::path future =
|
|
|
|
|
|
std::filesystem::temp_directory_path() / "t2d_future.txt";
|
|
|
|
|
|
{
|
|
|
|
|
|
std::ofstream f(future);
|
|
|
|
|
|
f << "version " << (PROFILE_VERSION + 1) << "\nmaxdepth 99\n";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Profile vNext;
|
|
|
|
|
|
std::string whyFuture;
|
|
|
|
|
|
const bool readFuture = vNext.Load(future.string(), &whyFuture);
|
|
|
|
|
|
|
|
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
std::filesystem::remove(old, ec);
|
|
|
|
|
|
std::filesystem::remove(future, ec);
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nпрофиль: старый формат %s (глубина %d, схрон %d), из будущего %s\n",
|
|
|
|
|
|
readOld ? "прочитан" : "НЕ прочитан", v1.maxDepth, v1.StashCount(),
|
|
|
|
|
|
readFuture ? "ПРОЧИТАН" : "отвергнут");
|
|
|
|
|
|
|
|
|
|
|
|
if (!readOld || v1.maxDepth != 7 || v1.StashCount() != 2)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] миграция со старого формата потеряла данные: %s\n", why.c_str());
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (readFuture)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] профиль новее игры прочитан — запись поверх сотрёт чужое\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.
NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.
Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:
отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2
Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.
Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.
Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.
--accept PASS без изменений: без профиля отряд по-прежнему впятером.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
|
|
|
|
// Старый профиль играл впятером, и строки squad в нём нет. Дать такому
|
|
|
|
|
|
// игроку одного оперативника значило бы отобрать четверых, которых он
|
|
|
|
|
|
// не терял, — а выглядело бы это как «игра съела отряд».
|
|
|
|
|
|
if (readOld && v1.squadSize != NUM_AGENTS)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] миграция отобрала бойцов: %d вместо %d\n", v1.squadSize,
|
|
|
|
|
|
NUM_AGENTS);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
2026-08-14 01:16:08 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
Отряд растёт с одного оперативника до пяти (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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Найм: стоит опыта, и опыт списывается.
|
|
|
|
|
|
pr.bankedXp = RecruitCost(1) + RecruitCost(2);
|
|
|
|
|
|
const bool hired = pr.Recruit();
|
|
|
|
|
|
const float leftAfterHire = pr.bankedXp;
|
|
|
|
|
|
game.NewRun(1);
|
|
|
|
|
|
const int mannedPair = mannedNow();
|
|
|
|
|
|
|
|
|
|
|
|
// Потолок: шестого не нанять ни за какие деньги.
|
|
|
|
|
|
pr.squadSize = NUM_AGENTS;
|
|
|
|
|
|
pr.bankedXp = 1e9f;
|
|
|
|
|
|
const bool overHired = pr.Recruit();
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nотряд: в профиле 1 -> на этаже %d (живых %d), после найма %d, "
|
|
|
|
|
|
"цена %.0f (осталось %.0f), сверх потолка %s\n"
|
|
|
|
|
|
" одиночка с ножом на первом этаже: %s, здоровье %.0f -> %.0f, "
|
|
|
|
|
|
"тварей подошло %d, убил %d\n",
|
|
|
|
|
|
mannedSolo, aliveSolo, mannedPair, double(RecruitCost(1)),
|
|
|
|
|
|
double(leftAfterHire), overHired ? "НАНЯЛСЯ" : "отказ",
|
|
|
|
|
|
soloLived ? "выжил" : "ПОГИБ", double(hpStart), double(hpLeft), engaged,
|
|
|
|
|
|
killed);
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!hired || mannedPair != 2)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] найм не довёл бойца до этажа: занято %d\n", mannedPair);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (leftAfterHire >= RecruitCost(1) + RecruitCost(2))
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] найм бесплатный: опыт не списался\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (overHired)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] нанят шестой — потолок отряда не держит\n");
|
|
|
|
|
|
++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
|
|
|
|
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
// -------------------------------------------------------------------------
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на
|
Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
|
|
|
|
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
|
|
|
|
|
|
// влияет, выглядит точно так же, как работающее улучшение.
|
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.
ДВИЖОК (engine/, цель tile2d)
Каталог свой, и решение «это движок» принимается тем, куда положен файл.
Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
и карты уехали в engine/config.h, потолок населения этажа стал своим
(MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
центром карты вместо позиции отряда.
engine/projection.* единственное место, где движок знает про изометрию;
видов сразу два — абстракция с одной реализацией ничем
не проверена и обычно оказывается неправильной
engine/project.* манифест проекта: пути, вид, кривая глубины
engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла
engine/tilemap_draw движок научился показывать свою карту
РЕДАКТОР (editor/, цель tile2d_editor)
Открывает любой проект и ни одной игры по имени не знает.
--tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
этажа, ASCII-карта, проверка набора. Правку делают файлами,
последствия читают командой (docs/19-agent.md)
терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
открытый проект уходит потомкам через TILE2D_PROJECT
ПРИМЕР (samples/walk, цель tile2d_walk)
Второй потребитель движка. Игра и редактор самостоятельность движка
проверить не могут: редактор мира не рисует, а игра — то место, куда общий
код и уезжает незаметно.
ИГРА (src/)
Экстракшен: вес и два порога переноски, конечный боезапас, расходники
применяются сами, общая сумка отряда, трата опыта на улучшения.
Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
боец с клинком не тратил найденные патроны при запасном стволе в сумке;
массив виджетов экрана переполнился и молча съел хвост схрона; проверка
улучшений сама ломала забег, вызывая NewRun посреди спуска.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
|
|
|
|
//
|
|
|
|
|
|
// Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает
|
|
|
|
|
|
// глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в
|
|
|
|
|
|
// одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра
|
|
|
|
|
|
// сломана, а потому что её сломал измеряющий.
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile& pr = *game.profile;
|
|
|
|
|
|
const float hpBefore = game.squad.agents[0].maxHp;
|
|
|
|
|
|
const int ammoBefore = int(g_tune.startAmmo);
|
|
|
|
|
|
|
|
|
|
|
|
pr.bankedXp = 10000.0f;
|
|
|
|
|
|
const bool boughtAmmo = pr.Buy(Upgrade::AMMO);
|
|
|
|
|
|
const bool boughtVigor = pr.Buy(Upgrade::VIGOR);
|
|
|
|
|
|
const float leftXp = pr.bankedXp;
|
|
|
|
|
|
|
|
|
|
|
|
// Улучшения раздаются вместе со снаряжением, то есть на новой вылазке.
|
|
|
|
|
|
game.NewRun(1);
|
|
|
|
|
|
game.Step(FIXED_DT);
|
|
|
|
|
|
|
|
|
|
|
|
const float hpAfter = game.squad.agents[0].maxHp;
|
|
|
|
|
|
const int ammoAfter = game.squad.agents[0].ammoReserve;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nулучшения: здоровье %.0f -> %.0f, патроны %d -> %d, "
|
|
|
|
|
|
"опыта осталось %.0f\n",
|
|
|
|
|
|
double(hpBefore), double(hpAfter), ammoBefore, ammoAfter, double(leftXp));
|
|
|
|
|
|
|
|
|
|
|
|
if (!boughtAmmo || !boughtVigor)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] покупка не прошла при полном кошельке\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hpAfter <= hpBefore)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] живучесть куплена, но здоровье не выросло\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (ammoAfter <= ammoBefore)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] запас куплен, но патронов не прибавилось\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (leftXp >= 10000.0f)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] опыт не списался: покупка бесплатная\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nитог: этажей %d, опыта %.0f, ошибок %d\n", floorsDone, double(xpEarned), bad);
|
|
|
|
|
|
return bad == 0 ? 0 : 1;
|
|
|
|
|
|
}
|
Пункт UPGRADES выходил из игры; меню теперь проверяется (M26)
Жалоба была «крашится при открытии апгрейдов». Не крашилось: пункт
закрывал игру. В обработчике главного меню стоял `default: ui.quit`, а
экран улучшений добавили четвёртым пунктом — обработчик о нём просто не
узнал. Снаружи мгновенное закрытие окна неотличимо от падения.
Починка на две строки, но интереснее причина, по которой это прожило
четыре вехи: в проекте каждая механика меряется прогоном с кодом
возврата, а меню не проверял никто. Все инструменты смотрят на
СИМУЛЯЦИЮ.
Поэтому заведён --ui-check: проходит по всем пунктам меню и требует
одного — нажатие ВЕДЁТ КУДА-ТО, а из игры выходит ровно один пункт,
QUIT. Плюс проверяет, что кнопки экрана улучшений действительно покупают.
Окно при этом не открывается: UiActivate ничего из raylib не трогает.
Проверка сперва запущена на СЛОМАННОЙ сборке и дала код 1 с диагнозом
«пункт UPGRADES не делает ничего», и только после этого объявлена
рабочей. Проверка, которую не видели падающей, ещё ничего не проверяет.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 06:51:58 +07:00
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
// --ui-check: меню без окна.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Проверяет ровно одно утверждение, зато для каждого пункта: НАЖАТИЕ ВЕДЁТ
|
|
|
|
|
|
// КУДА-ТО. Пункт, который не меняет ни экран, ни состояние игры, — это
|
|
|
|
|
|
// неработающая кнопка, и выглядит она в точности как работающая.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Проверка появилась после конкретной поломки: пункт UPGRADES четыре вехи
|
|
|
|
|
|
// закрывал игру. В обработчике стоял `default: quit`, экран улучшений добавили
|
|
|
|
|
|
// четвёртым пунктом, и обработчик о нём не узнал. Для игрока это выглядело как
|
|
|
|
|
|
// падение при открытии улучшений — то есть как ошибка совсем другого рода.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Раньше поймать это было нечем: все прогоны в проекте меряют СИМУЛЯЦИЮ, а
|
|
|
|
|
|
// меню не проверял никто. Отсюда и правило: у поверхности, по которой игрок
|
|
|
|
|
|
// щёлкает, тоже должен быть код возврата.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Окно не открывается: UiActivate ничего из raylib не трогает, он только
|
|
|
|
|
|
// переключает экраны и зовёт методы игры.
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
int RunUiCheck()
|
|
|
|
|
|
{
|
|
|
|
|
|
Profile profile;
|
|
|
|
|
|
profile.ResetLoadouts();
|
|
|
|
|
|
profile.bankedXp = 100000.0f; // чтобы покупки на экранах реально проходили
|
|
|
|
|
|
|
|
|
|
|
|
Game game;
|
|
|
|
|
|
game.profile = &profile;
|
|
|
|
|
|
game.Init();
|
|
|
|
|
|
|
|
|
|
|
|
int bad = 0;
|
|
|
|
|
|
const int count = UiMenuItemCount();
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("%-4s %-12s %-10s %-10s %s\n", "N", "ПУНКТ", "ЭКРАН ДО", "ПОСЛЕ", "ИТОГ");
|
|
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < count; ++i)
|
|
|
|
|
|
{
|
|
|
|
|
|
Ui ui;
|
|
|
|
|
|
ui.started = true; // «продолжить» имеет смысл только в начатом забеге
|
|
|
|
|
|
const Screen before = ui.screen;
|
|
|
|
|
|
|
|
|
|
|
|
UiActivate(ui, game, UiPart::MENU_ITEM, i);
|
|
|
|
|
|
|
|
|
|
|
|
const char* name = UiMenuItemName(i);
|
|
|
|
|
|
const bool isQuit = std::strcmp(name, "QUIT") == 0;
|
|
|
|
|
|
const bool moved = ui.screen != before;
|
|
|
|
|
|
const bool ok = isQuit ? (ui.quit && !moved) : (moved && !ui.quit);
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("%-4d %-12s %-10d %-10d %s\n", i, name, int(before), int(ui.screen),
|
|
|
|
|
|
ok ? "ok" : "ПОЛОМКА");
|
|
|
|
|
|
|
|
|
|
|
|
if (!ok)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (ui.quit && !isQuit)
|
|
|
|
|
|
std::printf(" [FAIL] пункт `%s` выходит из игры\n", name);
|
|
|
|
|
|
else if (!moved)
|
|
|
|
|
|
std::printf(" [FAIL] пункт `%s` не делает ничего\n", name);
|
|
|
|
|
|
else
|
|
|
|
|
|
std::printf(" [FAIL] пункт `%s` ведёт себя не как QUIT\n", name);
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Экран улучшений — тот самый, из-за которого проверка и появилась.
|
|
|
|
|
|
// Мало открыть его: покупка обязана работать с той же кнопки.
|
|
|
|
|
|
{
|
|
|
|
|
|
Ui ui;
|
|
|
|
|
|
const int sizeBefore = profile.squadSize;
|
|
|
|
|
|
UiActivate(ui, game, UiPart::UPGRADE_ITEM, int(Upgrade::COUNT)); // строка найма
|
|
|
|
|
|
const bool hired = profile.squadSize > sizeBefore;
|
|
|
|
|
|
|
|
|
|
|
|
const int lvlBefore = profile.UpgradeLevel(Upgrade::AMMO);
|
|
|
|
|
|
UiActivate(ui, game, UiPart::UPGRADE_ITEM, int(Upgrade::AMMO));
|
|
|
|
|
|
const bool bought = profile.UpgradeLevel(Upgrade::AMMO) > lvlBefore;
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nулучшения: найм %s, покупка %s\n", hired ? "работает" : "НЕ работает",
|
|
|
|
|
|
bought ? "работает" : "НЕ работает");
|
|
|
|
|
|
if (!hired || !bought)
|
|
|
|
|
|
{
|
|
|
|
|
|
std::printf(" [FAIL] кнопки экрана улучшений не покупают\n");
|
|
|
|
|
|
++bad;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::printf("\nитог: пунктов %d, поломок %d\n", count, bad);
|
|
|
|
|
|
return bad == 0 ? 0 : 1;
|
|
|
|
|
|
}
|