squad-proto/src/debug/harness.cpp

1021 lines
53 KiB
C++
Raw Normal View History

Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
#include "debug/harness.h"
#include <chrono>
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
#include <filesystem>
Версия профиля и миграция (E1) Формат профиля ломался дважды за три вехи — когда сумка стала общей и когда появились улучшения. Оба раза старый файл читался молча и частично, а игрок видел это как «схрон куда-то делся». Впереди аффиксы, которые сломают формат третий раз, поэтому версия заводится до них, а не после. Правило чтения несимметрично намеренно: файл СТАРШЕ игры — читается с миграцией (версии нет = формат 1) файл НОВЕЕ игры — не читается вовсе, с объяснением в сообщении Второе важнее первого. Молча обнулить профиль, записанный более новой сборкой, значит стереть десятки часов; отказ читать всего лишь просит не запускать старую сборку. Проверяется обеими сторонами в --descend: поддельный профиль формата 1 читается и сохраняет глубину и схрон, поддельный профиль из будущего отвергается. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:16:08 +07:00
#include <fstream>
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
#include <cstdarg>
#include <cstdio>
#include "game.h"
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. 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
// -----------------------------------------------------------------------------
// --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;
for (int i = 0; i < floors; ++i)
{
const GeneratedLevel& l = game.level;
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);
std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f"
" (перегруз %d), патронов %d\n",
l.floor.depth, ThemeName(l.floor.theme), l.monstersPlaced,
double(l.dangerSpent), int(l.loot.size()), l.exitDistance,
game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft);
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
{
// Лечиться можно только вне схватки, поэтому поле зачищается.
for (Target& t : game.targets) t.alive = false;
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;
}
}
// -------------------------------------------------------------------------
// Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз»
// выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами,
// и по ним вес выглядит втрое меньше настоящего. Мерить правило по такой
// цифре — верный способ выставить пороги, которые ничего не ограничивают.
// -------------------------------------------------------------------------
{
// Один боец несёт ствол и общую сумку целиком: доля на одного — это
// худший случай, тот самый, в котором отряд остался вдвоём или один.
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;
}
}
// Эвакуация с последнего этажа. Без этого шага «спуск работает» ничего не
// значит: вся суть экстракшена в том, что происходит на выходе.
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();
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
{
int diff = 0;
if (back.maxDepth != profile.maxDepth) ++diff;
if (back.StashCount() != profile.StashCount()) ++diff;
for (int a = 0; a < NUM_AGENTS; ++a)
{
const Inventory& x = profile.loadout[a];
const Inventory& y = back.loadout[a];
if (x.weapon != y.weapon || x.armor != y.armor || x.trinket != y.trinket) ++diff;
}
if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; }
else std::printf("профиль: запись и чтение сходятся\n");
}
std::error_code ec;
std::filesystem::remove(tmp, ec);
}
// -------------------------------------------------------------------------
Версия профиля и миграция (E1) Формат профиля ломался дважды за три вехи — когда сумка стала общей и когда появились улучшения. Оба раза старый файл читался молча и частично, а игрок видел это как «схрон куда-то делся». Впереди аффиксы, которые сломают формат третий раз, поэтому версия заводится до них, а не после. Правило чтения несимметрично намеренно: файл СТАРШЕ игры — читается с миграцией (версии нет = формат 1) файл НОВЕЕ игры — не читается вовсе, с объяснением в сообщении Второе важнее первого. Молча обнулить профиль, записанный более новой сборкой, значит стереть десятки часов; отказ читать всего лишь просит не запускать старую сборку. Проверяется обеими сторонами в --descend: поддельный профиль формата 1 читается и сохраняет глубину и схрон, поддельный профиль из будущего отвергается. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:16:08 +07:00
// -------------------------------------------------------------------------
// Версия профиля. Формат ломался дважды за три вехи, и оба раза старый файл
// читался молча и частично. Проверяются оба направления: старый читается с
// миграцией, файл из будущего НЕ читается вовсе.
// -------------------------------------------------------------------------
{
const std::filesystem::path old = std::filesystem::temp_directory_path() / "t2d_v1.txt";
{
std::ofstream f(old);
// Формат 1: версии нет, у бойца шесть посумочных лексем.
f << "maxdepth 7\nruns 3\nxp 4200\n";
f << "agent 0 rifle vest charm bandage stimpack - - - -\n";
f << "stash saber plate\n";
}
Profile v1;
std::string why;
const bool readOld = v1.Load(old.string(), &why);
const std::filesystem::path future =
std::filesystem::temp_directory_path() / "t2d_future.txt";
{
std::ofstream f(future);
f << "version " << (PROFILE_VERSION + 1) << "\nmaxdepth 99\n";
}
Profile vNext;
std::string whyFuture;
const bool readFuture = vNext.Load(future.string(), &whyFuture);
std::error_code ec;
std::filesystem::remove(old, ec);
std::filesystem::remove(future, ec);
std::printf("\nпрофиль: старый формат %s (глубина %d, схрон %d), из будущего %s\n",
readOld ? "прочитан" : "НЕ прочитан", v1.maxDepth, v1.StashCount(),
readFuture ? "ПРОЧИТАН" : "отвергнут");
if (!readOld || v1.maxDepth != 7 || v1.StashCount() != 2)
{
std::printf(" [FAIL] миграция со старого формата потеряла данные: %s\n", why.c_str());
++bad;
}
if (readFuture)
{
std::printf(" [FAIL] профиль новее игры прочитан — запись поверх сотрёт чужое\n");
++bad;
}
}
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 int floorsDone = game.floorsCleared + 1;
const float xpEarned = game.xp;
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на
//
// Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает
// глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в
// одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра
// сломана, а потому что её сломал измеряющий.
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
// влияет, выглядит точно так же, как работающее улучшение.
// -------------------------------------------------------------------------
{
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;
}