604 lines
32 KiB
C++
604 lines
32 KiB
C++
|
|
#include "debug/harness.h"
|
|||
|
|
|
|||
|
|
#include <chrono>
|
|||
|
|
#include <cstdarg>
|
|||
|
|
#include <cstdio>
|
|||
|
|
|
|||
|
|
#include "game.h"
|
|||
|
|
|
|||
|
|
// Перевод экранных осей ввода в мировые — единственное место,
|
|||
|
|
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
|
|||
|
|
#include "render/iso.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(iso::WorldToScreen(Normalized(goal - g.squad.anchor)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// -----------------------------------------------------------------------------
|
|||
|
|
// Наблюдатель: собирает то, что нельзя увидеть в одном кадре.
|
|||
|
|
// * сколько агент непрерывно торчит в чужой линии огня (критерий 11.4);
|
|||
|
|
// * как часто меняется postPos (критерий 11.5 — «нет дёрганья»).
|
|||
|
|
// -----------------------------------------------------------------------------
|
|||
|
|
struct Watcher
|
|||
|
|
{
|
|||
|
|
float residence[NUM_AGENTS] = {}; // текущее время внутри чужой линии
|
|||
|
|
float maxResidence = 0.0f;
|
|||
|
|
|
|||
|
|
// «Дёрганье» = решатель перекоммитил postPos раньше, чем истёк commitTime.
|
|||
|
|
// Коммит виден по скачку commitTimer вверх. Смена состояния отряда сбрасывает
|
|||
|
|
// отсчёт: это новая обстановка, а не дрожание.
|
|||
|
|
float prevCommitTimer[NUM_AGENTS] = {};
|
|||
|
|
float sinceCommit[NUM_AGENTS] = {};
|
|||
|
|
bool hadCommit[NUM_AGENTS] = {};
|
|||
|
|
float minCommitInterval = 1e9f;
|
|||
|
|
int commits = 0;
|
|||
|
|
int jitterViolations = 0;
|
|||
|
|
AgentState prevState = AgentState::HOLDING;
|
|||
|
|
|
|||
|
|
// Граница сценария. Накопленные максимумы и счётчики остаются (они про весь
|
|||
|
|
// прогон), а вот «когда был прошлый коммит» — нет: следующий сценарий это
|
|||
|
|
// другой Game с другим отрядом, и мерить между ними интервал бессмысленно.
|
|||
|
|
// Без этого первый же коммит нового сценария выглядел дёрганьем.
|
|||
|
|
void BeginScenario()
|
|||
|
|
{
|
|||
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|||
|
|
{
|
|||
|
|
hadCommit[i] = false;
|
|||
|
|
sinceCommit[i] = 0.0f;
|
|||
|
|
prevCommitTimer[i] = 0.0f;
|
|||
|
|
residence[i] = 0.0f;
|
|||
|
|
}
|
|||
|
|
prevState = AgentState::HOLDING;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void Observe(const Game& g, float dt)
|
|||
|
|
{
|
|||
|
|
const bool stateChanged = (g.squad.state != prevState);
|
|||
|
|
prevState = g.squad.state;
|
|||
|
|
|
|||
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|||
|
|
{
|
|||
|
|
const Agent& a = g.squad.agents[i];
|
|||
|
|
|
|||
|
|
// Только линии огня: 11.4 про них, сектор клинка резервируется
|
|||
|
|
// заведомо шире опасной зоны (см. ai/lane_registry.h).
|
|||
|
|
const bool inside = g.lanes.CountBlocked(i, a.pos, a.bodyRadius, true) > 0;
|
|||
|
|
residence[i] = inside ? residence[i] + dt : 0.0f;
|
|||
|
|
maxResidence = std::max(maxResidence, residence[i]);
|
|||
|
|
|
|||
|
|
if (stateChanged) hadCommit[i] = false;
|
|||
|
|
|
|||
|
|
sinceCommit[i] += dt;
|
|||
|
|
if (a.commitTimer > prevCommitTimer[i] + 1e-4f) // произошёл коммит
|
|||
|
|
{
|
|||
|
|
++commits;
|
|||
|
|
if (hadCommit[i])
|
|||
|
|
{
|
|||
|
|
minCommitInterval = std::min(minCommitInterval, sinceCommit[i]);
|
|||
|
|
if (sinceCommit[i] < g_tune.commitTime - FIXED_DT)
|
|||
|
|
{
|
|||
|
|
++jitterViolations;
|
|||
|
|
std::printf(" JITTER #%d dt=%.3f state=%s melee=%d prio=%d lane=%d "
|
|||
|
|
"post(%.2f,%.2f) t=%.2f\n",
|
|||
|
|
i, double(sinceCommit[i]),
|
|||
|
|
g.squad.state == AgentState::ADVANCING ? "ADV" : "HOLD",
|
|||
|
|
int(a.IsMelee()), int(a.priorityLane), int(a.hasLane),
|
|||
|
|
double(a.postPos.x), double(a.postPos.y),
|
|||
|
|
double(g.simTime));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
hadCommit[i] = true;
|
|||
|
|
sinceCommit[i] = 0.0f;
|
|||
|
|
}
|
|||
|
|
prevCommitTimer[i] = a.commitTimer;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
int CountLanes(const Game& g)
|
|||
|
|
{
|
|||
|
|
int n = 0;
|
|||
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|||
|
|
if (g.squad.agents[i].hasLane) ++n;
|
|||
|
|
return n;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void PrintVerdict(const char* name, bool pass, const char* detail)
|
|||
|
|
{
|
|||
|
|
std::printf("[%s] %-52s %s\n", pass ? "PASS" : "FAIL", name, detail);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} // namespace
|
|||
|
|
|
|||
|
|
// -----------------------------------------------------------------------------
|
|||
|
|
// --headless: маршрут + метрики
|
|||
|
|
// -----------------------------------------------------------------------------
|
|||
|
|
int RunHeadless(const HarnessOptions& opt)
|
|||
|
|
{
|
|||
|
|
Game game;
|
|||
|
|
game.enableSolver = opt.solver;
|
|||
|
|
game.enableLaneAvoid = opt.laneAvoid;
|
|||
|
|
game.squad.meleeCount = opt.meleeCount; // до Init: состав раздаётся там
|
|||
|
|
game.squad.pistolCount = opt.pistolCount;
|
|||
|
|
game.Init();
|
|||
|
|
|
|||
|
|
Watcher watch;
|
|||
|
|
const int totalSteps = int(opt.seconds / FIXED_DT);
|
|||
|
|
int wp = 0;
|
|||
|
|
bool holding = false;
|
|||
|
|
float phaseClock = 0.0f;
|
|||
|
|
int lastFormation = -1;
|
|||
|
|
double simSeconds = 0.0;
|
|||
|
|
float nextReport = 10.0f;
|
|||
|
|
float worstSettle = -1.0f;
|
|||
|
|
float worstHoldUptime = 1e9f;
|
|||
|
|
|
|||
|
|
std::printf("headless: %.0f s, solver=%d laneAvoid=%d melee=%d/%d\n", double(opt.seconds),
|
|||
|
|
int(opt.solver), int(opt.laneAvoid), game.squad.meleeCount, NUM_AGENTS);
|
|||
|
|
std::printf(" t friendly shots kills uptime%% settle lanes\n");
|
|||
|
|
|
|||
|
|
for (int step = 0; step < totalSteps; ++step)
|
|||
|
|
{
|
|||
|
|
const Waypoint& p = kRoute[wp];
|
|||
|
|
const float distToGoal = Distance(game.squad.anchor, {p.x, p.y});
|
|||
|
|
|
|||
|
|
InputState in;
|
|||
|
|
if (!holding) in.moveScreen = MoveInputToward(game, {p.x, p.y});
|
|||
|
|
if (p.formation >= 0 && p.formation != lastFormation)
|
|||
|
|
{
|
|||
|
|
in.setFormation = p.formation;
|
|||
|
|
lastFormation = p.formation;
|
|||
|
|
}
|
|||
|
|
game.in = in;
|
|||
|
|
|
|||
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|||
|
|
game.Step(FIXED_DT);
|
|||
|
|
simSeconds += std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
|||
|
|
watch.Observe(game, FIXED_DT);
|
|||
|
|
|
|||
|
|
phaseClock += FIXED_DT;
|
|||
|
|
if (!holding)
|
|||
|
|
{
|
|||
|
|
if (distToGoal < kArriveDist || phaseClock > kWaypointTimeout)
|
|||
|
|
{
|
|||
|
|
holding = true;
|
|||
|
|
phaseClock = 0.0f;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
else if (phaseClock >= p.hold)
|
|||
|
|
{
|
|||
|
|
if (p.hold >= 4.0f)
|
|||
|
|
{
|
|||
|
|
std::printf(" hold @ (%.0f,%.0f) %.0fs -> lanes %d/%d uptime %.1f%% settle %.2f\n",
|
|||
|
|
double(p.x), double(p.y), double(p.hold), CountLanes(game), NUM_AGENTS,
|
|||
|
|
double(game.metrics.uptimeAvg), double(game.metrics.lastSettle));
|
|||
|
|
worstHoldUptime = std::min(worstHoldUptime, game.metrics.uptimeAvg);
|
|||
|
|
|
|||
|
|
if (opt.probe)
|
|||
|
|
{
|
|||
|
|
const SolverContext ctx = game.MakeContext();
|
|||
|
|
std::printf(" живых мишеней %d/%d\n", CountAliveTargets(game.targets),
|
|||
|
|
NUM_TARGETS);
|
|||
|
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
|||
|
|
{
|
|||
|
|
const Agent& a = game.squad.agents[i];
|
|||
|
|
const int nearest = NearestAliveTarget(a.pos, ctx, 1e9f);
|
|||
|
|
const float d =
|
|||
|
|
(nearest >= 0) ? Distance(a.pos, game.targets[nearest].pos) : -1.0f;
|
|||
|
|
std::printf(" #%d pos(%.1f,%.1f) lane=%d t=%d ally=%d wall=%d "
|
|||
|
|
"nearest=%.1f post(%.1f,%.1f) uptime=%.0f%%\n",
|
|||
|
|
i, double(a.pos.x), double(a.pos.y), int(a.hasLane),
|
|||
|
|
a.currentTargetId, int(a.targetBlockedByAlly),
|
|||
|
|
int(a.targetBlockedByWall), double(d), double(a.postPos.x),
|
|||
|
|
double(a.postPos.y), double(game.metrics.uptimePct[i]));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
holding = false;
|
|||
|
|
phaseClock = 0.0f;
|
|||
|
|
wp = (wp + 1) % kRouteCount;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
worstSettle = std::max(worstSettle, game.metrics.lastSettle);
|
|||
|
|
|
|||
|
|
if (game.simTime >= nextReport)
|
|||
|
|
{
|
|||
|
|
nextReport += 10.0f;
|
|||
|
|
std::printf("%6.1f %8d %6d %6d %7.1f %6.2f %d/%d\n", double(game.simTime),
|
|||
|
|
game.metrics.friendlyHits, game.metrics.shotsFired,
|
|||
|
|
game.metrics.targetsKilled, double(game.metrics.uptimeAvg),
|
|||
|
|
double(game.metrics.lastSettle), CountLanes(game), NUM_AGENTS);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const double avgUs = (totalSteps > 0) ? (simSeconds * 1e6 / totalSteps) : 0.0;
|
|||
|
|
std::printf("---\nFRIENDLY_HITS = %d\nshots = %d kills = %d\n", game.metrics.friendlyHits,
|
|||
|
|
game.metrics.shotsFired, game.metrics.targetsKilled);
|
|||
|
|
std::printf("FIRE UPTIME avg = %.1f%% (по агентам:", double(game.metrics.uptimeAvg));
|
|||
|
|
for (int i = 0; i < NUM_AGENTS; ++i) std::printf(" %.0f", double(game.metrics.uptimePct[i]));
|
|||
|
|
std::printf(")\nхудший uptime на стоянке = %.1f%%\n", double(worstHoldUptime));
|
|||
|
|
std::printf("SETTLE last = %.2f s, worst = %.2f s\n", double(game.metrics.lastSettle),
|
|||
|
|
double(worstSettle));
|
|||
|
|
if (game.squad.meleeCount > 0)
|
|||
|
|
std::printf("MELEE: в зоне удара %.1f%% времени, взмахов %d, попаданий %d, оборвано %d\n",
|
|||
|
|
double(game.metrics.meleeUptimeAvg), game.metrics.meleeSwings,
|
|||
|
|
game.metrics.meleeHits, game.metrics.meleeAborts);
|
|||
|
|
std::printf("макс. время в чужой линии = %.2f s, дёрганий postPos = %d\n",
|
|||
|
|
double(watch.maxResidence), watch.jitterViolations);
|
|||
|
|
std::printf("шаг симуляции в среднем = %.1f us (бюджет кадра 16666 us)\n", avgUs);
|
|||
|
|
|
|||
|
|
for (int i = 0; i < game.metrics.hitLogCount; ++i)
|
|||
|
|
{
|
|||
|
|
const FriendlyHitEvent& e = game.metrics.hitLog[i];
|
|||
|
|
std::printf(" friendly hit t=%.2f #%d -> #%d @ (%.1f,%.1f), жертва %s\n", double(e.time),
|
|||
|
|
e.shooter, e.victim, double(e.pos.x), double(e.pos.y),
|
|||
|
|
e.victimAdvancing ? "ADVANCING" : "HOLDING");
|
|||
|
|
}
|
|||
|
|
return game.metrics.friendlyHits == 0 ? 0 : 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// -----------------------------------------------------------------------------
|
|||
|
|
// --accept: сценарии из раздела 11
|
|||
|
|
// -----------------------------------------------------------------------------
|
|||
|
|
int RunAccept()
|
|||
|
|
{
|
|||
|
|
bool allPass = true;
|
|||
|
|
Watcher watch; // общий: коммиты postPos по всем сценариям
|
|||
|
|
Watcher arenaWatch; // открытая арена: там и меряется критерий 11.4
|
|||
|
|
|
|||
|
|
// Критерии 11.1 / 11.6 / 11.7 / 11.8 описывают ПРАВИЛА СТРЕЛЬБЫ и меряются
|
|||
|
|
// на отряде из пяти стрелков — так же, как их формулировал спек. Клинок
|
|||
|
|
// проверяется отдельно (блок M7 ниже) и в трёхминутном прогоне 11.2, где
|
|||
|
|
// состав отряда обычный, боевой.
|
|||
|
|
const int kSpecSquadMelee = 0;
|
|||
|
|
const int kSpecSquadPistol = 0;
|
|||
|
|
|
|||
|
|
// === 11.1 + 11.8: отряд в куче на открытой арене, игрок останавливается ====
|
|||
|
|
{
|
|||
|
|
Game game;
|
|||
|
|
game.squad.meleeCount = kSpecSquadMelee;
|
|||
|
|
game.squad.pistolCount = kSpecSquadPistol;
|
|||
|
|
game.Init();
|
|||
|
|
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
|||
|
|
game.PlaceSquad({28.0f, 23.5f}, true); // кучка из 5 мишеней в радиусе
|
|||
|
|
|
|||
|
|
// 0.4 с движения, чтобы отряд был в ADVANCING, потом игрок отпускает клавиши
|
|||
|
|
for (int i = 0; i < int(0.4f / FIXED_DT); ++i)
|
|||
|
|
{
|
|||
|
|
game.in = InputState{};
|
|||
|
|
game.in.moveScreen = {0.0f, 1.0f};
|
|||
|
|
game.Step(FIXED_DT);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
float clock = 0.0f;
|
|||
|
|
float settle4 = -1.0f;
|
|||
|
|
for (int i = 0; i < int(25.0f / FIXED_DT); ++i)
|
|||
|
|
{
|
|||
|
|
game.in = InputState{};
|
|||
|
|
game.Step(FIXED_DT);
|
|||
|
|
watch.Observe(game, FIXED_DT);
|
|||
|
|
arenaWatch.Observe(game, FIXED_DT);
|
|||
|
|
clock += FIXED_DT;
|
|||
|
|
if (settle4 < 0.0f && CountLanes(game) >= 4) settle4 = clock;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const bool p1 = (settle4 >= 0.0f && settle4 <= 1.5f);
|
|||
|
|
const bool p8 = (game.metrics.uptimeAvg >= 80.0f);
|
|||
|
|
allPass = allPass && p1 && p8;
|
|||
|
|
PrintVerdict("11.1 >=4/5 линий за <=1.5 с после остановки", p1,
|
|||
|
|
Fmt("settle=%.2f с, линий сейчас %d/5", double(settle4),
|
|||
|
|
CountLanes(game)));
|
|||
|
|
PrintVerdict("11.8 FIRE UPTIME в стоячем бою >= 80%", p8,
|
|||
|
|
Fmt("uptime=%.1f%%", double(game.metrics.uptimeAvg)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === 11.6: узкий проход в 2 тайла =========================================
|
|||
|
|
// Коридор: стена y=13..15, проём x=30..31 -> движение вдоль -y, поперёк = x.
|
|||
|
|
{
|
|||
|
|
Game game;
|
|||
|
|
game.squad.meleeCount = kSpecSquadMelee;
|
|||
|
|
game.squad.pistolCount = kSpecSquadPistol;
|
|||
|
|
game.Init();
|
|||
|
|
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
|||
|
|
game.PlaceSquad({31.0f, 19.0f}, false);
|
|||
|
|
Watcher corridorWatch;
|
|||
|
|
|
|||
|
|
double lateralSum = 0.0; // усреднённый разброс поперёк/вдоль движения
|
|||
|
|
double longitudinalSum = 0.0;
|
|||
|
|
int maxInside = 0;
|
|||
|
|
// Считаются только выстрелы ВПЕРЁД, по ходу движения: именно там между
|
|||
|
|
// стрелком и целью может оказаться свой. Выстрел назад, в оставленную
|
|||
|
|
// позади комнату, к правилу «не стрелять сквозь своего» отношения не
|
|||
|
|
// имеет — впереди по такой линии никого из отряда нет по построению.
|
|||
|
|
int rearShotsInside = 0; // вперёд стреляет НЕ передний — вот это важно
|
|||
|
|
int frontShotsInside = 0;
|
|||
|
|
int backwardShots = 0; // для отчёта: сколько огня ушло назад
|
|||
|
|
int throughAllyShots = 0; // выстрелов, прошедших через тело союзника
|
|||
|
|
int samples = 0;
|
|||
|
|
|
|||
|
|
// Две фазы: сначала входим в проход, потом ВСТАЁМ и меряем.
|
|||
|
|
//
|
|||
|
|
// Мерить на ходу нельзя: пока игрок тянет курсор, тот всегда обгоняет
|
|||
|
|
// отряд (anchorSpeed 3.3 против moveSpeed 1.75), натяжение поводка
|
|||
|
|
// упирается в упор и включается спринт, а в спринте отряд по замыслу не
|
|||
|
|
// стреляет вовсе (Game::FireWeapons). Критерий 11.6 проверял бы пустоту.
|
|||
|
|
// Боевая ситуация в проходе — это именно «вошли и работаем»: тогда
|
|||
|
|
// передний держит линию, а задние упираются в его спину.
|
|||
|
|
// Меряем ВСЮ проводку через горловину, а не только стоянку в ней.
|
|||
|
|
// Проход глубиной три тайла, а бойцов пятеро — колонна физически длиннее
|
|||
|
|
// самого прохода и целиком внутрь него не помещается никогда. Поэтому
|
|||
|
|
// область замера это горловина ПЛЮС подходы с обеих сторон: именно на
|
|||
|
|
// этом отрезке видно, вытянулся отряд в нитку или пошёл шеренгой.
|
|||
|
|
//
|
|||
|
|
// Порог входа в шеренгу узкий по построению: сам проём шириной два тайла.
|
|||
|
|
const int totalSteps = int(26.0f / FIXED_DT);
|
|||
|
|
const int advanceSteps = int(16.0f / FIXED_DT);
|
|||
|
|
|
|||
|
|
for (int i = 0; i < totalSteps; ++i)
|
|||
|
|
{
|
|||
|
|
game.in = InputState{};
|
|||
|
|
// Ведём отряд сквозь проход и оставляем работать по северной кучке.
|
|||
|
|
if (i < advanceSteps) game.in.moveScreen = MoveInputToward(game, {31.0f, 9.0f});
|
|||
|
|
game.Step(FIXED_DT);
|
|||
|
|
watch.Observe(game, FIXED_DT);
|
|||
|
|
corridorWatch.Observe(game, FIXED_DT);
|
|||
|
|
|
|||
|
|
// Кто сейчас в створе прохода (горловина + подходы).
|
|||
|
|
int inside[NUM_AGENTS];
|
|||
|
|
int insideCount = 0;
|
|||
|
|
for (int k = 0; k < NUM_AGENTS; ++k)
|
|||
|
|
{
|
|||
|
|
const Vec2 p = game.squad.agents[k].pos;
|
|||
|
|
if (p.y > 9.0f && p.y < 19.0f && p.x > 28.0f && p.x < 34.0f)
|
|||
|
|
inside[insideCount++] = k;
|
|||
|
|
}
|
|||
|
|
if (insideCount < 2) continue;
|
|||
|
|
|
|||
|
|
++samples;
|
|||
|
|
maxInside = std::max(maxInside, insideCount);
|
|||
|
|
|
|||
|
|
float xMin = 1e9f, xMax = -1e9f, yMin = 1e9f, yMax = -1e9f;
|
|||
|
|
float frontY = 1e9f;
|
|||
|
|
for (int n = 0; n < insideCount; ++n)
|
|||
|
|
{
|
|||
|
|
const Vec2 p = game.squad.agents[inside[n]].pos;
|
|||
|
|
xMin = std::min(xMin, p.x); xMax = std::max(xMax, p.x);
|
|||
|
|
yMin = std::min(yMin, p.y); yMax = std::max(yMax, p.y);
|
|||
|
|
frontY = std::min(frontY, p.y);
|
|||
|
|
}
|
|||
|
|
// Колонна = вытянуты вдоль движения сильнее, чем поперёк.
|
|||
|
|
lateralSum += double(xMax - xMin);
|
|||
|
|
longitudinalSum += double(yMax - yMin);
|
|||
|
|
|
|||
|
|
for (int n = 0; n < insideCount; ++n)
|
|||
|
|
{
|
|||
|
|
const Agent& a = game.squad.agents[inside[n]];
|
|||
|
|
if (!a.fired) continue;
|
|||
|
|
const Vec2 tp = (a.currentTargetId >= 0) ? game.targets[a.currentTargetId].pos : Vec2{};
|
|||
|
|
|
|||
|
|
// НАСТОЯЩАЯ проверка правила, независимая от реализации: берём
|
|||
|
|
// отрезок реально сделанного выстрела и заново, своей геометрией,
|
|||
|
|
// смотрим, не задевает ли он тело союзника. Порог физический
|
|||
|
|
// (тело + полуширина трассы), без запаса safetyMargin: выстрел
|
|||
|
|
// впритирку, но мимо тела, правило не нарушает.
|
|||
|
|
const Vec2 muzzle = a.MuzzleTowards(tp);
|
|||
|
|
for (int k = 0; k < NUM_AGENTS; ++k)
|
|||
|
|
{
|
|||
|
|
if (k == inside[n]) continue;
|
|||
|
|
const Agent& ally = game.squad.agents[k];
|
|||
|
|
if (DistancePointToSegment(ally.pos, muzzle, tp) <
|
|||
|
|
ally.bodyRadius + g_tune.laneHalfWidth)
|
|||
|
|
++throughAllyShots;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Разбивка ниже — только для отчёта. Задний, стреляющий ПОД УГЛОМ
|
|||
|
|
// мимо переднего, ничего не нарушает: проход шириной 2 тайла даёт
|
|||
|
|
// законные диагональные линии, и это подтверждается проверкой выше.
|
|||
|
|
const bool forward = Dot(tp - a.pos, game.squad.forward) > 0.0f;
|
|||
|
|
const bool rear = a.pos.y > frontY + 0.5f;
|
|||
|
|
if (!forward) { ++backwardShots; continue; }
|
|||
|
|
if (rear) ++rearShotsInside;
|
|||
|
|
else ++frontShotsInside;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const double lateralAvg = (samples > 0) ? lateralSum / samples : 0.0;
|
|||
|
|
const double longAvg = (samples > 0) ? longitudinalSum / samples : 0.0;
|
|||
|
|
|
|||
|
|
// Что здесь проверяется на самом деле.
|
|||
|
|
//
|
|||
|
|
// «Задние не стреляют» — это СЛЕДСТВИЕ правила «не стрелять сквозь
|
|||
|
|
// своего», а не отдельное правило. В проходе шириной 2 тайла у заднего
|
|||
|
|
// бойца есть законные диагональные линии мимо переднего, и считать их
|
|||
|
|
// нарушением — значит требовать от отряда быть глупее, чем он есть.
|
|||
|
|
// Поэтому считаем не «кто стрелял», а инвариант: НИ ОДИН выстрел в
|
|||
|
|
// проходе не прошёл через тело союзника (перепроверено своей геометрией
|
|||
|
|
// по факту выстрела) и ни одного попадания по своим.
|
|||
|
|
//
|
|||
|
|
// Плюс форма: отряд обязан вытянуться в колонну, а не идти шеренгой.
|
|||
|
|
// Пороги с запасом: строгое неравенство на маленьких числах — монетка,
|
|||
|
|
// а не тест.
|
|||
|
|
const bool column = longAvg > lateralAvg * 1.5;
|
|||
|
|
const bool clean = (throughAllyShots == 0) && (game.metrics.friendlyHits == 0);
|
|||
|
|
const bool enough = (frontShotsInside + rearShotsInside) >= 4;
|
|||
|
|
const bool p6 = (samples > 0) && (maxInside >= 2) && column && clean && enough;
|
|||
|
|
allPass = allPass && p6;
|
|||
|
|
PrintVerdict("11.6 в проходе колонна, огонь не идёт сквозь своих", p6,
|
|||
|
|
Fmt("до %d агентов в проходе, поперёк %.2f / вдоль %.2f тайла, "
|
|||
|
|
"выстрелов вперёд передний/хвост %d/%d (назад %d), "
|
|||
|
|
"сквозь своего %d, FRIENDLY_HITS=%d",
|
|||
|
|
maxInside, lateralAvg, longAvg, frontShotsInside, rearShotsInside,
|
|||
|
|
backwardShots, throughAllyShots, game.metrics.friendlyHits));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === 11.7: ручной режим, назначенная цель =================================
|
|||
|
|
{
|
|||
|
|
Game game;
|
|||
|
|
game.squad.meleeCount = kSpecSquadMelee;
|
|||
|
|
game.squad.pistolCount = kSpecSquadPistol;
|
|||
|
|
game.Init();
|
|||
|
|
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
|||
|
|
game.PlaceSquad({28.0f, 23.5f}, true);
|
|||
|
|
|
|||
|
|
// Включаем ручной режим и наводим вектор прицела на кучку.
|
|||
|
|
InputState toggle;
|
|||
|
|
toggle.toggleAimMode = true;
|
|||
|
|
game.in = toggle;
|
|||
|
|
game.Step(FIXED_DT);
|
|||
|
|
|
|||
|
|
int stepsWithAssigned = 0;
|
|||
|
|
for (int i = 0; i < int(30.0f / FIXED_DT); ++i)
|
|||
|
|
{
|
|||
|
|
game.in = InputState{};
|
|||
|
|
// Медленно крутим прицел — Tab каждые 5 секунд.
|
|||
|
|
game.in.aimTurn = (i < int(3.0f / FIXED_DT)) ? 1.0f : 0.0f;
|
|||
|
|
game.in.cycleTarget = (i % int(5.0f / FIXED_DT)) == 0;
|
|||
|
|
game.Step(FIXED_DT);
|
|||
|
|
watch.Observe(game, FIXED_DT);
|
|||
|
|
if (game.aim.assignedTargetId >= 0) ++stepsWithAssigned;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const bool p7 = (game.metrics.friendlyHits == 0) && (stepsWithAssigned > 0);
|
|||
|
|
allPass = allPass && p7;
|
|||
|
|
PrintVerdict("11.7 ручной режим не стреляет сквозь своих", p7,
|
|||
|
|
Fmt("FRIENDLY_HITS=%d, назначенная цель была %.0f%% времени",
|
|||
|
|
game.metrics.friendlyHits,
|
|||
|
|
100.0 * double(stepsWithAssigned) / (30.0 / FIXED_DT)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === M7: ближний бой ======================================================
|
|||
|
|
// Смешанный отряд у кучки из пяти мишеней. Проверяется ровно то, что
|
|||
|
|
// требовалось от механики: клинок доходит и бьёт, никого из своих при этом
|
|||
|
|
// не задевает, и каждый класс живёт в СВОЁМ круге вокруг диска.
|
|||
|
|
{
|
|||
|
|
Game game;
|
|||
|
|
game.Init(); // состав по умолчанию: 2 клинка
|
|||
|
|
watch.BeginScenario(); // интервалы коммитов меряются внутри сценария
|
|||
|
|
game.PlaceSquad({28.0f, 23.5f}, true);
|
|||
|
|
|
|||
|
|
float maxMeleeFromAnchor = 0.0f;
|
|||
|
|
float maxRangedFromAnchor = 0.0f;
|
|||
|
|
|
|||
|
|
for (int i = 0; i < int(30.0f / FIXED_DT); ++i)
|
|||
|
|
{
|
|||
|
|
game.in = InputState{};
|
|||
|
|
game.Step(FIXED_DT);
|
|||
|
|
watch.Observe(game, FIXED_DT);
|
|||
|
|
|
|||
|
|
for (int k = 0; k < NUM_AGENTS; ++k)
|
|||
|
|
{
|
|||
|
|
const Agent& a = game.squad.agents[k];
|
|||
|
|
const float d = Distance(a.pos, game.squad.anchor);
|
|||
|
|
if (a.IsMelee()) maxMeleeFromAnchor = std::max(maxMeleeFromAnchor, d);
|
|||
|
|
else maxRangedFromAnchor = std::max(maxRangedFromAnchor, d);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const Metrics& m = game.metrics;
|
|||
|
|
// Порог круга с запасом на инерцию: цель может быть у самой границы,
|
|||
|
|
// и боец имеет право дотянуться до неё, а не тормозить по линейке.
|
|||
|
|
const float meleeLimit = g_tune.meleeCohesionRadius + 1.2f;
|
|||
|
|
const float rangedLimit = g_tune.cohesionRadius + 1.6f;
|
|||
|
|
|
|||
|
|
const bool pM1 = (m.meleeHits > 0) && (m.meleeSwings > 0);
|
|||
|
|
const bool pM2 = (m.friendlyHits == 0);
|
|||
|
|
const bool pM3 = (maxMeleeFromAnchor <= meleeLimit) &&
|
|||
|
|
(maxRangedFromAnchor <= rangedLimit) &&
|
|||
|
|
(maxRangedFromAnchor < maxMeleeFromAnchor);
|
|||
|
|
allPass = allPass && pM1 && pM2 && pM3;
|
|||
|
|
|
|||
|
|
PrintVerdict("M7.1 клинок доходит до цели и наносит урон", pM1,
|
|||
|
|
Fmt("взмахов %d, попаданий %d, оборвано своими %d, убито %d; "
|
|||
|
|
"связка %s=%d %s=%d %s=%d",
|
|||
|
|
m.meleeSwings, m.meleeHits, m.meleeAborts, m.targetsKilled,
|
|||
|
|
MELEE_COMBO[0].name, m.meleeComboSwings[0], MELEE_COMBO[1].name,
|
|||
|
|
m.meleeComboSwings[1], MELEE_COMBO[2].name, m.meleeComboSwings[2]));
|
|||
|
|
const bool pM4 = m.meleeMaxHitExcessDeg <= 12.0f;
|
|||
|
|
allPass = allPass && pM4;
|
|||
|
|
PrintVerdict("M7.4 лезвие не достаёт за пределы своей дуги", pM4,
|
|||
|
|
Fmt("максимум за сектором %.1f град (лимит 12)",
|
|||
|
|
double(m.meleeMaxHitExcessDeg)));
|
|||
|
|
PrintVerdict("M7.2 ближний бой не задевает своих", pM2,
|
|||
|
|
Fmt("FRIENDLY_HITS=%d при %d взмахах", m.friendlyHits, m.meleeSwings));
|
|||
|
|
PrintVerdict("M7.3 у каждого класса свой круг вокруг диска", pM3,
|
|||
|
|
Fmt("стрелки до %.2f (лимит %.2f), клинки до %.2f (лимит %.2f)",
|
|||
|
|
double(maxRangedFromAnchor), double(rangedLimit),
|
|||
|
|
double(maxMeleeFromAnchor), double(meleeLimit)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === 11.2: три минуты непрерывного боя во всех режимах =====================
|
|||
|
|
{
|
|||
|
|
HarnessOptions opt;
|
|||
|
|
opt.seconds = 180.0f; // состав боевой: смешанный отряд, как в игре
|
|||
|
|
std::printf("--- прогон 180 с по маршруту (критерий 11.2) ---\n");
|
|||
|
|
const int rc = RunHeadless(opt);
|
|||
|
|
const bool p2 = (rc == 0);
|
|||
|
|
allPass = allPass && p2;
|
|||
|
|
PrintVerdict("11.2 FRIENDLY_HITS = 0 за 3 минуты", p2, p2 ? "0 попаданий" : "см. журнал");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// === 11.4 + 11.5 ==========================================================
|
|||
|
|
// 11.4 меряется на открытой арене: в проходе 2 тайла разойтись физически
|
|||
|
|
// некуда, и это ровно тот геометрический тест-кейс, о котором говорит спек.
|
|||
|
|
const bool p4 = arenaWatch.maxResidence <= 0.4f;
|
|||
|
|
const bool p5 = watch.jitterViolations == 0;
|
|||
|
|
allPass = allPass && p4 && p5;
|
|||
|
|
PrintVerdict("11.4 агент уходит с чужой линии за ~0.3 с", p4,
|
|||
|
|
Fmt("на арене максимум %.2f с (по всем сценариям %.2f с)",
|
|||
|
|
double(arenaWatch.maxResidence), double(watch.maxResidence)));
|
|||
|
|
PrintVerdict("11.5 нет дёрганья postPos чаще commitTime", p5,
|
|||
|
|
Fmt("коммитов %d, нарушений %d, минимальный интервал %.2f с", watch.commits,
|
|||
|
|
watch.jitterViolations,
|
|||
|
|
double(watch.minCommitInterval > 1e8f ? 0.0f : watch.minCommitInterval)));
|
|||
|
|
|
|||
|
|
std::printf("\nИТОГ: %s\n", allPass ? "все проверяемые критерии PASS" : "есть FAIL");
|
|||
|
|
return allPass ? 0 : 1;
|
|||
|
|
}
|