Пункт UPGRADES выходил из игры; меню теперь проверяется (M26)

Жалоба была «крашится при открытии апгрейдов». Не крашилось: пункт
закрывал игру. В обработчике главного меню стоял `default: ui.quit`, а
экран улучшений добавили четвёртым пунктом — обработчик о нём просто не
узнал. Снаружи мгновенное закрытие окна неотличимо от падения.

Починка на две строки, но интереснее причина, по которой это прожило
четыре вехи: в проекте каждая механика меряется прогоном с кодом
возврата, а меню не проверял никто. Все инструменты смотрят на
СИМУЛЯЦИЮ.

Поэтому заведён --ui-check: проходит по всем пунктам меню и требует
одного — нажатие ВЕДЁТ КУДА-ТО, а из игры выходит ровно один пункт,
QUIT. Плюс проверяет, что кнопки экрана улучшений действительно покупают.
Окно при этом не открывается: UiActivate ничего из raylib не трогает.

Проверка сперва запущена на СЛОМАННОЙ сборке и дала код 1 с диагнозом
«пункт UPGRADES не делает ничего», и только после этого объявлена
рабочей. Проверка, которую не видели падающей, ещё ничего не проверяет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-16 02:51:58 +03:00
parent d3aa044cbe
commit 53187a7cdd
8 changed files with 159 additions and 13 deletions

View File

@ -51,7 +51,12 @@
переменной `TILE2D_PROJECT`. переменной `TILE2D_PROJECT`.
- Самопроверка: `squad_proto.exe --accept` печатает PASS/FAIL по всем измеримым критериям. - Самопроверка: `squad_proto.exe --accept` печатает PASS/FAIL по всем измеримым критериям.
Содержимое проверяется отдельно: `tile2d_editor.exe --check` (комнаты и покрытие глубин), Содержимое проверяется отдельно: `tile2d_editor.exe --check` (комнаты и покрытие глубин),
`squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля). `squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля),
`squad_proto.exe --ui-check` (пункты меню: каждый куда-то ведёт).
- **У кнопки тоже есть код возврата.** Пункт UPGRADES четыре вехи закрывал игру
вместо открытия экрана: в обработчике стоял `default: quit`, а новый пункт
добавили четвёртым. Все прогоны меряли симуляцию, меню не проверял никто —
теперь проверяет `--ui-check`, и новый экран обязан появиться в нём.
- **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё, - **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё,
достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)). достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)).
- **`NUM_AGENTS` — это вместимость, а не размер отряда.** Сколько слотов занято, - **`NUM_AGENTS` — это вместимость, а не размер отряда.** Сколько слотов занято,

View File

@ -110,6 +110,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64
| Аргумент | Что делает | | Аргумент | Что делает |
|---|---| |---|---|
| `--accept` | прогоняет сценарии критериев приёмки (спек 11) и печатает PASS/FAIL | | `--accept` | прогоняет сценарии критериев приёмки (спек 11) и печатает PASS/FAIL |
| `--ui-check` | пункты меню без окна: каждый ведёт куда-то, выходит только QUIT |
| `--headless <сек>` | прогон по маршруту без окна + метрики каждые 10 с | | `--headless <сек>` | прогон по маршруту без окна + метрики каждые 10 с |
| `--probe` | к `--headless`: подробности по каждому агенту на стоянках | | `--probe` | к `--headless`: подробности по каждому агенту на стоянках |
| `--no-solver` | отключить решатель огневых позиций (A/B сравнение) | | `--no-solver` | отключить решатель огневых позиций (A/B сравнение) |
@ -142,6 +143,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64
```bash ```bash
squad_proto.exe --accept squad_proto.exe --accept
squad_proto.exe --ui-check
squad_proto.exe --headless 300 squad_proto.exe --headless 300
squad_proto.exe --headless 300 --no-solver --no-laneavoid # каким прототип был бы без ядра squad_proto.exe --headless 300 --no-solver --no-laneavoid # каким прототип был бы без ядра
squad_proto.exe --headless 300 --melee 0 # чистый отряд стрелков squad_proto.exe --headless 300 --melee 0 # чистый отряд стрелков

View File

