Отряд из пяти не проходил первый этаж ни на одном сиде, а одиночка проходил. Дело было не в ширине коридоров: у бойцов не было поиска пути вообще. Боец, оказавшийся за стеной от своего слота, 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>
2990 lines
157 KiB
C++
2990 lines
157 KiB
C++
#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;
|
||
}
|