squad-proto/src/debug/harness.cpp
z.kirill c1f79a1fb8 Поле путей: отставший возвращается в строй (M37)
Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил.
Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец,
оказавшийся за стеной от своего слота, seek'ом тянулся В СТЕНУ, обход стены
толкал его обратно, и он замирал в углу навсегда.

Теперь у каждого бойца своё поле путей (BFS от клетки его цели) с натягиванием
верёвки: он берёт самую дальнюю точку пути, которую видит. Включается обход по
двум условиям сразу — боец упёрся (хочет идти и не едет) И потерял строй. Счётчик
упора сбрасывается по факту «цель видно», а не «поехал»: сброс по скорости давал
дребезг, и боец полз шаг за две секунды.

По дороге вскрылись две неверные МЕРКИ:

* критерий 11.6 считал колонной «разброс вдоль больше, чем поперёк» — и потому
  награждал отряд, у которого двое навсегда остались по ту сторону стены. Теперь
  колонна меряется самим проёмом: разом в горловине не больше двух, и прошли ВСЕ.
* зонд --wall читал «стены не нашлось» (ноль) как «стена на нулевом этаже» и
  объявлял пятёрку, не сломавшуюся нигде, худшей из трёх.

Обе переписаны и проверены на сломанной сборке.

Замеры: --play 6 5 на восьми сидах 47/48 (было 0/48), --accept PASS, шаг 6.1 мкс,
--wall 30 одиночка D20 / трое D20 / пятеро D31 (не найдена), --descend 8/30,
hollow --descend 6, --ui-check, оба проекта --check — без ошибок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:31:34 +03:00