@ -40,6 +40,8 @@
| **M25** | редкости и аффиксы: экземпляр предмета вместо вида (`ItemInstance`), четыре редкости, десять тегов, бюджет и тир по глубине; выкатанное ложится в те же четыре числа бойца и в стихии оружия; инструменты `--tool drops` и `--tool affix` | замеры поймали ТРИ расхождения дизайн-документа с самим собой: формула редкости противоречила своей же таблице (2.7% против 22% на первом этаже), бюджет пускал восемь высших аффиксов вместо обещанных шести, а предельная вещь собиралась к семидесятому этажу вместо сотого — числа пересчитаны, документ исправлен; проверка «доезжает до бойца» показала урон 12→18, здоровье 100→140, броню 0.10→0.25, конверсию KIN 1.00→0.60 при сумме долей ровно 1.000; `--accept` PASS без изменений ([22-loot.md](22-loot.md)) | | **M25** | редкости и аффиксы: экземпляр предмета вместо вида (`ItemInstance`), четыре редкости, десять тегов, бюджет и тир по глубине; выкатанное ложится в те же четыре числа бойца и в стихии оружия; инструменты `--tool drops` и `--tool affix` | замеры поймали ТРИ расхождения дизайн-документа с самим собой: формула редкости противоречила своей же таблице (2.7% против 22% на первом этаже), бюджет пускал восемь высших аффиксов вместо обещанных шести, а предельная вещь собиралась к семидесятому этажу вместо сотого — числа пересчитаны, документ исправлен; проверка «доезжает до бойца» показала урон 12→18, здоровье 100→140, броню 0.10→0.25, конверсию KIN 1.00→0.60 при сумме долей ровно 1.000; `--accept` PASS без изменений ([22-loot.md](22-loot.md)) |
| **M26** | `--ui-check`: пункты меню проверяются кодом возврата, как всё остальное | нашлось по жалобе «игра крашится при открытии улучшений»: пункт UPGRADES не крашил, а ВЫХОДИЛ из игры — в обработчике стоял `default: quit`, а экран улучшений добавили четвёртым пунктом, и обработчик о нём не узнал; проверка сперва воспроизведена на сломанной сборке (код 1, «пункт UPGRADES не делает ничего») и только потом объявлена рабочей ([01-build-and-run.md](01-build-and-run.md)) |
## Что находилось по ходу и чинилось ## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -5,8 +5,10 @@
#include <fstream> #include <fstream>
#include <cstdarg> #include <cstdarg>
#include <cstdio> #include <cstdio>
#include <cstring>
#include "game.h" #include "game.h"
#include "ui/menu.h"
// Перевод экранных осей ввода в мировые — единственное место, // Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. docs/02-architecture.md). // где симуляционный слой касается изометрии (см. docs/02-architecture.md).
@ -1557,3 +1559,90 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
std::printf("\nитог: этажей %d, опыта %.0f, ошибок %d\n", floorsDone, double(xpEarned), bad); std::printf("\nитог: этажей %d, опыта %.0f, ошибок %d\n", floorsDone, double(xpEarned), bad);
return bad == 0 ? 0 : 1; 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 sizeBefore = profile.squadSize;
UiActivate(ui, game, UiPart::UPGRADE_ITEM, int(Upgrade::COUNT)); // строка найма
const bool hired = profile.squadSize > sizeBefore;
const int lvlBefore = profile.UpgradeLevel(Upgrade::AMMO);
UiActivate(ui, game, UiPart::UPGRADE_ITEM, int(Upgrade::AMMO));
const bool bought = profile.UpgradeLevel(Upgrade::AMMO) > lvlBefore;
std::printf("\nулучшения: найм %s, покупка %s\n", hired ? "работает" : "НЕ работает",
bought ? "работает" : "НЕ работает");
if (!hired || !bought)
{
std::printf(" [FAIL] кнопки экрана улучшений не покупают\n");
++bad;
}
}
std::printf("\nитог: пунктов %d, поломок %d\n", count, bad);
return bad == 0 ? 0 : 1;
}

View File

@ -32,3 +32,8 @@ struct HarnessOptions
int RunHeadless(const HarnessOptions& opt); int RunHeadless(const HarnessOptions& opt);
int RunAccept(); int RunAccept();
// --ui-check: пункты меню без окна. Проверяет, что каждый пункт КУДА-ТО ведёт и
// что из игры выходит ровно один — QUIT. Появилась после того, как UPGRADES
// четыре вехи молча закрывал игру (ui/menu.h).
int RunUiCheck();

View File

