squad-proto/src/debug/harness.cpp
z.kirill d60f9e0ce3 Версия профиля и миграция (E1)
Формат профиля ломался дважды за три вехи — когда сумка стала общей и когда
появились улучшения. Оба раза старый файл читался молча и частично, а игрок
видел это как «схрон куда-то делся». Впереди аффиксы, которые сломают формат
третий раз, поэтому версия заводится до них, а не после.

Правило чтения несимметрично намеренно:

  файл СТАРШЕ игры  — читается с миграцией (версии нет = формат 1)
  файл НОВЕЕ игры   — не читается вовсе, с объяснением в сообщении

Второе важнее первого. Молча обнулить профиль, записанный более новой сборкой,
значит стереть десятки часов; отказ читать всего лишь просит не запускать
старую сборку.

Проверяется обеими сторонами в --descend: поддельный профиль формата 1
читается и сохраняет глубину и схрон, поддельный профиль из будущего
отвергается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:16:08 +03:00

1021 lines
53 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "debug/harness.h"
#include <chrono>
#include <filesystem>
#include <fstream>
#include <cstdarg>
#include <cstdio>
#include "game.h"
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
#include "engine/view2d.h"
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)
{
return Normalized(v2d::WorldToScreen(Normalized(goal - g.squad.anchor)));
}
// -----------------------------------------------------------------------------
// Наблюдатель: собирает то, что нельзя увидеть в одном кадре.
// * сколько агент непрерывно торчит в чужой линии огня (критерий 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;
}
// -----------------------------------------------------------------------------
// --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);
}
// -------------------------------------------------------------------------
// -------------------------------------------------------------------------
// Версия профиля. Формат ломался дважды за три вехи, и оба раза старый файл
// читался молча и частично. Проверяются оба направления: старый читается с
// миграцией, файл из будущего НЕ читается вовсе.
// -------------------------------------------------------------------------
{
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;
}
}
// Итог забега снимается ДО проверки улучшений: та начинает новую вылазку и
// обнуляет счётчик этажей. Печатать его после значило бы отчитаться
// цифрой, которую испортил измеряющий.
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;
}