2990 lines
157 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 <cmath>
#include <cstdio>
#include <cstring>
#include "game.h"
#include "sim/affix.h"
#include "sim/craft.h"
#include "sim/tree.h"
#include "sim/enemy.h"
#include "sim/loot.h"
#include "sim/trigger.h"
#include "ui/menu.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;
// --- проход гуськом --------------------------------------------------
int maxInThroat = 0; // сколько разом влезло в сам проём
bool crossed[NUM_AGENTS] = {}; // кто оказался по ту сторону стены
// Две фазы: сначала входим в проход, потом ВСТАЁМ и меряем.
//
// Мерить на ходу нельзя: пока игрок тянет курсор, тот всегда обгоняет
// отряд (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);
// Горловина — это САМ ПРОЁМ: стена y=13..15, проём x=30..31.
// Пятеро в проём шириной два тайла лезть разом не должны, а пройти
// его обязаны все до одного.
{
int inThroat = 0;
for (int k = 0; k < NUM_AGENTS; ++k)
{
const Agent& a = game.squad.agents[k];
if (!a.alive) continue;
if (a.pos.y > 12.8f && a.pos.y < 15.2f &&
a.pos.x > 29.6f && a.pos.x < 32.4f) ++inThroat;
if (a.pos.y < 12.8f) crossed[k] = true; // севернее стены
}
maxInThroat = std::max(maxInThroat, inThroat);
}
// Кто сейчас в створе прохода (горловина + подходы).
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 тайла у заднего
// бойца есть законные диагональные линии мимо переднего, и считать их
// нарушением — значит требовать от отряда быть глупее, чем он есть.
// Поэтому считаем не «кто стрелял», а инвариант: НИ ОДИН выстрел в
// проходе не прошёл через тело союзника (перепроверено своей геометрией
// по факту выстрела) и ни одного попадания по своим.
//
// Плюс форма: отряд обязан вытянуться в колонну, а не идти шеренгой.
// Пороги с запасом: строгое неравенство на маленьких числах — монетка,
// а не тест.
// Что здесь ПРАВИЛЬНО считать колонной.
//
// Первая мерка сравнивала разброс вдоль движения с разбросом поперёк — и
// была неверной насквозь. Отряд, у которого двое навсегда застряли по ту
// сторону стены, а трое стоят севернее, растянут вдоль на шесть тайлов и
// проверку ПРОХОДИЛ. Тот же отряд, когда отставшие научились обходить
// стену и собираться, стал компактным — и проверку ронял. Мерка
// награждала ровно ту поломку, ради которой писалась (docs/08-decisions.md).
//
// Честное «гуськом» — это про сам проём: одновременно в нём не больше
// двух (проём шириной два тайла), и прошли через него ВСЕ.
int crossedCount = 0, aliveCount = 0;
for (int k = 0; k < NUM_AGENTS; ++k)
{
if (!game.squad.agents[k].alive) continue;
++aliveCount;
if (crossed[k]) ++crossedCount;
}
const bool column = (maxInThroat <= 2) && (crossedCount == aliveCount);
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 из %d, в горловине разом до %d, обходов %d, "
"в створе до %d, поперёк %.2f / вдоль %.2f тайла, "
"выстрелов вперёд передний/хвост %d/%d (назад %d), "
"сквозь своего %d, FRIENDLY_HITS=%d",
crossedCount, aliveCount, maxInThroat, game.squad.navUses,
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;
}
// Убрать с этажа всех тварей НАСОВСЕМ.
//
// Насовсем — ключевое слово. Просто `alive = false` не работает: у мишени есть
// откат воскрешения, и с нулевым таймером она оживает тем же шагом. Проверки,
// которым нужно спокойное поле (лечение вне схватки, расход света на стоянке),
// на этом молча меряли бой.
//
// Поймано на глубине 30 и только там: на восьмом этаже тварей мало, и
// ближайшая воскресшая случайно оказывалась дальше девяти тайлов. Проверка
// проходила по УДАЧЕ, а не потому, что поле было чистым.
void ClearField(Game& game)
{
for (Target& t : game.targets)
{
t.alive = false;
t.respawnTimer = 1e9f;
}
}
// -----------------------------------------------------------------------------
// --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;
// Средний путь до спуска по всем этажам забега. Нужен свету: сравнивать
// заряд с длиной ОДНОГО этажа — значит мерить не баланс, а везение сида,
// а этажи одной глубины различаются вдвое.
int exitSum = 0;
int exitCount = 0;
for (int i = 0; i < floors; ++i)
{
const GeneratedLevel& l = game.level;
if (l.exitDistance > 0) { exitSum += l.exitDistance; ++exitCount; }
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);
// Сколько находок на полу ВЫКАТАНЫ. Отдельно от `--tool drops`: тот
// меряет саму кривую на десятках тысяч бросков, а этот столбец
// показывает, что бросок вообще случился на настоящем этаже — то есть
// что генератор лута и аффиксы соединены.
int rolled = 0;
for (const FloorItem& fi : game.floorItems)
if (fi.id.Count() > 0) ++rolled;
std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f"
" (перегруз %d), патронов %d, света на %.0f тайлов, с аффиксами %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,
double(game.squad.lightCharge / std::max(0.01f, LightDrain(l.floor.depth))),
rolled, int(game.floorItems.size()));
// Свет обязан ехать вниз вместе с отрядом, как патроны и сумки: заряд,
// обновляющийся на каждом этаже, — это не ресурс вылазки, а украшение
// на этаж. Прогон спуска не шагает симуляцией, поэтому сам заряд тут не
// тратится: срезаем его руками на первом этаже и смотрим внизу.
if (i == 0) game.squad.lightCharge -= 60.0f;
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
{
// Лечиться можно только вне схватки, поэтому поле зачищается.
ClearField(game);
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;
}
}
// -------------------------------------------------------------------------
// Свет. Проверяется ПРАВИЛО целиком: стоять бесплатно -> идти дорого ->
// погас, и обзор упал -> батарея из сумки встала сама.
//
// Первая проверка тут главная, и она не про баланс, а про жанр: заряд,
// тающий на стоянке, — это таймер, которого в игре нет. Написать такое
// случайно очень легко (одна строка в 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. СТОЯТЬ БЕСПЛАТНО. Ввода нет — отряд топчется в строю, и это не
// должно стоить ничего заметного.
//
// Поле зачищается насовсем: иначе на глубоких этажах отряд посреди
// «стоянки» отбивается от тварей, бегает и жжёт свет, а проверка
// объявляет это таймером.
ClearField(game);
game.in = InputState{};
// Отряд сначала ДОХОДИТ до своих мест в строю: после высадки он ещё
// расходится по слотам, и мерить «стоянку» на этом — мерить ходьбу.
for (int k = 0; k < 60; ++k) game.Step(FIXED_DT);
const float standBefore = game.squad.lightCharge;
for (int k = 0; k < 120; ++k) game.Step(FIXED_DT);
const float standCost = standBefore - game.squad.lightCharge;
// Порог — В ТАЙЛАХ, а не в заряде. Заряд за один и тот же шаг стоит
// тем дороже, чем глубже (LightDrain), и порог в единицах заряда
// объявлял бы таймером обычное топтание в строю на шестидесятом этаже.
const float standTiles = standCost / std::max(0.01f, LightDrain(game.depth));
// 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",
double(standTiles), double(perTile), double(LightDrain(game.depth)),
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;
}
if (standTiles > 0.05f)
{
std::printf(" [FAIL] отряд на стоянке прожёг света на %.2f тайла за две секунды —"
" это таймер, а таймера в игре нет\n", double(standTiles));
++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;
}
// -------------------------------------------------------------------------
// Аффиксы. Проверяется ЦЕПОЧКА до бойца, а не сам бросок: бросок меряется
// отдельно и на десятках тысяч образцов (`--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);
}
}
// -------------------------------------------------------------------------
// Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз»
// выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами,
// и по ним вес выглядит втрое меньше настоящего. Мерить правило по такой
// цифре — верный способ выставить пороги, которые ничего не ограничивают.
// -------------------------------------------------------------------------
{
// Один боец несёт ствол и общую сумку целиком: доля на одного — это
// худший случай, тот самый, в котором отряд остался вдвоём или один.
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;
}
}
// Итог забега снимается ЗДЕСЬ, пока он ещё правда. Ниже идут проверки,
// которые начинают новую вылазку (найм, улучшения) и обнуляют счётчик
// этажей: отчитываться цифрой, которую испортил измеряющий, уже приходилось
// дважды — сперва с улучшениями, потом с размером отряда.
// Материалы, добытые за вылазку, обязаны зачисляться ТОЛЬКО при эвакуации.
// Кладём известные числа перед выходом и сверяем после: это та же ставка,
// что и с добычей, и проверяется она тем же способом.
const int matBefore[MATERIAL_COUNT] = {profile.material[0], profile.material[1],
profile.material[2], profile.material[3],
profile.material[4]};
for (int m = 0; m < MATERIAL_COUNT; ++m) game.runMaterials[m] = 3 + m;
const int floorsDone = game.floorsCleared + 1;
const float xpEarned = game.xp;
// Эвакуация с последнего этажа. Без этого шага «спуск работает» ничего не
// значит: вся суть экстракшена в том, что происходит на выходе.
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;
}
{
int credited = 0;
for (int m = 0; m < MATERIAL_COUNT; ++m)
if (profile.material[m] == matBefore[m] + 3 + m) ++credited;
std::printf("материалы: зачислено видов %d из %d (%d/%d/%d/%d/%d)\n", credited,
MATERIAL_COUNT, profile.material[0], profile.material[1], profile.material[2],
profile.material[3], profile.material[4]);
if (credited != MATERIAL_COUNT)
{
std::printf(" [FAIL] добытое за вылазку не доехало до профиля\n");
++bad;
}
}
// Вынесено считается по ВСЕМУ снаряжению, а не только по сумкам, поэтому
// проверяем неравенством: меньше содержимого сумок быть не может.
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();
// Прогон делается на НАБИТОМ профиле, а не на том, что получился сам.
// Пустое поле сходится с пустым при любой поломке записи: улучшения
// стояли в нулях весь прогон, и проверка «0 == 0» три вехи подряд
// подтверждала работу того, чего в файле не было вовсе.
const Profile saved = profile;
for (int i = 0; i < TREE_NODES; i += 7) profile.node[i] = 1 + (i % 3);
profile.squadSize = 3;
profile.runs = 11;
profile.extractions = 7;
profile.wipes = 4;
// И ВЕЩЬ С АФФИКСАМИ — в снаряжении и в схроне. Без неё круговой прогон
// сверял бы предметы без рулонов, то есть ровно ту часть формата,
// которая работала и раньше.
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);
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
{
// Сверяется ВСЁ, что есть в профиле, а не три поля из десяти.
//
// Прежняя версия проверки смотрела глубину, схрон и снаряжение — и
// ровно поэтому три вехи не замечала, что `Save` не писала ни
// версию, ни улучшения. Купленное действовало до конца запуска и
// молча пропадало, а круговой прогон при этом бодро сходился.
//
// Правило на будущее: круговой прогон обязан сверять поля СПИСКОМ,
// а список — расти вместе с форматом. Выборочная сверка проверяет
// не формат, а память того, кто её писал.
int diff = 0;
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 i = 0; i < TREE_NODES; ++i)
same(back.node[i] == profile.node[i], TreeNodeAt(i).name);
// Предметы сверяются ЦЕЛИКОМ, с рулонами. Сравнение через 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);
};
for (int a = 0; a < NUM_AGENTS; ++a)
{
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, "карман бойца");
}
for (int k = 0; k < profile.StashCount() && k < back.StashCount(); ++k)
sameItem(profile.stash[size_t(k)], back.stash[size_t(k)], "вещь из схрона");
if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; }
else std::printf("профиль: запись и чтение сходятся по всем полям\n");
}
// Профиль возвращается как был: дальше он ещё нужен настоящим.
profile = saved;
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;
}
// Старый профиль играл впятером, и строки squad в нём нет. Дать такому
// игроку одного оперативника значило бы отобрать четверых, которых он
// не терял, — а выглядело бы это как «игра съела отряд».
if (readOld && v1.squadSize != NUM_AGENTS)
{
std::printf(" [FAIL] миграция отобрала бойцов: %d вместо %d\n", v1.squadSize,
NUM_AGENTS);
++bad;
}
}
// -------------------------------------------------------------------------
// Триггеры. Проверяется то же, что у реакций: не «срабатывает ли», а ТРИ
// ОГРАНИЧИТЕЛЯ, без которых система условных эффектов вешает игру
// (docs/21-damage.md §4.1).
//
// Отдельно — что триггер вообще доезжает до боя. Аффикс с триггером легко
// сделать так, что он лежит в предмете, показывается в подсказке и не
// делает ничего: ровно так дважды умирали вес и стихии.
// -------------------------------------------------------------------------
{
Agent& a = game.squad.agents[0];
Inventory* inv = game.world.Get<Inventory>(a.self);
if (!inv)
{
std::printf("\n [FAIL] у бойца нет инвентаря — триггеры не проверить\n");
++bad;
}
else
{
const Inventory savedInv = *inv;
const SquadBag savedBag = game.squad.bag;
// Оружие с гарантированным ON_HIT: шанс единица, чтобы мерить
// ПРОВОДКУ, а не везение.
ItemInstance rifle(ItemId::RIFLE);
rifle.rarity = Rarity::RELIC;
rifle.affix[rifle.count++] =
Affix{AffixTag::TRIGGER, 5, uint8_t(TriggerKind::ON_HIT), 1.0f};
rifle.affix[rifle.count++] =
Affix{AffixTag::TRIGGER, 5, uint8_t(TriggerKind::ON_KILL), 1.0f};
*inv = Inventory{};
inv->weapon = rifle;
EquipWeapon(game.world, a, inv->weapon);
game.Step(FIXED_DT);
const bool wired = a.mods.trigger[int(TriggerKind::ON_HIT)] > 0.0f &&
a.mods.trigger[int(TriggerKind::ON_KILL)] > 0.0f;
// 1. ON_HIT доезжает до цели: ставим тварь под ствол и смотрим,
// появились ли на ней стаки жара. Оружие кинетическое, поэтому
// жар взяться больше неоткуда.
ClearField(game);
// Цель ищется НЕ по нулевому индексу: первым в пуле может стоять
// ящик, а обстановка не горит и убийством не считается. Первая
// версия проверки брала targets[0] и проходила на descent просто
// потому, что там повезло, — на hollow она падала.
int victimIndex = -1;
for (int t = 0; t < int(game.targets.size()); ++t)
if (!game.targets[size_t(t)].IsProp() && !game.targets[size_t(t)].IsDummy())
{
victimIndex = t;
break;
}
if (victimIndex < 0)
{
std::printf("\n [FAIL] на этаже нет ни одной твари — триггеры не проверить\n");
++bad;
victimIndex = 0;
}
Target& victim = game.targets[size_t(victimIndex)];
victim.alive = true;
victim.respawnTimer = 0.0f;
victim.hp = 100000.0f; // чтобы не умерла раньше времени
victim.status.Clear();
// Ставим цель в СВОБОДНУЮ клетку рядом со стрелком и с прямой
// видимостью: иначе пуля уходит в стену, и проверка меряет
// планировку этажа, а не триггеры.
const Vec2 dirs[8] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1},
{1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
victim.pos = a.pos + Vec2{1.5f, 0.0f};
for (const Vec2& d : dirs)
{
const Vec2 spot = a.pos + d * 2.0f;
if (game.map.IsWallAt(spot)) continue;
if (game.map.RaycastBlocked(a.pos, spot)) continue;
victim.pos = spot;
break;
}
a.trig.icd[int(TriggerKind::ON_HIT)] = 0.0f;
for (int k = 0; k < 240 && victim.status.stacks[int(Element::INC)] == 0; ++k)
game.Step(FIXED_DT);
const int burned = victim.status.stacks[int(Element::INC)];
// 2. ОТКАТ. Сразу после срабатывания триггер обязан молчать: гасим
// стаки и проверяем, что за полсекунды они не вернулись.
victim.status.Clear();
const float icdLeft = a.trig.icd[int(TriggerKind::ON_HIT)];
for (int k = 0; k < 24; ++k) game.Step(FIXED_DT);
const int duringIcd = victim.status.stacks[int(Element::INC)];
// 3. ON_KILL: добиваем тварь и смотрим стаки рывка.
a.trig.killStacks = 0;
victim.hp = 1.0f;
for (int k = 0; k < 240 && a.trig.killStacks == 0; ++k) game.Step(FIXED_DT);
const int rush = a.trig.killStacks;
// Ещё один шаг ПЕРЕД замером скорости: стак прибавляется в момент
// смерти цели, а статы бойца пересчитываются в начале шага
// (SyncLoadout). Читать скорость сразу — значит читать её до того,
// как рывок вообще применился. Один кадр задержки в игре ни на что
// не влияет, а проверку сбивал.
game.Step(FIXED_DT);
const float speedWithRush = a.speedMul;
std::printf("\nтриггеры: аффикс доехал %s, ON_HIT поджёг стаков %d, откат %.1f с"
" (за него наложено %d), ON_KILL стаков %d, скорость %.2f\n",
wired ? "да" : "НЕТ", burned, double(icdLeft), duringIcd, rush,
double(speedWithRush));
if (!wired)
{
std::printf(" [FAIL] триггер с предмета не дошёл до бойца\n");
++bad;
}
if (burned <= 0)
{
std::printf(" [FAIL] ON_HIT не поджёг цель — эффект не доезжает до боя\n");
++bad;
}
if (duringIcd > 0)
{
std::printf(" [FAIL] ON_HIT сработал внутри отката: ограничителя нет\n");
++bad;
}
if (rush <= 0)
{
std::printf(" [FAIL] ON_KILL не дал рывка\n");
++bad;
}
if (rush > 0 && speedWithRush <= 1.0f)
{
std::printf(" [FAIL] стаки рывка есть, а скорость прежняя\n");
++bad;
}
// 4. ЗАПРЕТ РЕКУРСИИ. Поджиг от триггера сам урона не наносит и
// второй раз триггер не запускает, поэтому за один шаг стаков
// больше одного не появляется. Проверяем в лоб: гасим статусы,
// сбрасываем откат и делаем РОВНО ОДИН шаг с попаданием.
victim.status.Clear();
victim.hp = 100000.0f;
victim.alive = true;
a.trig.icd[int(TriggerKind::ON_HIT)] = 0.0f;
int perStepMax = 0;
for (int k = 0; k < 240; ++k)
{
const int before = victim.status.stacks[int(Element::INC)];
game.Step(FIXED_DT);
const int after = victim.status.stacks[int(Element::INC)];
perStepMax = std::max(perStepMax, after - before);
}
std::printf("рекурсия: за один шаг стаков прибавлялось максимум %d\n", perStepMax);
if (perStepMax > 1)
{
std::printf(" [FAIL] триггер запускает сам себя: %d стаков за шаг\n", perStepMax);
++bad;
}
*inv = savedInv;
EquipWeapon(game.world, a, inv->weapon);
game.squad.bag = savedBag;
game.squad.rageTimer = 0.0f;
a.trig = TriggerState{};
a.trig.rng = 1u;
game.Step(FIXED_DT);
}
}
// -------------------------------------------------------------------------
// Парирование. Проверяется РАЗМЕН, а не факт: удался отбив — урона нет,
// тварь получила ответ, и боец ударил заново, не сбросив откат.
//
// Условие «удары соприкоснулись» проверяется отдельно и с двух сторон:
// в замахе отбить МОЖНО, стоя столбом — НЕЛЬЗЯ. Без второй половины клинок
// превратился бы в щит, который работает, пока стоишь.
// -------------------------------------------------------------------------
{
Agent& a = game.squad.agents[0];
Inventory* inv = game.world.Get<Inventory>(a.self);
if (!inv)
{
std::printf("\n [FAIL] у бойца нет инвентаря — парирование не проверить\n");
++bad;
}
else
{
const Inventory savedInv = *inv;
// Нож: самое лёгкое оружие, у него шанс отбива выше всех.
EquipWeapon(game.world, a, ItemId::KNIFE);
inv->weapon = ItemInstance(ItemId::KNIFE);
game.Step(FIXED_DT);
const float knifeParry = a.melee.parry;
EquipWeapon(game.world, a, ItemId::CLEAVER);
inv->weapon = ItemInstance(ItemId::CLEAVER);
game.Step(FIXED_DT);
const float cleaverParry = a.melee.parry;
// Возвращаем нож и ставим гарантированный отбив: проверяется
// ПРОВОДКА размена, а не везение.
EquipWeapon(game.world, a, ItemId::KNIFE);
inv->weapon = ItemInstance(ItemId::KNIFE);
game.Step(FIXED_DT);
ClearField(game);
int foeIndex = -1;
for (int t = 0; t < int(game.targets.size()); ++t)
if (!game.targets[size_t(t)].IsProp() && !game.targets[size_t(t)].IsDummy())
{
foeIndex = t;
break;
}
bool parried = false, riposted = false, cooldownKept = false, standBlocked = true;
if (foeIndex >= 0)
{
Target& foe = game.targets[size_t(foeIndex)];
foe.alive = true;
foe.respawnTimer = 0.0f;
foe.hp = 100000.0f;
foe.pos = a.pos + Vec2{0.8f, 0.0f};
a.hp = a.maxHp;
a.melee.parry = 1.0f; // отбив гарантирован
a.melee.state = MeleeState::STRIKE; // удары «соприкоснулись»
a.melee.phase = a.melee.strike;
a.melee.cooldown = 0.42f;
const float hpBefore = a.hp;
const float foeBefore = foe.hp;
const float cdBefore = a.melee.cooldown;
// Бьём по бойцу напрямую тем же путём, каким бьёт тварь.
HurtAgentForTest(a, 0, foe, foeIndex, game.targets, 40.0f);
parried = a.hp >= hpBefore;
riposted = foe.hp < foeBefore;
cooldownKept = std::fabs(a.melee.cooldown - cdBefore) < 0.001f;
// А теперь то же самое, но боец НЕ в ударе.
a.melee.state = MeleeState::IDLE;
a.melee.phase = 0.0f;
const float hpIdle = a.hp;
HurtAgentForTest(a, 0, foe, foeIndex, game.targets, 40.0f);
standBlocked = a.hp < hpIdle; // урон обязан пройти
}
std::printf("\nпарирование: нож %.0f%%, тесак %.0f%%; отбив %s, ответ %s,"
" откат %s, стоя столбом %s\n",
double(knifeParry * 100.0f), double(cleaverParry * 100.0f),
parried ? "снял урон" : "НЕ СРАБОТАЛ",
riposted ? "прошёл" : "НЕ ПРОШЁЛ",
cooldownKept ? "не сброшен" : "СБРОШЕН",
standBlocked ? "не отбивает" : "ОТБИВАЕТ");
if (knifeParry <= cleaverParry)
{
std::printf(" [FAIL] тесак парирует не хуже ножа — вес ни на что не влияет\n");
++bad;
}
if (!parried || !riposted)
{
std::printf(" [FAIL] размен не состоялся: урон %s, ответ %s\n",
parried ? "снят" : "прошёл", riposted ? "есть" : "нет");
++bad;
}
if (!cooldownKept)
{
std::printf(" [FAIL] повторный удар сбросил откат — это не размен,"
" а бесплатная атака\n");
++bad;
}
if (!standBlocked)
{
std::printf(" [FAIL] отбивает, стоя столбом: клинок стал щитом\n");
++bad;
}
*inv = savedInv;
EquipWeapon(game.world, a, inv->weapon);
game.Step(FIXED_DT);
}
}
// -------------------------------------------------------------------------
// Качество находок. Проверяется не «падает ли лут», а то, из-за чего он
// ощущался мусором: у оружия могло не оказаться НИ ОДНОГО боевого аффикса,
// и «реликт» выходил хуже обычного карабина.
// -------------------------------------------------------------------------
{
uint32_t rng = 4242u;
int rolled = 0, empty = 0, tierSum = 0, affixSum = 0;
for (int i = 0; i < 2000; ++i)
{
const ItemInstance it = RollItem(ItemId::RIFLE, 40, rng);
if (it.rarity == Rarity::COMMON) continue;
++rolled;
bool core = false;
for (int a = 0; a < it.Count(); ++a)
{
const AffixTag t = it.affix[a].tag;
if (t == AffixTag::DAMAGE || t == AffixTag::PEN || t == AffixTag::CONVERT ||
t == AffixTag::TRIGGER || t == AffixTag::PARRY)
core = true;
tierSum += it.affix[a].tier;
++affixSum;
}
if (!core) ++empty;
}
const float avgTier = (affixSum > 0) ? float(tierSum) / float(affixSum) : 0.0f;
std::printf("\nнаходки (карабин, D40): выкатано %d, без боевых аффиксов %d,"
" средний тир %.2f при потолке %d\n",
rolled, empty, double(avgTier), MaxAffixTier(40));
if (empty > 0)
{
std::printf(" [FAIL] %d стволов без боевых аффиксов: редкость обещает силу,"
" а даёт строки про переноску\n", empty);
++bad;
}
if (avgTier < float(MaxAffixTier(40)) * 0.5f)
{
std::printf(" [FAIL] средний тир %.2f при потолке %d — глубина не чувствуется\n",
double(avgTier), MaxAffixTier(40));
++bad;
}
}
// -------------------------------------------------------------------------
// Мастерская. Проверяются ПРАВИЛА операций, а не то, что они «есть»:
// каждая обязана менять предмет ровно так, как обещает подпись под ней, и
// списывать за это материалы.
//
// Отдельно и главное — залом. Единственная операция с риском, и риск обязан
// быть настоящим: и вещь теряется, и плата снимается. Меряется он на тысяче
// попыток, потому что «у меня трижды не рассыпалось» — не доказательство.
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;
const int savedMat[MATERIAL_COUNT] = {pr.material[0], pr.material[1], pr.material[2],
pr.material[3], pr.material[4]};
// Заведомо хватает на всё: проверяются правила, а не бедность.
for (int m = 0; m < MATERIAL_COUNT; ++m) pr.material[m] = 100000;
uint32_t rng = 12345u;
ItemInstance it(ItemId::RIFLE);
it.rarity = Rarity::PROTOTYPE;
it.affix[it.count++] = Affix{AffixTag::DAMAGE, 3, 0, 0.30f};
it.affix[it.count++] = Affix{AffixTag::PEN, 2, 0, 0.10f};
it.affix[it.count++] = Affix{AffixTag::SPEED, 2, 0, 0.08f};
// 1. Перековка: значения меняются, теги и тиры — нет.
const ItemInstance before = it;
const int scrapBefore = pr.material[int(MaterialKind::SCRAP)];
const CraftResult rr = ApplyCraft(CraftOp::REFORGE, it, pr.material, 40, rng);
bool tagsKept = it.Count() == before.Count();
bool valueMoved = false;
for (int i = 0; i < it.Count() && tagsKept; ++i)
{
tagsKept = it.affix[i].tag == before.affix[i].tag &&
it.affix[i].tier == before.affix[i].tier;
if (std::fabs(it.affix[i].value - before.affix[i].value) > 1e-6f) valueMoved = true;
}
const bool paid = pr.material[int(MaterialKind::SCRAP)] < scrapBefore;
// 2. Прививка: аффиксов стало больше, тег новый.
const int countBeforeGraft = it.Count();
const CraftResult gr = ApplyCraft(CraftOp::GRAFT, it, pr.material, 40, rng);
bool grafted = it.Count() == countBeforeGraft + 1;
if (grafted)
{
const AffixTag fresh = it.affix[it.Count() - 1].tag;
for (int i = 0; i < it.Count() - 1; ++i)
if (it.affix[i].tag == fresh) grafted = false; // тег повторился
}
// 3. Отсечение: аффиксов стало меньше.
const int countBeforeExcise = it.Count();
const CraftResult er = ApplyCraft(CraftOp::EXCISE, it, pr.material, 40, rng);
const bool excised = it.Count() == countBeforeExcise - 1;
// 4. Возвышение: у одного аффикса тир вырос ровно на единицу.
int tierSumBefore = 0;
for (int i = 0; i < it.Count(); ++i) tierSumBefore += it.affix[i].tier;
const CraftResult xr = ApplyCraft(CraftOp::EXALT, it, pr.material, 40, rng);
int tierSumAfter = 0;
for (int i = 0; i < it.Count(); ++i) tierSumAfter += it.affix[i].tier;
const bool exalted = tierSumAfter == tierSumBefore + 1;
std::printf("\nмастерская: перековка %s, прививка %s, отсечение %s, возвышение %s\n",
(rr == CraftResult::OK && tagsKept && valueMoved && paid) ? "ok" : "ПОЛОМКА",
(gr == CraftResult::OK && grafted) ? "ok" : "ПОЛОМКА",
(er == CraftResult::OK && excised) ? "ok" : "ПОЛОМКА",
(xr == CraftResult::OK && exalted) ? "ok" : "ПОЛОМКА");
if (rr != CraftResult::OK || !tagsKept || !valueMoved || !paid)
{
std::printf(" [FAIL] перековка: результат %d, теги %s, значения %s, плата %s\n",
int(rr), tagsKept ? "целы" : "СБИТЫ", valueMoved ? "новые" : "ТЕ ЖЕ",
paid ? "снята" : "НЕ СНЯТА");
++bad;
}
if (gr != CraftResult::OK || !grafted)
{
std::printf(" [FAIL] прививка не добавила нового тега\n");
++bad;
}
if (er != CraftResult::OK || !excised)
{
std::printf(" [FAIL] отсечение не убрало аффикс\n");
++bad;
}
if (xr != CraftResult::OK || !exalted)
{
std::printf(" [FAIL] возвышение не подняло тир\n");
++bad;
}
// 4.5. НАПРАВЛЕННОСТЬ. Половина операций работает по ВЫБРАННОМУ аффиксу,
// и это главное, чего не было в первой версии крафта: там игрок
// жал кнопку и смотрел, что выпало.
{
ItemInstance aim(ItemId::VEST);
aim.rarity = Rarity::PROTOTYPE;
aim.affix[aim.count++] = Affix{AffixTag::HEALTH, 2, 0, 10.0f};
aim.affix[aim.count++] = Affix{AffixTag::ARMOR, 2, 0, 0.05f};
aim.affix[aim.count++] = Affix{AffixTag::CARRY, 2, 0, 0.40f};
// Отсечение снимает именно средний, а не последний.
const CraftResult cut = ApplyCraft(CraftOp::EXCISE, aim, pr.material, 40, rng, 1);
const bool cutRight = cut == CraftResult::OK && aim.Count() == 2 &&
aim.affix[0].tag == AffixTag::HEALTH &&
aim.affix[1].tag == AffixTag::CARRY;
// Возвышение поднимает именно указанный.
const int tierBefore = aim.affix[1].tier;
const CraftResult up = ApplyCraft(CraftOp::EXALT, aim, pr.material, 40, rng, 1);
const bool upRight = up == CraftResult::OK && aim.affix[1].tier == tierBefore + 1 &&
aim.affix[0].tier == 2;
// Закалка двигает рулон вверх, не трогая тир.
const float rollBefore = aim.affix[0].value;
const int tierKept = aim.affix[0].tier;
const CraftResult tmp = ApplyCraft(CraftOp::TEMPER, aim, pr.material, 40, rng, 0);
const bool tempRight = tmp == CraftResult::OK && aim.affix[0].value > rollBefore &&
aim.affix[0].tier == tierKept;
// Отпечаток переносит аффикс донора и сжигает его.
ItemInstance donor(ItemId::PLATE);
donor.rarity = Rarity::RELIC;
donor.affix[donor.count++] = Affix{AffixTag::SPEED, 4, 0, 0.12f};
const int haveBefore = aim.Count();
const CraftResult imp =
ApplyCraft(CraftOp::IMPRINT, aim, pr.material, 40, rng, 0, &donor);
const bool impRight = imp == CraftResult::OK && aim.Count() == haveBefore + 1 &&
donor.Empty();
std::printf("направленность: отсечение %s, возвышение %s, закалка %s, отпечаток %s\n",
cutRight ? "по цели" : "МИМО", upRight ? "по цели" : "МИМО",
tempRight ? "двигает рулон" : "НЕ РАБОТАЕТ",
impRight ? "перенёс и сжёг донора" : "НЕ СРАБОТАЛ");
if (!cutRight || !upRight)
{
std::printf(" [FAIL] направленная операция трогает не тот аффикс\n");
++bad;
}
if (!tempRight)
{
std::printf(" [FAIL] закалка не двигает рулон или трогает тир\n");
++bad;
}
if (!impRight)
{
std::printf(" [FAIL] отпечаток не перенёс аффикс или не сжёг донора\n");
++bad;
}
}
// 5. Залом на тысяче попыток: доля потерь, плата за неудачу и размен.
int destroyed = 0;
int paidTimes = 0;
int slotsLost = 0;
for (int k = 0; k < 1000; ++k)
{
ItemInstance probe(ItemId::VEST);
probe.rarity = Rarity::RELIC;
for (int a = 0; a < 5; ++a)
probe.affix[probe.count++] = Affix{AffixTag::HEALTH, 2, 0, 10.0f};
const int slagBefore = pr.material[int(MaterialKind::SLAG)];
const int countBefore = probe.Count();
const CraftResult fr = ApplyCraft(CraftOp::FRACTURE, probe, pr.material, 40, rng);
if (pr.material[int(MaterialKind::SLAG)] < slagBefore) ++paidTimes;
if (fr == CraftResult::DESTROYED) ++destroyed;
else if (probe.Count() == countBefore - 1) ++slotsLost;
}
const float rate = float(destroyed) / 1000.0f;
std::printf("залом: рассыпалось %.1f%% при заявленных %.0f%%, платят всегда %s,"
" слот теряется %s\n",
double(rate * 100.0f), double(FRACTURE_RISK * 100.0f),
(paidTimes == 1000) ? "да" : "НЕТ",
(slotsLost + destroyed == 1000) ? "да" : "НЕТ");
if (std::fabs(rate - FRACTURE_RISK) > 0.03f)
{
std::printf(" [FAIL] риск залома не тот, что обещан подписью\n");
++bad;
}
if (paidTimes != 1000)
{
std::printf(" [FAIL] за неудавшийся залом не платят — риска нет\n");
++bad;
}
if (slotsLost + destroyed != 1000)
{
std::printf(" [FAIL] залом не отнял слот: это улучшение без размена\n");
++bad;
}
// 6. Бедность: без материалов не проходит ничего.
for (int m = 0; m < MATERIAL_COUNT; ++m) pr.material[m] = 0;
ItemInstance poor(ItemId::RIFLE);
poor.rarity = Rarity::MARKED;
poor.affix[poor.count++] = Affix{AffixTag::DAMAGE, 2, 0, 0.12f};
const CraftResult broke = ApplyCraft(CraftOp::REFORGE, poor, pr.material, 40, rng);
if (broke != CraftResult::NO_MATERIALS)
{
std::printf(" [FAIL] операция прошла без материалов\n");
++bad;
}
for (int m = 0; m < MATERIAL_COUNT; ++m) pr.material[m] = savedMat[m];
}
// -------------------------------------------------------------------------
// Полосы добычи. Правило «материал добывается только в своей полосе» —
// единственная причина возвращаться на средние глубины, когда рекорд ушёл
// вниз (docs/22-loot.md §5.1). Проверяется прямым опросом таблицы: в игре
// это заметно только часов через двадцать.
// -------------------------------------------------------------------------
{
struct Case
{
int depth;
int theme;
const char* what;
};
const Case cases[] = {
{1, 0, "первый этаж, сталь"}, {25, 2, "плоть на 25"}, {25, 0, "сталь на 25"},
{40, 3, "мороз на 40"}, {60, 1, "ржавь на 60"}, {80, 2, "плоть на 80"},
};
std::printf("\nполосы материалов (с твари danger 2):\n");
int bandBad = 0;
for (const Case& c : cases)
{
const MaterialDrop d = KillDrop(c.depth, c.theme, 2.0f);
std::printf(" %-20s лом %d, био %d, крио %d, осколок %d, шлак %d\n", c.what,
d.amount[0], d.amount[1], d.amount[2], d.amount[3], d.amount[4]);
// Ядра обязаны молчать вне своей темы и выше своей глубины.
if (c.depth < 21 && d.Total() > 0) ++bandBad;
if (c.theme != 2 && d.amount[int(MaterialKind::BIO)] > 0) ++bandBad;
if (c.theme != 3 && d.amount[int(MaterialKind::CRYO)] > 0) ++bandBad;
}
const MaterialDrop prop = PropDrop(1);
std::printf(" %-20s лом %d\n", "разбитый ящик", prop.amount[0]);
if (prop.amount[int(MaterialKind::SCRAP)] <= 0)
{
std::printf(" [FAIL] обстановка не даёт лома — базовый материал недобываем\n");
++bandBad;
}
if (bandBad > 0)
{
std::printf(" [FAIL] полосы протекают: материал падает не со своих этажей\n");
bad += bandBad;
}
}
// -------------------------------------------------------------------------
// Размер отряда. Проверяется цепочка «в профиле -> на этаже»: сколько
// человек в профиле, столько и выходит, и ни одного лишнего тела рядом.
//
// Стоит ПЕРЕД проверкой улучшений, потому что та начинает новую вылазку и
// сама же пересобирает отряд.
// -------------------------------------------------------------------------
{
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;
}();
// Ставим отряд в СВОБОДНУЮ клетку рядом с тварью и с прямой
// видимостью. Слепое «+3 по X» попадало в стену, стоило
// комнатам измениться: боец не видел цель, клинок её не брал, и
// проверка объявляла непроходимым первый этаж — хотя мерила
// собственную раскладку.
const Vec2 foePos = game.targets[size_t(victim)].pos;
Vec2 spot = foePos + Vec2{3.0f, 0.0f};
const Vec2 around[8] = {{3, 0}, {-3, 0}, {0, 3}, {0, -3},
{2, 2}, {-2, 2}, {2, -2}, {-2, -2}};
for (const Vec2& d : around)
{
const Vec2 test = foePos + d;
if (game.map.IsWallAt(test)) continue;
if (game.map.RaycastBlocked(test, foePos)) continue;
spot = test;
break;
}
game.PlaceSquad(spot, 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;
}
}
// Наёмник НАХОДИТСЯ, а не покупается: подкладываем капсулу под ноги
// бойцу и смотрим, встал ли человек в строй и со своими ли числами.
SpawnFloorItem(game.world, ItemInstance(ItemId::CAPSULE), game.squad.agents[0].pos, 0.0f);
for (int k = 0; k < 30 && mannedNow() < 2; ++k) game.Step(FIXED_DT);
const int mannedPair = mannedNow();
const bool rescued = mannedPair == 2;
const MercStats got = pr.merc[1];
const bool hasStats = got.Any();
const bool inProfile = pr.squadSize == 2;
// Потолок: шестого не втиснуть никакой капсулой.
pr.squadSize = NUM_AGENTS;
const bool overFull = pr.CanRescue();
std::printf("\nотряд: в профиле 1 -> на этаже %d (живых %d), капсула подняла до %d\n"
" числа наёмника: hp +%.0f, скорость +%.2f, обзор +%.2f;"
" сверх потолка %s\n"
" одиночка с ножом на первом этаже: %s, здоровье %.0f -> %.0f, "
"тварей подошло %d, убил %d\n",
mannedSolo, aliveSolo, mannedPair, double(got.hp), double(got.speed),
double(got.sight), overFull ? "ВЛЕЗ" : "отказ",
soloLived ? "выжил" : "ПОГИБ", double(hpStart), double(hpLeft), engaged,
killed);
if (!inProfile && rescued)
{
std::printf(" [FAIL] найденный не записался в профиль\n");
++bad;
}
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 (!rescued)
{
std::printf(" [FAIL] капсула не поставила человека в строй: занято %d\n",
mannedPair);
++bad;
}
if (rescued && !hasStats)
{
std::printf(" [FAIL] у найденного нет своих чисел: это слот, а не человек\n");
++bad;
}
if (overFull)
{
std::printf(" [FAIL] шестой влезает — потолок отряда не держит\n");
++bad;
}
pr.squadSize = sizeBefore;
}
// -------------------------------------------------------------------------
// Вайп: половина добычи уцелевает, и редкое теряется чаще.
//
// Меряется на СОТНЕ вайпов, а не на одном: «у меня уцелел реликт» и «у меня
// не уцелел» — одинаково бессмысленные наблюдения. Проверяются два разных
// утверждения, и их легко перепутать:
//
// 1. в среднем доезжает около половины;
// 2. обычное доезжает ЧАЩЕ реликта.
//
// Второе важнее: без него правило вырождается в «теряется половина бинтов».
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;
const Profile savedProfile = pr;
int commonKept = 0, commonAll = 0;
int relicKept = 0, relicAll = 0;
for (int round = 0; round < 100; ++round)
{
pr.stash.clear();
// Сумка отряда: поровну обычного и реликтов, чтобы сравнивать
// доли, а не количества.
game.squad.bag.Clear();
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i)
{
ItemInstance it(ItemId::JACKET);
if (i % 2 == 1)
{
it.rarity = Rarity::RELIC;
it.affix[it.count++] = Affix{AffixTag::HEALTH, 5, 0, 20.0f};
++relicAll;
}
else
{
++commonAll;
}
game.squad.bag.slots[i] = it;
}
// Сид вайпа считается от сида уровня и глубины, поэтому крутим их:
// иначе сто вайпов дали бы сто одинаковых итогов.
game.levelSeed = uint32_t(round) * 2654435761u + 17u;
game.depth = 10 + round % 7;
// Считаем ВСЁ, что отряд нёс, а не только сумку: спасение забирает
// и надетое. Первая версия проверки считала одну сумку и получила
// «обычного доехало 112%» — то есть больше, чем клала.
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& ag = game.squad.agents[i];
if (!ag.present) continue;
if (const Inventory* inv = game.world.Get<Inventory>(ag.self))
{
const ItemInstance worn[3] = {inv->weapon, inv->armor, inv->trinket};
for (const ItemInstance& w : worn)
{
if (w.Empty()) continue;
if (w.rarity == Rarity::RELIC) ++relicAll;
else ++commonAll;
}
}
}
for (int i = 0; i < NUM_AGENTS; ++i) game.squad.agents[i].alive = false;
game.WipeRun();
for (const ItemInstance& it : pr.stash)
{
if (it.rarity == Rarity::RELIC) ++relicKept;
else ++commonKept;
}
}
const float commonRate = float(commonKept) / float(std::max(1, commonAll));
const float relicRate = float(relicKept) / float(std::max(1, relicAll));
const float total = float(commonKept + relicKept) / float(std::max(1, commonAll + relicAll));
std::printf("\nвайп: доезжает %.0f%% добычи (обычное %.0f%%, реликты %.0f%%)\n",
double(total * 100.0f), double(commonRate * 100.0f),
double(relicRate * 100.0f));
if (total < 0.35f || total > 0.65f)
{
std::printf(" [FAIL] после вайпа доезжает %.0f%% — это не «половина»\n",
double(total * 100.0f));
++bad;
}
if (relicRate >= commonRate)
{
std::printf(" [FAIL] реликты теряются не чаще обычного: %.0f%% против %.0f%%\n",
double(relicRate * 100.0f), double(commonRate * 100.0f));
++bad;
}
pr = savedProfile;
game.squad.bag.Clear();
}
// -------------------------------------------------------------------------
// Опыт платится за УБИЙСТВА и сразу. Раньше он зачислялся только при
// эвакуации, то есть вайп отнимал ещё и понимание того, что игрок уже
// сделал. Теперь выносить надо ВЕЩИ, а не опыт.
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;
game.NewRun(1);
const float bankBefore = pr.bankedXp;
ClearField(game);
// Убиваем тварь руками: прогон спуска ни с кем не воюет, и ждать
// естественной смерти тут нечего.
int victim = -1;
for (int t = 0; t < int(game.targets.size()); ++t)
if (!game.targets[size_t(t)].IsProp() && !game.targets[size_t(t)].IsDummy())
{
victim = t;
break;
}
bool killed = false;
if (victim >= 0)
{
Target& t = game.targets[size_t(victim)];
t.alive = true;
t.countedDead = false;
t.hp = 1.0f;
game.Step(FIXED_DT);
t.hp = 0.0f;
t.alive = false;
game.Step(FIXED_DT);
killed = true;
}
const float bankAfter = pr.bankedXp;
std::printf("опыт: банк %.0f -> %.0f за убийство БЕЗ эвакуации\n", double(bankBefore),
double(bankAfter));
if (!killed || bankAfter <= bankBefore)
{
std::printf(" [FAIL] опыт за убийство не зачислен — его снова надо выносить\n");
++bad;
}
}
// -------------------------------------------------------------------------
// Дерево улучшений. Проверяется ПРОВОДКА до боя, а не таблица узлов: узел,
// который красиво нарисован и ничего не делает, выглядит снаружи ровно как
// работающий. На этом уже дважды попались вес и стихии.
//
// Берётся по узлу из каждой ветви, вкачивается до потолка, и сверяются
// числа НА БОЙЦЕ и в вылазке — до и после.
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;
const Profile saved = pr;
game.NewRun(1);
game.Step(FIXED_DT);
const Agent& a0 = game.squad.agents[0];
const float dmgBare = a0.gun.def.damage;
const float hpBare = a0.maxHp;
const float speedBare = a0.speedMul;
const float parryBare = a0.melee.parry;
const float lightBare = game.squad.lightCharge;
// Ветвь ствола до «глубоких карманов», ветвь клинка до парирования,
// снабжение до фонаря, отряд до здоровья. Цепочка соблюдается: узел
// открывается предыдущим, и проверка обязана идти тем же путём, каким
// идёт игрок.
pr.bankedXp = 1e9f;
int bought = 0;
const int path[] = {
TreeIndex(Branch::GUN, 0), TreeIndex(Branch::GUN, 1),
TreeIndex(Branch::GUN, 2), TreeIndex(Branch::BLADE, 0),
TreeIndex(Branch::BLADE, 1), TreeIndex(Branch::SUPPLY, 0),
TreeIndex(Branch::SQUAD, 0), TreeIndex(Branch::SQUAD, 1),
TreeIndex(Branch::SQUAD, 2),
};
for (const int n : path)
for (int r = 0; r < TreeNodeAt(n).maxRank; ++r)
if (pr.BuyNode(n)) ++bought;
// Закрытый узел не покупается ни за какие деньги: цепочка — это правило,
// а не подсказка.
const int locked = TreeIndex(Branch::ELEMENT, 5);
const bool boughtLocked = pr.BuyNode(locked);
game.NewRun(1);
game.Step(FIXED_DT);
const Agent& a1 = game.squad.agents[0];
const TreeBonus tb = pr.Tree();
std::printf("\nдерево: куплено уровней %d, закрытый узел %s\n"
" урон ствола %.1f -> %.1f, здоровье %.0f -> %.0f,"
" скорость %.2f -> %.2f\n"
" парирование %.2f -> %.2f, свет %.0f -> %.0f\n",
bought, boughtLocked ? "КУПИЛСЯ" : "закрыт", double(dmgBare),
double(a1.gun.def.damage), double(hpBare), double(a1.maxHp), double(speedBare),
double(a1.speedMul), double(parryBare), double(a1.melee.parry),
double(lightBare), double(game.squad.lightCharge));
if (bought <= 0)
{
std::printf(" [FAIL] ни один узел не купился при полном банке\n");
++bad;
}
if (boughtLocked)
{
std::printf(" [FAIL] закрытый узел купился — цепочка ветви не держит\n");
++bad;
}
if (a1.gun.def.damage <= dmgBare)
{
std::printf(" [FAIL] ветвь ствола не дошла до оружия\n");
++bad;
}
if (a1.maxHp <= hpBare)
{
std::printf(" [FAIL] ветвь отряда не дошла до здоровья\n");
++bad;
}
if (a1.speedMul <= speedBare)
{
std::printf(" [FAIL] ветвь отряда не дошла до скорости\n");
++bad;
}
if (a1.melee.parry <= parryBare)
{
std::printf(" [FAIL] ветвь клинка не дошла до парирования\n");
++bad;
}
if (game.squad.lightCharge <= lightBare)
{
std::printf(" [FAIL] ветвь снабжения не дошла до фонаря\n");
++bad;
}
if (tb.gunDamage <= 0.0f || tb.health <= 0.0f)
{
std::printf(" [FAIL] сумма дерева пустая при купленных узлах\n");
++bad;
}
pr = saved;
game.NewRun(1);
}
// -------------------------------------------------------------------------
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
// влияет, выглядит точно так же, как работающее улучшение.
//
// Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает
// глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в
// одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра
// сломана, а потому что её сломал измеряющий.
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;
const float hpBefore = game.squad.agents[0].maxHp;
const int ammoBefore = int(g_tune.startAmmo);
pr.bankedXp = 10000.0f;
// Узлы: боезапас (ветвь ствола) и здоровье (ветвь отряда).
const int nodeAmmo = TreeIndex(Branch::GUN, 2);
const int nodeHealth = TreeIndex(Branch::SQUAD, 0);
// Цепочка: до «глубоких карманов» надо взять два узла над ними.
pr.BuyNode(TreeIndex(Branch::GUN, 0));
pr.BuyNode(TreeIndex(Branch::GUN, 1));
const bool boughtAmmo = pr.BuyNode(nodeAmmo);
const bool boughtVigor = pr.BuyNode(nodeHealth);
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;
}
// -----------------------------------------------------------------------------
// --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 first = TreeIndex(Branch::GUN, 0);
const int lvlBefore = profile.NodeRank(first);
UiActivate(ui, game, UiPart::UPGRADE_ITEM, first);
const bool bought = profile.NodeRank(first) > lvlBefore;
std::printf("\nулучшения: покупка %s\n", bought ? "работает" : "НЕ работает");
if (!bought)
{
std::printf(" [FAIL] кнопки экрана улучшений не покупают\n");
++bad;
}
}
std::printf("\nитог: пунктов %d, поломок %d\n", count, bad);
return bad == 0 ? 0 : 1;
}
// -----------------------------------------------------------------------------
// --wall: где обрывается забег.
//
// Главный вопрос кривой после того, как чекпоинты разошлись на пятьдесят
// этажей: до какой глубины доходит отряд ДАННОЙ силы. Ответ на него нельзя
// получить ни из головы, ни из `--descend` — тот собирает этажи, но ни с кем не
// воюет. Здесь отряд именно дерётся.
//
// Меряется грубо и честно: отряд ставится в середину населённого этажа и живёт
// там полторы минуты без управления. Это НИЖНЯЯ оценка — игрок уводит бойцов,
// выбирает, кого бить первым, и отступает; проверка не умеет ничего из этого.
// Поэтому глубина, на которой отряд перестаёт выживать здесь, — это не «конец
// игры», а место, где игра начинает требовать умения.
//
// Смысл цифр: если граница НЕ растёт с силой отряда, значит вылазки не
// накапливаются, и петля «несколько заходов до чекпоинта» не работает.
// -----------------------------------------------------------------------------
int RunWall(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int maxDepth)
{
std::printf("%-7s %-8s %8s %8s %8s %9s\n", "DEPTH", "ОТРЯД", "ТВАРЕЙ", "ВЫЖИЛО", "УБИЛ",
"ЗДОРОВЬЕ");
const int sizes[] = {1, 3, 5};
int lastAlive[3] = {0, 0, 0};
int wall[3] = {0, 0, 0};
for (int si = 0; si < 3; ++si)
{
const int size = sizes[si];
for (int d = 1; d <= maxDepth; d += (d < 10) ? 3 : 10)
{
Profile profile;
profile.ResetLoadouts();
profile.squadSize = size;
// Глубина рекорда — заведомо большая: NewRun режет старт по
// открытым чекпоинтам, а те теперь через пятьдесят этажей. С
// maxDepth = 50 любой запрос глубже первого молча уезжал на первый
// этаж, и проверка мерила два бредущих на каждой строке таблицы.
profile.maxDepth = 100000;
// Снаряжение по глубине: отряд, дошедший до d, одет примерно в то,
// что на d и находят. Без этого проверка мерила бы нож против
// сорокового этажа и объявляла стеной первый же поворот.
uint32_t rng = seed * 7919u + uint32_t(d);
for (int i = 0; i < size; ++i)
{
Inventory& inv = profile.loadout[i];
inv.weapon = RollItem((i == 0) ? ItemId::SHOTGUN : ItemId::RIFLE, d, rng);
inv.armor = RollItem((d >= 9) ? ItemId::PLATE : ItemId::VEST, d, rng);
inv.trinket = RollItem(ItemId::CHARM, d, rng);
if (i > 0) profile.merc[i] = RollMerc(rng);
}
Game game;
game.combatMode = true;
game.rooms = &rooms;
game.catalog = &catalog;
game.profile = &profile;
game.levelSeed = seed + uint32_t(d) * 17u;
game.Init();
game.NewRun(d);
// Отряд ставится в гущу: к точке, где стоит больше всего тварей.
// Иначе половина этажа до него просто не дойдёт, и проверка
// измерит планировку.
Vec2 crowd = game.squad.anchor;
float best = -1.0f;
for (const Target& t : game.targets)
{
if (!t.alive || t.IsProp() || t.IsDummy()) continue;
float near_ = 0.0f;
for (const Target& o : game.targets)
if (o.alive && !o.IsProp() && DistanceSq(o.pos, t.pos) < 36.0f) near_ += 1.0f;
if (near_ > best)
{
best = near_;
crowd = t.pos;
}
}
game.PlaceSquad(crowd, false);
int monsters = 0;
for (const Target& t : game.targets)
if (t.alive && !t.IsProp() && !t.IsDummy()) ++monsters;
for (int k = 0; k < 5400 && game.AliveAgents() > 0; ++k) game.Step(FIXED_DT);
int aliveNow = 0;
for (const Target& t : game.targets)
if (t.alive && !t.IsProp() && !t.IsDummy()) ++aliveNow;
float hp = 0.0f, maxHp = 0.0f;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
if (!a.present) continue;
hp += std::max(0.0f, a.hp);
maxHp += a.maxHp;
}
const int survivors = game.AliveAgents();
std::printf("%-7d %-8d %8d %8d %8d %8.0f%%\n", d, size, monsters, survivors,
monsters - aliveNow, double(100.0f * hp / std::max(1.0f, maxHp)));
if (survivors > 0) lastAlive[si] = d;
else if (wall[si] == 0) wall[si] = d;
}
std::printf("\n");
}
// Смотреть надо на ПЕРВУЮ стену, а не на «глубже всего дожили»: на глубоких
// этажах твари разбросаны, и отряд иногда доживает полторы минуты просто
// потому, что до него дошли не все. Последний выживший — шум, первая
// стена — сигнал.
// Ноль в wall[] означает «стены не нашлось ВООБЩЕ»: отряд пережил все
// проверенные глубины. Читать этот ноль как «стена на нулевом этаже» —
// ошибка мерки, а не игры: пятёрка, не сломавшаяся нигде, объявлялась худшей
// из трёх и роняла проверку. Не нашлось — значит глубже последней.
const int noWall = maxDepth + 1;
for (int si = 0; si < 3; ++si)
if (wall[si] == 0) wall[si] = noWall;
std::printf("первая стена:");
const char* names[3] = {"одиночка", "трое", "пятеро"};
for (int si = 0; si < 3; ++si)
std::printf(" %s D%d%s%s", names[si], wall[si],
wall[si] == noWall ? " (не найдена)" : "", si < 2 ? "," : "\n");
std::printf("глубже всего кто-то дожил (шумно): D%d / D%d / D%d\n", lastAlive[0],
lastAlive[1], lastAlive[2]);
int bad = 0;
if (wall[2] <= wall[0])
{
std::printf(" [FAIL] стена пятерых не глубже стены одиночки — вылазки не\n"
" накапливаются, и петля «несколько заходов до чекпоинта»\n"
" не работает\n");
++bad;
}
if (lastAlive[0] < 1)
{
std::printf(" [FAIL] одиночка не переживает даже первый этаж\n");
++bad;
}
return bad == 0 ? 0 : 1;
}
// -----------------------------------------------------------------------------
// --play N: автопилот. Отряд ИДЁТ К СПУСКУ и дерётся по дороге.
//
// Зачем ещё один прогон, когда есть `--descend` и `--wall`: они меряют разные
// куски, и ни один не меряет ИГРУ. `--descend` собирает этажи и ни с кем не
// воюет, `--wall` ставит отряд в толпу и запрещает ему двигаться. А жалоба
// игрока звучит иначе: «дольше пяти секунд не продержаться» — это про то, как
// проходится этаж целиком.
//
// Автопилот тупой намеренно: он ведёт курсор к спуску и всё. Он не отступает,
// не выбирает цели, не лечится вручную (лечение и так автоматическое). Это
// нижняя граница проходимости: игрок сыграет лучше. Если этаж не проходит даже
// автопилот — этаж непроходим.
// -----------------------------------------------------------------------------
int RunPlay(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors,
int squadSize)
{
Profile profile;
profile.ResetLoadouts();
profile.squadSize = std::max(1, std::min(NUM_AGENTS, squadSize));
profile.maxDepth = 100000; // чекпоинты не должны резать стартовую глубину
Game game;
game.combatMode = true;
game.rooms = &rooms;
game.catalog = &catalog;
game.profile = &profile;
game.levelSeed = seed;
game.Init();
game.NewRun(1);
std::printf("автопилот: отряд из %d, ведём к спуску, без отступлений\n\n",
profile.squadSize);
std::printf("%-6s %7s %7s %8s %8s %9s %8s %s\n", "DEPTH", "ТВАРЕЙ", "УБИЛ", "ЖИВЫХ",
"ЗДОРОВЬЕ", "ПАТРОНЫ", "СВЕТ", "ИТОГ");
int deepest = 0;
int wipedAt = 0;
for (int f = 0; f < floors && game.AliveAgents() > 0; ++f)
{
const int startMonsters = [&]() {
int n = 0;
for (const Target& t : game.targets)
if (t.alive && !t.IsProp() && !t.IsDummy()) ++n;
return n;
}();
// Поле расстояний до спуска. Считается один раз на этаж обычной
// заливкой по проходимым клеткам — тем же способом, каким генератор
// ищет саму точку спуска (engine/level_gen.cpp).
std::vector<int> flow(size_t(MAP_W) * MAP_H, -1);
{
std::vector<int> queue;
queue.reserve(size_t(MAP_W) * MAP_H);
const int ex = int(game.level.exitPos.x), ey = int(game.level.exitPos.y);
if (ex >= 0 && ey >= 0 && ex < MAP_W && ey < MAP_H)
{
flow[size_t(ey * MAP_W + ex)] = 0;
queue.push_back(ey * MAP_W + ex);
}
for (size_t head = 0; head < queue.size(); ++head)
{
const int cur = queue[head];
const int x = cur % MAP_W, y = cur / MAP_W;
// Только четыре соседа, БЕЗ диагоналей. По диагонали между
// двумя углами стен путь есть на сетке, но нет в мире: тело
// круглое и в щель не пролезает. Первая версия автопилота
// ходила по восьми и намертво вставала в первом же таком
// проходе — четыре клетки от старта.
const int step4[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (const auto& d4 : step4)
{
const int dx = d4[0], dy = d4[1];
const int nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
if (game.map.IsWall(nx, ny)) continue;
int& cell = flow[size_t(ny * MAP_W + nx)];
if (cell >= 0) continue;
cell = flow[size_t(cur)] + 1;
queue.push_back(ny * MAP_W + nx);
}
}
}
// Три минуты на этаж — с запасом: путь до спуска редко длиннее полутора.
constexpr int MAX_STEPS = 60 * 180;
bool reached = false;
int steps = 0;
int lastDist = -1; // на сколько клеток отряд был близок к спуску
int stuckFor = 0; // сколько шагов это число не меняется
for (; steps < MAX_STEPS && game.AliveAgents() > 0; ++steps)
{
// Куда идти — берём из ПОЛЯ РАССТОЯНИЙ до спуска, а не по прямой.
// Прямая упирается в первую же стену: у отряда нет поиска пути, он
// умеет только «иди туда». Первая версия автопилота именно так и
// застревала, показывая «этаж непроходим» там, где непроходимым был
// сам автопилот.
// Ведём КУРСОР, а не центр отряда: игрок водит именно его, а центр
// пятерых вообще может оказаться внутри стены, пока сами бойцы
// обходят её с двух сторон. На этом автопилот с пятёркой вставал на
// первом же этаже, хотя одиночка тот же этаж проходил.
const Vec2 c = game.squad.anchor;
const int cx = int(c.x), cy = int(c.y);
const int here = (cx >= 0 && cy >= 0 && cx < MAP_W && cy < MAP_H)
? flow[size_t(cy * MAP_W + cx)]
: -1;
// Цель — ЦЕНТР соседней клетки, а не «ось». По оси курсор дёргается
// между двумя клетками на границе и стоит на месте; по центру он
// идёт ровно.
// Цель — точка на НЕСКОЛЬКО клеток вперёд по спуску поля, а не
// соседняя клетка. По соседней курсор дёргается между двумя
// вариантами на каждой границе и стоит на месте; дальняя точка
// тянет ровно, как рука игрока.
// ...но НЕ ДАЛЬШЕ, ЧЕМ ВИДНО. Дальняя точка по полю может лежать за
// углом, и тогда прямая до неё идёт сквозь стену: курсор упирается
// в эту стену и стоит вплотную к спуску, до которого «две клетки по
// воздуху и шесть по полу». Берём самую дальнюю точку пути, которую
// видно, — ровно так же натягивает верёвку и сам отряд
// (Squad::NavWaypoint).
int bestDist = here;
Vec2 goal = c;
{
Vec2 hops[12];
int nHops = 0;
int wx = cx, wy = cy;
for (int hop = 0; hop < 12; ++hop)
{
int nextX = wx, nextY = wy;
int nextD = flow[size_t(wy * MAP_W + wx)];
const int look4[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (const auto& l4 : look4)
{
const int nx = wx + l4[0], ny = wy + l4[1];
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
const int d = flow[size_t(ny * MAP_W + nx)];
if (d < 0 || (nextD >= 0 && d >= nextD)) continue;
nextD = d;
nextX = nx;
nextY = ny;
}
if (nextX == wx && nextY == wy) break;
wx = nextX;
wy = nextY;
bestDist = nextD;
hops[nHops++] = Vec2{float(wx) + 0.5f, float(wy) + 0.5f};
}
if (nHops > 0)
{
goal = hops[0];
for (int h = nHops - 1; h >= 1; --h)
if (!game.map.RaycastBlocked(c, hops[h])) { goal = hops[h]; break; }
}
}
// Застряли — идём вторым по качеству соседом. Отряд упирается в
// угол или в собственную толпу, и «лучшее направление» перестаёт
// быть проходимым; живой игрок в этот момент просто обходит.
(void)bestDist;
if (here >= 0 && here == lastDist) ++stuckFor;
else stuckFor = 0;
lastDist = here;
game.in = InputState{};
const Vec2 toGoal = goal - c;
if (LengthSq(toGoal) > 1e-4f)
game.in.moveScreen = v2d::WorldDirToScreen(Normalized(toGoal));
game.Step(FIXED_DT);
if (game.atExit)
{
reached = true;
break;
}
}
int aliveNow = 0;
for (const Target& t : game.targets)
if (t.alive && !t.IsProp() && !t.IsDummy()) ++aliveNow;
float hp = 0.0f, maxHp = 0.0f;
int ammo = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
if (!a.present) continue;
hp += std::max(0.0f, a.hp);
maxHp += a.maxHp;
ammo += std::max(0, a.ammoReserve);
}
const char* verdict = reached ? "дошёл" : (game.AliveAgents() > 0 ? "ЗАСТРЯЛ" : "ВАЙП");
// Затык — самая дорогая поломка в игре и самая трудная в чтении: снаружи
// видно только «отряд не дошёл». Поэтому зонд обязан сказать, КТО встал
// и где, иначе следующий раз опять начнётся с ручной диагностики.
if (!reached && game.AliveAgents() > 0)
{
const Vec2 c = game.squad.anchor;
const int cx = int(c.x), cy = int(c.y);
const int fld = (cx >= 0 && cy >= 0 && cx < MAP_W && cy < MAP_H)
? flow[size_t(cy * MAP_W + cx)] : -1;
std::printf(" затык: курсор %.1f,%.1f (до спуска по полу %d кл.), "
"спуск %.1f,%.1f, состояние %d\n",
double(c.x), double(c.y), fld,
double(game.level.exitPos.x), double(game.level.exitPos.y),
int(game.squad.state));
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
if (!a.present || !a.alive) continue;
std::printf(" боец %d: %.1f,%.1f скорость %.2f отстал на %.1f%s\n", i,
double(a.pos.x), double(a.pos.y), double(Length(a.vel)),
double(Distance(a.pos, game.squad.SlotWorld(i))),
a.Grabbed() ? " (захвачен)" : "");
}
}
std::printf("%-6d %7d %7d %8d %7.0f%% %9d %7.0f %s (%.0f с)\n", game.depth, startMonsters,
startMonsters - aliveNow, game.AliveAgents(), double(100.0f * hp / std::max(1.0f, maxHp)),
ammo, double(game.squad.lightCharge), verdict, double(steps) * double(FIXED_DT));
if (game.AliveAgents() <= 0)
{
wipedAt = game.depth;
break;
}
deepest = game.depth;
if (!reached) break; // застрял — дальше мерить нечего
game.Descend();
}
std::printf("\nпройдено этажей: %d", deepest);
if (wipedAt > 0) std::printf(", вайп на D%d", wipedAt);
std::printf("\n");
int bad = 0;
if (deepest < 1)
{
std::printf(" [FAIL] автопилот не проходит ПЕРВЫЙ этаж — игра непроходима\n");
++bad;
}
if (wipedAt == 1)
{
std::printf(" [FAIL] отряд гибнет на первом этаже\n");
++bad;
}
return bad == 0 ? 0 : 1;
}