@ -123,6 +123,7 @@ int main(int argc, char** argv)
int shotFrame = -1; int shotFrame = -1;
const char* shotFile = "shot.png"; const char* shotFile = "shot.png";
bool accept = false; bool accept = false;
bool uiCheck = false;
bool clean = false; // --clean: стартовать без оверлея (для скриншотов) bool clean = false; // --clean: стартовать без оверлея (для скриншотов)
bool sfTiles = false; // --sf-tiles: пол и стены из библиотеки SpriteForge bool sfTiles = false; // --sf-tiles: пол и стены из библиотеки SpriteForge
bool sfEnemies = false; // --sf-enemies: анимированные твари оттуда же bool sfEnemies = false; // --sf-enemies: анимированные твари оттуда же
@ -205,6 +206,7 @@ int main(int argc, char** argv)
if (i + 1 < argc && argv[i + 1][0] != '-') projectArg = argv[++i]; if (i + 1 < argc && argv[i + 1][0] != '-') projectArg = argv[++i];
} }
else if (std::strcmp(argv[i], "--accept") == 0) accept = true; else if (std::strcmp(argv[i], "--accept") == 0) accept = true;
else if (std::strcmp(argv[i], "--ui-check") == 0) uiCheck = true;
else if (std::strcmp(argv[i], "--probe") == 0) harness.probe = true; else if (std::strcmp(argv[i], "--probe") == 0) harness.probe = true;
else if (std::strcmp(argv[i], "--no-solver") == 0) harness.solver = false; else if (std::strcmp(argv[i], "--no-solver") == 0) harness.solver = false;
else if (std::strcmp(argv[i], "--no-laneavoid") == 0) harness.laneAvoid = false; else if (std::strcmp(argv[i], "--no-laneavoid") == 0) harness.laneAvoid = false;
@ -288,6 +290,7 @@ int main(int argc, char** argv)
if (sfReport) return RunSpriteReport(); if (sfReport) return RunSpriteReport();
if (accept) return RunAccept(); if (accept) return RunAccept();
if (uiCheck) return RunUiCheck();
if (harness.seconds > 0.0f) return RunHeadless(harness); if (harness.seconds > 0.0f) return RunHeadless(harness);
if (descendFloors > 0) if (descendFloors > 0)
{ {

View File

@ -681,11 +681,15 @@ void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex)
EquipWeapon(game.world, a, inv->weapon); EquipWeapon(game.world, a, inv->weapon);
} }
} // namespace
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Действия кнопок. Один вход и для клика, и для ENTER: иначе мышь и клавиатура // Действия кнопок. Один вход и для клика, и для ENTER: иначе мышь и клавиатура
// начнут делать «почти одно и то же». // начнут делать «почти одно и то же».
//
// Не в анонимном пространстве: этот же вход дёргает `--ui-check` (ui/menu.h).
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
void Activate(Ui& ui, Game& game, UiPart part, int index) void UiActivate(Ui& ui, Game& game, UiPart part, int index)
{ {
switch (part) switch (part)
{ {
@ -709,7 +713,14 @@ void Activate(Ui& ui, Game& game, UiPart part, int index)
ui.screen = Screen::DEPTH; ui.screen = Screen::DEPTH;
break; break;
case 2: ui.screen = Screen::SQUAD; break; case 2: ui.screen = Screen::SQUAD; break;
default: ui.quit = true; break; case 3: ui.screen = Screen::UPGRADE; break;
// QUIT — последний пункт, и попасть сюда можно только им.
// Раньше здесь стоял default, и любой НЕ перечисленный пункт
// выходил из игры: экран улучшений появился четвёртым, а
// обработчик о нём не узнал — нажатие UPGRADES закрывало игру,
// и снаружи это выглядело как падение.
case 4: ui.quit = true; break;
default: break;
} }
return; return;
@ -749,6 +760,9 @@ void Activate(Ui& ui, Game& game, UiPart part, int index)
} }
} }
namespace
{
bool Pressed(int key) { return IsKeyPressed(key); } bool Pressed(int key) { return IsKeyPressed(key); }
// Все списки листаются одинаково: стрелки и WS. // Все списки листаются одинаково: стрелки и WS.
@ -762,6 +776,13 @@ int Step()
} // namespace } // namespace
int UiMenuItemCount() { return MENU_COUNT; }
const char* UiMenuItemName(int index)
{
return (index >= 0 && index < MENU_COUNT) ? MENU_ITEMS[index] : "?";
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Ввод // Ввод
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -833,34 +854,34 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt)
if (menuIndex == 0 && !started && d >= 0) menuIndex = 1; if (menuIndex == 0 && !started && d >= 0) menuIndex = 1;
if (lmbDown && hot && hot->enabled) if (lmbDown && hot && hot->enabled)
Activate(*this, game, hot->part, hot->index); UiActivate(*this, game, hot->part, hot->index);
else if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_KP_ENTER)) else if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_KP_ENTER))
Activate(*this, game, UiPart::MENU_ITEM, menuIndex); UiActivate(*this, game, UiPart::MENU_ITEM, menuIndex);
return true; return true;
} }
case Screen::PAUSE: case Screen::PAUSE:
if (lmbDown && hot) { Activate(*this, game, hot->part, hot->index); return true; } if (lmbDown && hot) { UiActivate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ESCAPE)) { screen = Screen::GAME; return true; } if (Pressed(KEY_ESCAPE)) { screen = Screen::GAME; return true; }
if (Pressed(KEY_I) || Pressed(KEY_TAB)) { screen = Screen::SQUAD; return true; } if (Pressed(KEY_I) || Pressed(KEY_TAB)) { screen = Screen::SQUAD; return true; }
if (Pressed(KEY_Q)) { screen = Screen::MENU; return true; } if (Pressed(KEY_Q)) { screen = Screen::MENU; return true; }
return true; return true;
case Screen::DEFEAT: case Screen::DEFEAT:
if (lmbDown && hot) { Activate(*this, game, hot->part, hot->index); return true; } if (lmbDown && hot) { UiActivate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE)) if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE))
Activate(*this, game, UiPart::DEFEAT_ITEM, 0); UiActivate(*this, game, UiPart::DEFEAT_ITEM, 0);
if (Pressed(KEY_ESCAPE)) screen = Screen::MENU; if (Pressed(KEY_ESCAPE)) screen = Screen::MENU;
return true; return true;
case Screen::SUMMARY: case Screen::SUMMARY:
if (lmbDown && hot) { Activate(*this, game, hot->part, hot->index); return true; } if (lmbDown && hot) { UiActivate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_ESCAPE)) if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_ESCAPE))
Activate(*this, game, UiPart::SUMMARY_ITEM, 0); UiActivate(*this, game, UiPart::SUMMARY_ITEM, 0);
return true; return true;
case Screen::UPGRADE: case Screen::UPGRADE:
if (lmbDown && hot) Activate(*this, game, hot->part, hot->index); if (lmbDown && hot) UiActivate(*this, game, hot->part, hot->index);
if (Pressed(KEY_ESCAPE) || Pressed(KEY_U)) screen = Screen::MENU; if (Pressed(KEY_ESCAPE) || Pressed(KEY_U)) screen = Screen::MENU;
return true; return true;
@ -874,11 +895,11 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt)
if (d) depthIndex = (depthIndex + d + shown) % shown; if (d) depthIndex = (depthIndex + d + shown) % shown;
if (depthIndex < 0) depthIndex = 0; if (depthIndex < 0) depthIndex = 0;
if (lmbDown && hot) { Activate(*this, game, hot->part, hot->index); return true; } if (lmbDown && hot) { UiActivate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE)) if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE))
{ {
const Widget& pick = w.item[depthIndex]; const Widget& pick = w.item[depthIndex];
Activate(*this, game, pick.part, pick.index); UiActivate(*this, game, pick.part, pick.index);
return true; return true;
} }
if (Pressed(KEY_ESCAPE)) screen = Screen::MENU; if (Pressed(KEY_ESCAPE)) screen = Screen::MENU;

