#include "debug/harness.h" #include #include #include #include #include #include "game.h" // Перевод экранных осей ввода в мировые — единственное место, // где симуляционный слой касается изометрии (см. docs/02-architecture.md). #include "engine/view2d.h" namespace { // Мини-форматтер для строк вердикта (raylib в этот модуль не тащим). const char* Fmt(const char* fmt, ...) { static char buf[4][256]; static int slot = 0; slot = (slot + 1) % 4; va_list args; va_start(args, fmt); vsnprintf(buf[slot], sizeof(buf[slot]), fmt, args); va_end(args); return buf[slot]; } // --- маршрут для --headless --------------------------------------------------- struct Waypoint { float x, y; float hold; // сколько стоять по прибытии, сек int formation; // -1 = не менять }; const Waypoint kRoute[] = { {24.0f, 26.0f, 8.0f, 0}, // стоячий бой на арене у кучки из 5 {31.0f, 20.0f, 6.0f, -1}, // вплотную к кучке {31.0f, 16.5f, 0.0f, 1}, // ко входу в северный коридор (2 тайла) {31.0f, 11.5f, 2.0f, -1}, // сквозь коридор {24.5f, 7.5f, 8.0f, 0}, // бой в северной комнате {31.0f, 11.5f, 0.0f, -1}, {31.0f, 17.5f, 2.0f, -1}, // обратно на арену {20.0f, 29.0f, 4.0f, 2}, {16.0f, 31.0f, 0.0f, 1}, // ко входу в западный коридор {11.0f, 31.0f, 2.0f, -1}, // сквозь него { 8.5f, 38.5f, 8.0f, 0}, // бой в западной комнате {11.0f, 31.0f, 0.0f, -1}, {17.0f, 31.0f, 2.0f, -1}, {24.0f, 26.0f, 6.0f, 0}, }; constexpr int kRouteCount = int(sizeof(kRoute) / sizeof(kRoute[0])); constexpr float kArriveDist = 0.7f; constexpr float kWaypointTimeout = 20.0f; // Мировое направление -> экранные оси: ровно то, что делает игрок клавишами. Vec2 MoveInputToward(const Game& g, Vec2 goal) { return Normalized(v2d::WorldToScreen(Normalized(goal - g.squad.anchor))); } // ----------------------------------------------------------------------------- // Наблюдатель: собирает то, что нельзя увидеть в одном кадре. // * сколько агент непрерывно торчит в чужой линии огня (критерий 11.4); // * как часто меняется postPos (критерий 11.5 — «нет дёрганья»). // ----------------------------------------------------------------------------- struct Watcher { float residence[NUM_AGENTS] = {}; // текущее время внутри чужой линии float maxResidence = 0.0f; // «Дёрганье» = решатель перекоммитил postPos раньше, чем истёк commitTime. // Коммит виден по скачку commitTimer вверх. Смена состояния отряда сбрасывает // отсчёт: это новая обстановка, а не дрожание. float prevCommitTimer[NUM_AGENTS] = {}; float sinceCommit[NUM_AGENTS] = {}; bool hadCommit[NUM_AGENTS] = {}; float minCommitInterval = 1e9f; int commits = 0; int jitterViolations = 0; AgentState prevState = AgentState::HOLDING; // Граница сценария. Накопленные максимумы и счётчики остаются (они про весь // прогон), а вот «когда был прошлый коммит» — нет: следующий сценарий это // другой Game с другим отрядом, и мерить между ними интервал бессмысленно. // Без этого первый же коммит нового сценария выглядел дёрганьем. void BeginScenario() { for (int i = 0; i < NUM_AGENTS; ++i) { hadCommit[i] = false; sinceCommit[i] = 0.0f; prevCommitTimer[i] = 0.0f; residence[i] = 0.0f; } prevState = AgentState::HOLDING; } void Observe(const Game& g, float dt) { const bool stateChanged = (g.squad.state != prevState); prevState = g.squad.state; for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = g.squad.agents[i]; // Только линии огня: 11.4 про них, сектор клинка резервируется // заведомо шире опасной зоны (см. ai/lane_registry.h). const bool inside = g.lanes.CountBlocked(i, a.pos, a.bodyRadius, true) > 0; residence[i] = inside ? residence[i] + dt : 0.0f; maxResidence = std::max(maxResidence, residence[i]); if (stateChanged) hadCommit[i] = false; sinceCommit[i] += dt; if (a.commitTimer > prevCommitTimer[i] + 1e-4f) // произошёл коммит { ++commits; if (hadCommit[i]) { minCommitInterval = std::min(minCommitInterval, sinceCommit[i]); if (sinceCommit[i] < g_tune.commitTime - FIXED_DT) { ++jitterViolations; std::printf(" JITTER #%d dt=%.3f state=%s melee=%d prio=%d lane=%d " "post(%.2f,%.2f) t=%.2f\n", i, double(sinceCommit[i]), g.squad.state == AgentState::ADVANCING ? "ADV" : "HOLD", int(a.IsMelee()), int(a.priorityLane), int(a.hasLane), double(a.postPos.x), double(a.postPos.y), double(g.simTime)); } } hadCommit[i] = true; sinceCommit[i] = 0.0f; } prevCommitTimer[i] = a.commitTimer; } } }; int CountLanes(const Game& g) { int n = 0; for (int i = 0; i < NUM_AGENTS; ++i) if (g.squad.agents[i].hasLane) ++n; return n; } void PrintVerdict(const char* name, bool pass, const char* detail) { std::printf("[%s] %-52s %s\n", pass ? "PASS" : "FAIL", name, detail); } } // namespace // ----------------------------------------------------------------------------- // --headless: маршрут + метрики // ----------------------------------------------------------------------------- int RunHeadless(const HarnessOptions& opt) { Game game; game.enableSolver = opt.solver; game.enableLaneAvoid = opt.laneAvoid; game.squad.meleeCount = opt.meleeCount; // до Init: состав раздаётся там game.squad.pistolCount = opt.pistolCount; game.Init(); Watcher watch; const int totalSteps = int(opt.seconds / FIXED_DT); int wp = 0; bool holding = false; float phaseClock = 0.0f; int lastFormation = -1; double simSeconds = 0.0; float nextReport = 10.0f; float worstSettle = -1.0f; float worstHoldUptime = 1e9f; std::printf("headless: %.0f s, solver=%d laneAvoid=%d melee=%d/%d\n", double(opt.seconds), int(opt.solver), int(opt.laneAvoid), game.squad.meleeCount, NUM_AGENTS); std::printf(" t friendly shots kills uptime%% settle lanes\n"); for (int step = 0; step < totalSteps; ++step) { const Waypoint& p = kRoute[wp]; const float distToGoal = Distance(game.squad.anchor, {p.x, p.y}); InputState in; if (!holding) in.moveScreen = MoveInputToward(game, {p.x, p.y}); if (p.formation >= 0 && p.formation != lastFormation) { in.setFormation = p.formation; lastFormation = p.formation; } game.in = in; const auto t0 = std::chrono::steady_clock::now(); game.Step(FIXED_DT); simSeconds += std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); watch.Observe(game, FIXED_DT); phaseClock += FIXED_DT; if (!holding) { if (distToGoal < kArriveDist || phaseClock > kWaypointTimeout) { holding = true; phaseClock = 0.0f; } } else if (phaseClock >= p.hold) { if (p.hold >= 4.0f) { std::printf(" hold @ (%.0f,%.0f) %.0fs -> lanes %d/%d uptime %.1f%% settle %.2f\n", double(p.x), double(p.y), double(p.hold), CountLanes(game), NUM_AGENTS, double(game.metrics.uptimeAvg), double(game.metrics.lastSettle)); worstHoldUptime = std::min(worstHoldUptime, game.metrics.uptimeAvg); if (opt.probe) { const SolverContext ctx = game.MakeContext(); std::printf(" живых мишеней %d/%d\n", CountAliveTargets(game.targets), NUM_TARGETS); for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = game.squad.agents[i]; const int nearest = NearestAliveTarget(a.pos, ctx, 1e9f); const float d = (nearest >= 0) ? Distance(a.pos, game.targets[nearest].pos) : -1.0f; std::printf(" #%d pos(%.1f,%.1f) lane=%d t=%d ally=%d wall=%d " "nearest=%.1f post(%.1f,%.1f) uptime=%.0f%%\n", i, double(a.pos.x), double(a.pos.y), int(a.hasLane), a.currentTargetId, int(a.targetBlockedByAlly), int(a.targetBlockedByWall), double(d), double(a.postPos.x), double(a.postPos.y), double(game.metrics.uptimePct[i])); } } } holding = false; phaseClock = 0.0f; wp = (wp + 1) % kRouteCount; } worstSettle = std::max(worstSettle, game.metrics.lastSettle); if (game.simTime >= nextReport) { nextReport += 10.0f; std::printf("%6.1f %8d %6d %6d %7.1f %6.2f %d/%d\n", double(game.simTime), game.metrics.friendlyHits, game.metrics.shotsFired, game.metrics.targetsKilled, double(game.metrics.uptimeAvg), double(game.metrics.lastSettle), CountLanes(game), NUM_AGENTS); } } const double avgUs = (totalSteps > 0) ? (simSeconds * 1e6 / totalSteps) : 0.0; std::printf("---\nFRIENDLY_HITS = %d\nshots = %d kills = %d\n", game.metrics.friendlyHits, game.metrics.shotsFired, game.metrics.targetsKilled); std::printf("FIRE UPTIME avg = %.1f%% (по агентам:", double(game.metrics.uptimeAvg)); for (int i = 0; i < NUM_AGENTS; ++i) std::printf(" %.0f", double(game.metrics.uptimePct[i])); std::printf(")\nхудший uptime на стоянке = %.1f%%\n", double(worstHoldUptime)); std::printf("SETTLE last = %.2f s, worst = %.2f s\n", double(game.metrics.lastSettle), double(worstSettle)); if (game.squad.meleeCount > 0) std::printf("MELEE: в зоне удара %.1f%% времени, взмахов %d, попаданий %d, оборвано %d\n", double(game.metrics.meleeUptimeAvg), game.metrics.meleeSwings, game.metrics.meleeHits, game.metrics.meleeAborts); std::printf("макс. время в чужой линии = %.2f s, дёрганий postPos = %d\n", double(watch.maxResidence), watch.jitterViolations); std::printf("шаг симуляции в среднем = %.1f us (бюджет кадра 16666 us)\n", avgUs); for (int i = 0; i < game.metrics.hitLogCount; ++i) { const FriendlyHitEvent& e = game.metrics.hitLog[i]; std::printf(" friendly hit t=%.2f #%d -> #%d @ (%.1f,%.1f), жертва %s\n", double(e.time), e.shooter, e.victim, double(e.pos.x), double(e.pos.y), e.victimAdvancing ? "ADVANCING" : "HOLDING"); } return game.metrics.friendlyHits == 0 ? 0 : 1; } // ----------------------------------------------------------------------------- // --accept: сценарии из раздела 11 // ----------------------------------------------------------------------------- int RunAccept() { bool allPass = true; Watcher watch; // общий: коммиты postPos по всем сценариям Watcher arenaWatch; // открытая арена: там и меряется критерий 11.4 // Критерии 11.1 / 11.6 / 11.7 / 11.8 описывают ПРАВИЛА СТРЕЛЬБЫ и меряются // на отряде из пяти стрелков — так же, как их формулировал спек. Клинок // проверяется отдельно (блок M7 ниже) и в трёхминутном прогоне 11.2, где // состав отряда обычный, боевой. const int kSpecSquadMelee = 0; const int kSpecSquadPistol = 0; // === 11.1 + 11.8: отряд в куче на открытой арене, игрок останавливается ==== { Game game; game.squad.meleeCount = kSpecSquadMelee; game.squad.pistolCount = kSpecSquadPistol; game.Init(); watch.BeginScenario(); // интервалы коммитов меряются внутри сценария game.PlaceSquad({28.0f, 23.5f}, true); // кучка из 5 мишеней в радиусе // 0.4 с движения, чтобы отряд был в ADVANCING, потом игрок отпускает клавиши for (int i = 0; i < int(0.4f / FIXED_DT); ++i) { game.in = InputState{}; game.in.moveScreen = {0.0f, 1.0f}; game.Step(FIXED_DT); } float clock = 0.0f; float settle4 = -1.0f; for (int i = 0; i < int(25.0f / FIXED_DT); ++i) { game.in = InputState{}; game.Step(FIXED_DT); watch.Observe(game, FIXED_DT); arenaWatch.Observe(game, FIXED_DT); clock += FIXED_DT; if (settle4 < 0.0f && CountLanes(game) >= 4) settle4 = clock; } const bool p1 = (settle4 >= 0.0f && settle4 <= 1.5f); const bool p8 = (game.metrics.uptimeAvg >= 80.0f); allPass = allPass && p1 && p8; PrintVerdict("11.1 >=4/5 линий за <=1.5 с после остановки", p1, Fmt("settle=%.2f с, линий сейчас %d/5", double(settle4), CountLanes(game))); PrintVerdict("11.8 FIRE UPTIME в стоячем бою >= 80%", p8, Fmt("uptime=%.1f%%", double(game.metrics.uptimeAvg))); } // === 11.6: узкий проход в 2 тайла ========================================= // Коридор: стена y=13..15, проём x=30..31 -> движение вдоль -y, поперёк = x. { Game game; game.squad.meleeCount = kSpecSquadMelee; game.squad.pistolCount = kSpecSquadPistol; game.Init(); watch.BeginScenario(); // интервалы коммитов меряются внутри сценария game.PlaceSquad({31.0f, 19.0f}, false); Watcher corridorWatch; double lateralSum = 0.0; // усреднённый разброс поперёк/вдоль движения double longitudinalSum = 0.0; int maxInside = 0; // Считаются только выстрелы ВПЕРЁД, по ходу движения: именно там между // стрелком и целью может оказаться свой. Выстрел назад, в оставленную // позади комнату, к правилу «не стрелять сквозь своего» отношения не // имеет — впереди по такой линии никого из отряда нет по построению. int rearShotsInside = 0; // вперёд стреляет НЕ передний — вот это важно int frontShotsInside = 0; int backwardShots = 0; // для отчёта: сколько огня ушло назад int throughAllyShots = 0; // выстрелов, прошедших через тело союзника int samples = 0; // Две фазы: сначала входим в проход, потом ВСТАЁМ и меряем. // // Мерить на ходу нельзя: пока игрок тянет курсор, тот всегда обгоняет // отряд (anchorSpeed 3.3 против moveSpeed 1.75), натяжение поводка // упирается в упор и включается спринт, а в спринте отряд по замыслу не // стреляет вовсе (Game::FireWeapons). Критерий 11.6 проверял бы пустоту. // Боевая ситуация в проходе — это именно «вошли и работаем»: тогда // передний держит линию, а задние упираются в его спину. // Меряем ВСЮ проводку через горловину, а не только стоянку в ней. // Проход глубиной три тайла, а бойцов пятеро — колонна физически длиннее // самого прохода и целиком внутрь него не помещается никогда. Поэтому // область замера это горловина ПЛЮС подходы с обеих сторон: именно на // этом отрезке видно, вытянулся отряд в нитку или пошёл шеренгой. // // Порог входа в шеренгу узкий по построению: сам проём шириной два тайла. const int totalSteps = int(26.0f / FIXED_DT); const int advanceSteps = int(16.0f / FIXED_DT); for (int i = 0; i < totalSteps; ++i) { game.in = InputState{}; // Ведём отряд сквозь проход и оставляем работать по северной кучке. if (i < advanceSteps) game.in.moveScreen = MoveInputToward(game, {31.0f, 9.0f}); game.Step(FIXED_DT); watch.Observe(game, FIXED_DT); corridorWatch.Observe(game, FIXED_DT); // Кто сейчас в створе прохода (горловина + подходы). int inside[NUM_AGENTS]; int insideCount = 0; for (int k = 0; k < NUM_AGENTS; ++k) { const Vec2 p = game.squad.agents[k].pos; if (p.y > 9.0f && p.y < 19.0f && p.x > 28.0f && p.x < 34.0f) inside[insideCount++] = k; } if (insideCount < 2) continue; ++samples; maxInside = std::max(maxInside, insideCount); float xMin = 1e9f, xMax = -1e9f, yMin = 1e9f, yMax = -1e9f; float frontY = 1e9f; for (int n = 0; n < insideCount; ++n) { const Vec2 p = game.squad.agents[inside[n]].pos; xMin = std::min(xMin, p.x); xMax = std::max(xMax, p.x); yMin = std::min(yMin, p.y); yMax = std::max(yMax, p.y); frontY = std::min(frontY, p.y); } // Колонна = вытянуты вдоль движения сильнее, чем поперёк. lateralSum += double(xMax - xMin); longitudinalSum += double(yMax - yMin); for (int n = 0; n < insideCount; ++n) { const Agent& a = game.squad.agents[inside[n]]; if (!a.fired) continue; const Vec2 tp = (a.currentTargetId >= 0) ? game.targets[a.currentTargetId].pos : Vec2{}; // НАСТОЯЩАЯ проверка правила, независимая от реализации: берём // отрезок реально сделанного выстрела и заново, своей геометрией, // смотрим, не задевает ли он тело союзника. Порог физический // (тело + полуширина трассы), без запаса safetyMargin: выстрел // впритирку, но мимо тела, правило не нарушает. const Vec2 muzzle = a.MuzzleTowards(tp); for (int k = 0; k < NUM_AGENTS; ++k) { if (k == inside[n]) continue; const Agent& ally = game.squad.agents[k]; if (DistancePointToSegment(ally.pos, muzzle, tp) < ally.bodyRadius + g_tune.laneHalfWidth) ++throughAllyShots; } // Разбивка ниже — только для отчёта. Задний, стреляющий ПОД УГЛОМ // мимо переднего, ничего не нарушает: проход шириной 2 тайла даёт // законные диагональные линии, и это подтверждается проверкой выше. const bool forward = Dot(tp - a.pos, game.squad.forward) > 0.0f; const bool rear = a.pos.y > frontY + 0.5f; if (!forward) { ++backwardShots; continue; } if (rear) ++rearShotsInside; else ++frontShotsInside; } } const double lateralAvg = (samples > 0) ? lateralSum / samples : 0.0; const double longAvg = (samples > 0) ? longitudinalSum / samples : 0.0; // Что здесь проверяется на самом деле. // // «Задние не стреляют» — это СЛЕДСТВИЕ правила «не стрелять сквозь // своего», а не отдельное правило. В проходе шириной 2 тайла у заднего // бойца есть законные диагональные линии мимо переднего, и считать их // нарушением — значит требовать от отряда быть глупее, чем он есть. // Поэтому считаем не «кто стрелял», а инвариант: НИ ОДИН выстрел в // проходе не прошёл через тело союзника (перепроверено своей геометрией // по факту выстрела) и ни одного попадания по своим. // // Плюс форма: отряд обязан вытянуться в колонну, а не идти шеренгой. // Пороги с запасом: строгое неравенство на маленьких числах — монетка, // а не тест. const bool column = longAvg > lateralAvg * 1.5; const bool clean = (throughAllyShots == 0) && (game.metrics.friendlyHits == 0); const bool enough = (frontShotsInside + rearShotsInside) >= 4; const bool p6 = (samples > 0) && (maxInside >= 2) && column && clean && enough; allPass = allPass && p6; PrintVerdict("11.6 в проходе колонна, огонь не идёт сквозь своих", p6, Fmt("до %d агентов в проходе, поперёк %.2f / вдоль %.2f тайла, " "выстрелов вперёд передний/хвост %d/%d (назад %d), " "сквозь своего %d, FRIENDLY_HITS=%d", maxInside, lateralAvg, longAvg, frontShotsInside, rearShotsInside, backwardShots, throughAllyShots, game.metrics.friendlyHits)); } // === 11.7: ручной режим, назначенная цель ================================= { Game game; game.squad.meleeCount = kSpecSquadMelee; game.squad.pistolCount = kSpecSquadPistol; game.Init(); watch.BeginScenario(); // интервалы коммитов меряются внутри сценария game.PlaceSquad({28.0f, 23.5f}, true); // Включаем ручной режим и наводим вектор прицела на кучку. InputState toggle; toggle.toggleAimMode = true; game.in = toggle; game.Step(FIXED_DT); int stepsWithAssigned = 0; for (int i = 0; i < int(30.0f / FIXED_DT); ++i) { game.in = InputState{}; // Медленно крутим прицел — Tab каждые 5 секунд. game.in.aimTurn = (i < int(3.0f / FIXED_DT)) ? 1.0f : 0.0f; game.in.cycleTarget = (i % int(5.0f / FIXED_DT)) == 0; game.Step(FIXED_DT); watch.Observe(game, FIXED_DT); if (game.aim.assignedTargetId >= 0) ++stepsWithAssigned; } const bool p7 = (game.metrics.friendlyHits == 0) && (stepsWithAssigned > 0); allPass = allPass && p7; PrintVerdict("11.7 ручной режим не стреляет сквозь своих", p7, Fmt("FRIENDLY_HITS=%d, назначенная цель была %.0f%% времени", game.metrics.friendlyHits, 100.0 * double(stepsWithAssigned) / (30.0 / FIXED_DT))); } // === M7: ближний бой ====================================================== // Смешанный отряд у кучки из пяти мишеней. Проверяется ровно то, что // требовалось от механики: клинок доходит и бьёт, никого из своих при этом // не задевает, и каждый класс живёт в СВОЁМ круге вокруг диска. { Game game; game.Init(); // состав по умолчанию: 2 клинка watch.BeginScenario(); // интервалы коммитов меряются внутри сценария game.PlaceSquad({28.0f, 23.5f}, true); float maxMeleeFromAnchor = 0.0f; float maxRangedFromAnchor = 0.0f; for (int i = 0; i < int(30.0f / FIXED_DT); ++i) { game.in = InputState{}; game.Step(FIXED_DT); watch.Observe(game, FIXED_DT); for (int k = 0; k < NUM_AGENTS; ++k) { const Agent& a = game.squad.agents[k]; const float d = Distance(a.pos, game.squad.anchor); if (a.IsMelee()) maxMeleeFromAnchor = std::max(maxMeleeFromAnchor, d); else maxRangedFromAnchor = std::max(maxRangedFromAnchor, d); } } const Metrics& m = game.metrics; // Порог круга с запасом на инерцию: цель может быть у самой границы, // и боец имеет право дотянуться до неё, а не тормозить по линейке. const float meleeLimit = g_tune.meleeCohesionRadius + 1.2f; const float rangedLimit = g_tune.cohesionRadius + 1.6f; const bool pM1 = (m.meleeHits > 0) && (m.meleeSwings > 0); const bool pM2 = (m.friendlyHits == 0); const bool pM3 = (maxMeleeFromAnchor <= meleeLimit) && (maxRangedFromAnchor <= rangedLimit) && (maxRangedFromAnchor < maxMeleeFromAnchor); allPass = allPass && pM1 && pM2 && pM3; PrintVerdict("M7.1 клинок доходит до цели и наносит урон", pM1, Fmt("взмахов %d, попаданий %d, оборвано своими %d, убито %d; " "связка %s=%d %s=%d %s=%d", m.meleeSwings, m.meleeHits, m.meleeAborts, m.targetsKilled, MELEE_COMBO[0].name, m.meleeComboSwings[0], MELEE_COMBO[1].name, m.meleeComboSwings[1], MELEE_COMBO[2].name, m.meleeComboSwings[2])); const bool pM4 = m.meleeMaxHitExcessDeg <= 12.0f; allPass = allPass && pM4; PrintVerdict("M7.4 лезвие не достаёт за пределы своей дуги", pM4, Fmt("максимум за сектором %.1f град (лимит 12)", double(m.meleeMaxHitExcessDeg))); PrintVerdict("M7.2 ближний бой не задевает своих", pM2, Fmt("FRIENDLY_HITS=%d при %d взмахах", m.friendlyHits, m.meleeSwings)); PrintVerdict("M7.3 у каждого класса свой круг вокруг диска", pM3, Fmt("стрелки до %.2f (лимит %.2f), клинки до %.2f (лимит %.2f)", double(maxRangedFromAnchor), double(rangedLimit), double(maxMeleeFromAnchor), double(meleeLimit))); } // === 11.2: три минуты непрерывного боя во всех режимах ===================== { HarnessOptions opt; opt.seconds = 180.0f; // состав боевой: смешанный отряд, как в игре std::printf("--- прогон 180 с по маршруту (критерий 11.2) ---\n"); const int rc = RunHeadless(opt); const bool p2 = (rc == 0); allPass = allPass && p2; PrintVerdict("11.2 FRIENDLY_HITS = 0 за 3 минуты", p2, p2 ? "0 попаданий" : "см. журнал"); } // === 11.4 + 11.5 ========================================================== // 11.4 меряется на открытой арене: в проходе 2 тайла разойтись физически // некуда, и это ровно тот геометрический тест-кейс, о котором говорит спек. const bool p4 = arenaWatch.maxResidence <= 0.4f; const bool p5 = watch.jitterViolations == 0; allPass = allPass && p4 && p5; PrintVerdict("11.4 агент уходит с чужой линии за ~0.3 с", p4, Fmt("на арене максимум %.2f с (по всем сценариям %.2f с)", double(arenaWatch.maxResidence), double(watch.maxResidence))); PrintVerdict("11.5 нет дёрганья postPos чаще commitTime", p5, Fmt("коммитов %d, нарушений %d, минимальный интервал %.2f с", watch.commits, watch.jitterViolations, double(watch.minCommitInterval > 1e8f ? 0.0f : watch.minCommitInterval))); std::printf("\nИТОГ: %s\n", allPass ? "все проверяемые критерии PASS" : "есть FAIL"); return allPass ? 0 : 1; } // ----------------------------------------------------------------------------- // --descend N: забег на N этажей без окна. // // Проверяет ровно то, что нельзя проверить ни скриншотом, ни `--check`: // цепочку «спуск -> пересборка мира -> перенос отряда». Ошибка здесь не // падает и не рисуется — она выглядит как отряд, приехавший вниз с пустыми // руками, и заметить её на глаз почти невозможно. // // Симуляция не шагает: здесь важна сборка, а не бой. // ----------------------------------------------------------------------------- int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t seed, int floors) { // Свой профиль в памяти, не с диска: прогон не имеет права трогать чужой // схрон, а без профиля не проверить ни эвакуацию, ни чекпоинты. Profile profile; profile.ResetLoadouts(); Game game; game.combatMode = true; game.rooms = &rooms; game.catalog = &catalog; game.profile = &profile; game.levelSeed = seed; game.Init(); std::printf("%-6s %-7s %7s %6s %5s %6s %s\n", "DEPTH", "THEME", "MONSTER", "DANGER", "LOOT", "EXIT", "SQUAD"); int bad = 0; for (int i = 0; i < floors; ++i) { const GeneratedLevel& l = game.level; if (!game.levelGenerated) { std::printf("[FAIL] этаж %d не собрался\n", game.depth); return 1; } // Сумки считаются каждый этаж: именно они переезжают вниз, и именно // их пропажа была бы тихой. Вместе с ними — НАГРУЗКА: без неё не видно, // связывает вес хоть что-то или он остался числом в каталоге. const int bags = game.squad.bag.Count(); float maxLoad = 0.0f; int overloaded = 0; for (int a = 0; a < NUM_AGENTS; ++a) { const float load = game.squad.agents[a].load; if (load > maxLoad) maxLoad = load; if (load > CARRY_LIMIT) ++overloaded; } // Патроны — главный ресурс вылазки, и в прогоне он обязан стоять // столбцом: «дошли до восьмого» само по себе не говорит, было ли чем // стрелять на восьмом. int ammoLeft = 0; for (int a = 0; a < NUM_AGENTS; ++a) ammoLeft += std::max(0, game.squad.agents[a].ammoReserve); std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f" " (перегруз %d), патронов %d\n", l.floor.depth, ThemeName(l.floor.theme), l.monstersPlaced, double(l.dangerSpent), int(l.loot.size()), l.exitDistance, game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft); if (l.exitDistance <= 0) { std::printf(" [FAIL] спуск совпал со стартом\n"); ++bad; } if (l.spawnsDropped > 0 || l.spawnsUnreachable > 0) { std::printf(" [FAIL] срезано %d, недостижимо %d\n", l.spawnsDropped, l.spawnsUnreachable); ++bad; } // Кладём каждому в сумку по предмету перед спуском: если перенос // сломан, на следующем этаже сумки окажутся пустыми, и это видно // в столбце прямо здесь. // Кладём в ОБЩУЮ сумку по предмету на бойца перед спуском: если // перенос сломан, на следующем этаже сумка окажется пустой. for (int a = 0; a < NUM_AGENTS; ++a) game.squad.bag.Take(ItemId::BANDAGE, game.squad.Carriers(), 0.0f); if (i + 1 < floors) game.Descend(); } // Сколько довезли до низа — считается ДО опытов ниже: они меняют содержимое // сумки, и посчитав после, проверка «доехало ли нажитое» сравнивала бы // сумку с собственноручно разложенной сумкой, то есть проходила бы всегда. const int bagsBefore = game.squad.bag.Count(); // ------------------------------------------------------------------------- // Расходники. Проверяется правило, а не побочные цифры прогона: спуск ни с // кем не воюет, поэтому патроны сами по себе не кончатся, и «столбец не // изменился» не значит ничего. // // Условия ставятся руками: у бойца пусто в стволе и в запасе, в сумке пачка // патронов и бинт, здоровье срезано, живых тварей рядом нет. Дальше — шаг // симуляции, и оба расходника обязаны сработать сами. // ------------------------------------------------------------------------- { Agent& a = game.squad.agents[0]; Inventory* inv = game.world.Get(a.self); SquadBag& bag = game.squad.bag; // Опыт разбирает сумку под себя, а после него проверяется эвакуация — // то есть ровно то, что он разобрал. Инструмент обязан вернуть мир // как было, иначе следующая проверка меряет его следы. const SquadBag savedBag = bag; const float savedHp = a.hp; if (!inv) { std::printf("\n [FAIL] у бойца нет инвентаря — расходники не проверить\n"); ++bad; } else { // Лечиться можно только вне схватки, поэтому поле зачищается. for (Target& t : game.targets) t.alive = false; bag.Clear(); bag.slots[0] = ItemId::AMMO; bag.slots[1] = ItemId::BANDAGE; // Ствол в сумке обязателен: патроны нужны тому, у кого есть из чего // стрелять, а в руках у этого бойца клинок. Без него проверка // мерила бы не правило, а свою же расчищенную сумку. bag.slots[2] = ItemId::RIFLE; a.alive = true; a.hp = a.maxHp * 0.3f; a.ammoReserve = 0; a.gun.ammo = 0; a.gun.reloadTimer = 0.0f; const float hpBefore = a.hp; for (int k = 0; k < 8; ++k) game.Step(FIXED_DT); const bool loaded = a.ammoReserve >= AMMO_BOX_ROUNDS; const bool healed = a.hp > hpBefore; const bool spent = bag.slots[0] == ItemId::NONE && bag.slots[1] == ItemId::NONE; std::printf("\nрасходники: патроны %s, лечение %s (%.0f -> %.0f), из сумки ушли %s\n", loaded ? "набиты" : "НЕ набиты", healed ? "сработало" : "НЕ сработало", double(hpBefore), double(a.hp), spent ? "обе" : "не все"); if (!loaded) { std::printf(" [FAIL] пачка патронов не пошла в запас — стрелять нечем\n"); ++bad; } if (!healed) { std::printf(" [FAIL] бинт не применился: расходники снова мёртвый груз\n"); ++bad; } if (!spent) { std::printf(" [FAIL] потраченное осталось в сумке\n"); ++bad; } // И главное правило: перезарядка тянет ИЗ ЗАПАСА. Прогон спуска ни // с кем не воюет, поэтому столбец «патронов» в таблице выше стоит // на месте — и без этой проверки бесконечный боезапас выглядел бы // ровно так же. EquipWeapon(game.world, a, ItemId::RIFLE); game.Step(FIXED_DT); // SyncLoadout переносит ствол на бойца const int reserveBefore = a.ammoReserve; a.gun.ammo = 0; for (int k = 0; k < 200 && a.ammoReserve == reserveBefore; ++k) game.Step(FIXED_DT); const int spentRounds = reserveBefore - a.ammoReserve; std::printf("перезарядка: запас %d -> %d (магазин %d)\n", reserveBefore, a.ammoReserve, a.gun.def.magazine); if (spentRounds <= 0) { std::printf(" [FAIL] магазин набился ниоткуда — боезапас бесконечен\n"); ++bad; } // Мир возвращается как был: дальше проверяется эвакуация, то есть // ровно то содержимое сумки, которое опыт только что разобрал. bag = savedBag; a.hp = savedHp; } } // ------------------------------------------------------------------------- // Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз» // выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами, // и по ним вес выглядит втрое меньше настоящего. Мерить правило по такой // цифре — верный способ выставить пороги, которые ничего не ограничивают. // ------------------------------------------------------------------------- { // Один боец несёт ствол и общую сумку целиком: доля на одного — это // худший случай, тот самый, в котором отряд остался вдвоём или один. const float gun = ItemDefOf(ItemId::RIFLE).weight; SquadBag probe; // Три находки среднего веса — отряд, который взял нужное и ушёл. probe.Take(ItemId::SABER, 1, gun); probe.Take(ItemId::LANTERN, 1, gun); probe.Take(ItemId::VEST, 1, gun); const float light = gun + probe.Load(); // Добираем до отказа: ячейки кончатся или кончится переноска. int added = 0; while (probe.Take(ItemId::PLATE, 1, gun)) ++added; const float heavy = gun + probe.Load(); const bool freeAtLight = CarryMul(light) > 0.999f; const bool slowAtHeavy = CarryMul(heavy) < 0.95f; const bool refused = probe.FreeSlot() >= 0; // отказ по ВЕСУ, а не по ячейкам std::printf("\nпереноска: налегке %.1f (скорость %.0f%%), с грузом %.1f " "(скорость %.0f%%), плит влезло %d\n", double(light), double(CarryMul(light) * 100.0f), double(heavy), double(CarryMul(heavy) * 100.0f), added); if (!freeAtLight) { std::printf(" [FAIL] обычное снаряжение уже тормозит отряд\n"); ++bad; } if (!slowAtHeavy) { std::printf(" [FAIL] полная сумка не стоит ничего — вес не связывает\n"); ++bad; } if (!refused) { std::printf(" [FAIL] предел переноски не сработал: остановили ячейки, не вес\n"); ++bad; } } // Эвакуация с последнего этажа. Без этого шага «спуск работает» ничего не // значит: вся суть экстракшена в том, что происходит на выходе. game.Extract(); const RunReport& r = game.report; std::printf("\nэвакуация: вынесено %d, потеряно %d, выживших %d, чекпоинт D%d\n", r.itemsBrought, r.itemsLost, r.survivors, r.checkpoint); if (r.outcome != RunOutcome::EXTRACTED) { std::printf(" [FAIL] исход не эвакуация\n"); ++bad; } // Вынесено считается по ВСЕМУ снаряжению, а не только по сумкам, поэтому // проверяем неравенством: меньше содержимого сумок быть не может. if (r.itemsBrought < bagsBefore) { std::printf(" [FAIL] в сумках было %d, до профиля доехало %d\n", bagsBefore, r.itemsBrought); ++bad; } if (profile.maxDepth < game.depth) { std::printf(" [FAIL] глубина %d не записалась в профиль (%d)\n", game.depth, profile.maxDepth); ++bad; } // Круговой прогон профиля через ЗАПИСЬ. Здесь он не для полноты: запись // профиля — единственный путь, которым игра может стереть всё нажитое, и // ошибка в нём выглядит как «схрон куда-то делся», а не как падение. { const std::string tmp = (std::filesystem::temp_directory_path() / "squad_profile_roundtrip.txt").string(); std::string why; Profile back; if (!profile.Save(tmp, &why)) { std::printf(" [FAIL] профиль не пишется: %s\n", why.c_str()); ++bad; } else if (!back.Load(tmp, &why)) { std::printf(" [FAIL] профиль не читается обратно: %s\n", why.c_str()); ++bad; } else { int diff = 0; if (back.maxDepth != profile.maxDepth) ++diff; if (back.StashCount() != profile.StashCount()) ++diff; for (int a = 0; a < NUM_AGENTS; ++a) { const Inventory& x = profile.loadout[a]; const Inventory& y = back.loadout[a]; if (x.weapon != y.weapon || x.armor != y.armor || x.trinket != y.trinket) ++diff; } if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; } else std::printf("профиль: запись и чтение сходятся\n"); } std::error_code ec; std::filesystem::remove(tmp, ec); } // ------------------------------------------------------------------------- // ------------------------------------------------------------------------- // Версия профиля. Формат ломался дважды за три вехи, и оба раза старый файл // читался молча и частично. Проверяются оба направления: старый читается с // миграцией, файл из будущего НЕ читается вовсе. // ------------------------------------------------------------------------- { const std::filesystem::path old = std::filesystem::temp_directory_path() / "t2d_v1.txt"; { std::ofstream f(old); // Формат 1: версии нет, у бойца шесть посумочных лексем. f << "maxdepth 7\nruns 3\nxp 4200\n"; f << "agent 0 rifle vest charm bandage stimpack - - - -\n"; f << "stash saber plate\n"; } Profile v1; std::string why; const bool readOld = v1.Load(old.string(), &why); const std::filesystem::path future = std::filesystem::temp_directory_path() / "t2d_future.txt"; { std::ofstream f(future); f << "version " << (PROFILE_VERSION + 1) << "\nmaxdepth 99\n"; } Profile vNext; std::string whyFuture; const bool readFuture = vNext.Load(future.string(), &whyFuture); std::error_code ec; std::filesystem::remove(old, ec); std::filesystem::remove(future, ec); std::printf("\nпрофиль: старый формат %s (глубина %d, схрон %d), из будущего %s\n", readOld ? "прочитан" : "НЕ прочитан", v1.maxDepth, v1.StashCount(), readFuture ? "ПРОЧИТАН" : "отвергнут"); if (!readOld || v1.maxDepth != 7 || v1.StashCount() != 2) { std::printf(" [FAIL] миграция со старого формата потеряла данные: %s\n", why.c_str()); ++bad; } if (readFuture) { std::printf(" [FAIL] профиль новее игры прочитан — запись поверх сотрёт чужое\n"); ++bad; } } // Итог забега снимается ДО проверки улучшений: та начинает новую вылазку и // обнуляет счётчик этажей. Печатать его после значило бы отчитаться // цифрой, которую испортил измеряющий. const int floorsDone = game.floorsCleared + 1; const float xpEarned = game.xp; // Опыт тратится. Проверяется цепочка целиком: купили — подействовало на // // Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает // глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в // одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра // сломана, а потому что её сломал измеряющий. // отряд — пережило запись профиля. Число в профиле, которое ни на что не // влияет, выглядит точно так же, как работающее улучшение. // ------------------------------------------------------------------------- { Profile& pr = *game.profile; const float hpBefore = game.squad.agents[0].maxHp; const int ammoBefore = int(g_tune.startAmmo); pr.bankedXp = 10000.0f; const bool boughtAmmo = pr.Buy(Upgrade::AMMO); const bool boughtVigor = pr.Buy(Upgrade::VIGOR); const float leftXp = pr.bankedXp; // Улучшения раздаются вместе со снаряжением, то есть на новой вылазке. game.NewRun(1); game.Step(FIXED_DT); const float hpAfter = game.squad.agents[0].maxHp; const int ammoAfter = game.squad.agents[0].ammoReserve; std::printf("\nулучшения: здоровье %.0f -> %.0f, патроны %d -> %d, " "опыта осталось %.0f\n", double(hpBefore), double(hpAfter), ammoBefore, ammoAfter, double(leftXp)); if (!boughtAmmo || !boughtVigor) { std::printf(" [FAIL] покупка не прошла при полном кошельке\n"); ++bad; } if (hpAfter <= hpBefore) { std::printf(" [FAIL] живучесть куплена, но здоровье не выросло\n"); ++bad; } if (ammoAfter <= ammoBefore) { std::printf(" [FAIL] запас куплен, но патронов не прибавилось\n"); ++bad; } if (leftXp >= 10000.0f) { std::printf(" [FAIL] опыт не списался: покупка бесплатная\n"); ++bad; } } std::printf("\nитог: этажей %d, опыта %.0f, ошибок %d\n", floorsDone, double(xpEarned), bad); return bad == 0 ? 0 : 1; }