#include "debug/harness.h" #include #include #include #include #include #include #include #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(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(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(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(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(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(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 flow(size_t(MAP_W) * MAP_H, -1); { std::vector 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; }