View File

@ -107,3 +107,22 @@ struct Ui
// Поверх готового кадра. fit — куда лёг фреймбуфер в окне. // Поверх готового кадра. fit — куда лёг фреймбуфер в окне.
void Draw(const Game& game, const ScreenRect& fit) const; void Draw(const Game& game, const ScreenRect& fit) const;
}; };
// -----------------------------------------------------------------------------
// Нажать пункт так, как будто по нему кликнули. Наружу вынесено ради ОДНОЙ
// проверки — `--ui-check`, — и вынесено не для полноты API.
//
// Причина конкретная: пункт меню UPGRADES четыре вехи выходил из игры. В
// обработчике стоял `default: quit`, экран улучшений появился четвёртым
// пунктом, и обработчик о нём просто не узнал. Снаружи это выглядело как
// падение игры при открытии улучшений.
//
// Ни один прогон этого не ловил, потому что все проверки в проекте меряют
// СИМУЛЯЦИЮ, а меню не проверял никто. Теперь его щупают так же, как всё
// остальное: код возврата, а не глаз.
void UiActivate(Ui& ui, Game& game, UiPart part, int index);
// Сколько пунктов в главном меню и как называется i-й. Нужны той же проверке:
// она обязана пройти по ВСЕМ пунктам, а не по тем, о которых знает.
int UiMenuItemCount();
const char* UiMenuItemName(int index);