1. Сценарий поведения: что это и зачем он нужен
Когда программа маленькая, очень хочется тестировать её так: запустил, ввёл пару чисел, порадовался, что «вроде работает», и пошёл пить чай. Проблема в том, что «вроде» — это технический термин, означающий «через 15 минут оно сломается, но не сейчас». Сценарий поведения — это попытка сделать запуск предсказуемым: вы заранее задаёте ситуацию и заранее знаете, что программа обязана сделать.
Сценарий — это не про автоматизированные тесты и не про «супер-инженерию» (мы их сегодня не трогаем). Это про привычку мыслить так: «Если пользователь вводит не число — что будет?», «Если он вводит минимальную границу диапазона — что будет?», «Если попытки закончились — что будет?». По сути, сценарий — это маленький договор между вами и вашей программой: вы обещаете дать определённый ввод, а программа обязана дать определённый результат.
Чтобы было проще держать в голове, можно мыслить в стиле мини-истории: «Дано → Когда → Тогда». Дано: секрет фиксированный, попыток 3, диапазон 1..10. Когда: пользователь вводит abc, затем 11, затем 7. Тогда: программа дважды ругается на ввод и только в третий раз сравнивает число с секретом. (И да, уже здесь всплывает важнейший вопрос: «неверный ввод тратит попытку или нет?» — вы это решали раньше, а теперь вы это проверяете.)
2. Предсказуемость и debug-логи
Фиксируем секрет и отключаем «рандом»
Любая отладка страдает, когда результат меняется сам по себе. Random — отличный инструмент для игры, но ужасный компаньон для проверки логики: сегодня секрет 42, завтра 17, а вы пытаетесь понять, почему на третьей попытке программа внезапно проиграла. Поэтому первый приём в отладке такой: на время проверок сделайте секрет фиксированным, чтобы можно было воспроизводить поведение снова и снова.
Самый простой вариант — завести флаг debug и выбирать секрет по нему. Это «честный» способ: в обычном режиме игра остаётся игрой, в debug-режиме — становится лабораторной мышью (в хорошем смысле).
val debug = true
val secret = if (debug) {
42
} else {
generateSecret(min, max)
}
println("Игра началась!") // Игра началась!
Обратите внимание на важный психологический момент: вы не «ломаете игру», вы временно меняете источник секрета ради воспроизводимости. Это как прикрутить велосипед к станку, чтобы настроить тормоза: кататься так нельзя, но чинить — очень удобно.
Ещё один удобный приём — вывести подсказку о том, что мы в debug-режиме, чтобы потом не сидеть в недоумении, почему «я всегда угадываю за одну попытку, я гений» (а потому что секрет вы сами написали).
if (debug) {
println("DEBUG: секрет зафиксирован = $secret") // DEBUG: секрет зафиксирован = 42
}
Только, пожалуйста, не забудьте потом выключить debug. Игра, которая честно пишет «секрет = 42», — это не игра, а спойлер-тренажёр.
Отладочные сообщения: куда ставить println("DEBUG: ...")
Отладочный println — инструмент древний, как первые баги (то есть очень древний). Он хорош, если вы ставите его точечно и печатаете то, что реально помогает. Он плох, если вы начинаете печатать всё подряд и превращаете консоль в водопад текста, где теряется самое важное.
Главная идея: при каждом «шаге игры» вас обычно интересует три-четыре значения: номер попытки, введённая догадка, секрет, и результат сравнения (например -1/0/1). Поэтому имеет смысл сделать маленькую функцию для debug-вывода — чтобы не повторять одну и ту же строку, и чтобы можно было выключить весь debug одним флагом.
fun debugState(debug: Boolean, attempt: Int, secret: Int, guess: Int?) {
if (!debug) return
println("DEBUG: attempt=$attempt secret=$secret guess=$guess")
// DEBUG: attempt=1 secret=42 guess=10
}
И использовать её в цикле аккуратно, сразу после ввода (когда guess может быть null) — так вы видите, что реально пришло с клавиатуры.
val guess = readGuessOrNull(min, max)
debugState(debug, attempt, secret, guess)
if (guess == null) {
println("Некорректный ввод")
continue
}
Чуть более «умный» вариант — печатать ещё и compareResult. Это особенно полезно, когда подсказки кажутся «перепутанными»: например, вы вводите число больше секрета, а программа пишет «Слишком мало». Часто проблема в сравнении или в том, что ветки перепутаны местами.
val result = compareGuess(guess, secret)
if (debug) {
println("DEBUG: compareResult=$result") // DEBUG: compareResult=1
}
println(hintText(result))
3. Таблица сценариев: победа, проигрыш, ввод и границы
Когда сценарии живут только в голове, они быстро превращаются в кашу: «кажется, я это уже проверял… или это было в прошлой жизни?». Поэтому полезно оформлять сценарии хотя бы в виде таблицы: коротко, без романов, но так, чтобы вы могли повторить проверку.
Ниже — пример таблицы для нашей игры. Здесь я намеренно включаю «неприятные» случаи (не число, пробелы, выход за диапазон), потому что именно они чаще всего и ломают программу.
| Сценарий | Настройки | Ввод пользователя (по шагам) | Ожидаемое поведение | Что проверяем |
|---|---|---|---|---|
| Победа сразу | secret=42, maxAttempts=7 | 42 | вывод «Угадал!» + конец игры | корректное завершение по победе |
| Победа не сразу | secret=42, maxAttempts=7 | 10, 50, 42 | «Слишком мало», «Слишком много», «Угадал!» | сравнение и подсказки |
| Проигрыш | secret=42, maxAttempts=3 | 1, 2, 3 | три подсказки + итог «проиграли» | лимит попыток и финальное сообщение |
| Не число | secret=42, maxAttempts=7 | abc, потом 42 | сообщение об ошибке ввода, затем нормальная игра | toIntOrNull() и обработка null |
| Пустая строка/пробелы | secret=42, maxAttempts=7 | " ", потом 42 | ошибка ввода (после trim() строка пустая) | нормализация trim() |
| Выход за диапазон | диапазон 1..100, secret=42 | 101, потом 42 | ошибка «вне диапазона», затем нормальная игра | проверка guess in min..max |
| Граница минимума | диапазон 1..100, secret=1 | 1 | победа | корректность границ |
| Граница максимума | диапазон 1..100, secret=100 | 100 | победа | корректность верхней границы |
Заметьте: даже без автоматизации эта таблица уже даёт вам системность. Вы не просто «тыкали числа», а прошли набор обязательных ситуаций. Если потом появится баг, вы сможете сказать не «что-то сломалось», а «сломался сценарий “выход за диапазон”» — и это уже очень близко к тому, как думают разработчики в реальных командах.
4. Логика игры: попытки, границы и ветвления
Ошибки «на 1»: попытки, <= и место attempt++
Большинство логических багов в играх “угадай число” — это не мистические проблемы, а банальные ошибки «на 1» (off-by-one). Они возникают там, где вы считаете попытки или работаете с границами диапазонов. И неприятны они тем, что программа почти работает: иногда проигрывает на одну попытку раньше, иногда сообщает «победа за 4 попытки», хотя попыток было 3, иногда «зависает» на одной и той же попытке при неверном вводе.
Самая частая зона риска — место attempt++. Если неверный ввод не тратит попытку, то инкремент должен происходить только тогда, когда попытка действительно «сыграла», то есть когда у вас есть корректное число и вы его сравнили с секретом.
Вот характерный фрагмент с правильным расположением attempt++ (политика: неверный ввод попытку не тратит):
val guess = readGuessOrNull(min, max)
if (guess == null) {
println("Некорректный ввод")
continue
}
val result = compareGuess(guess, secret)
println(hintText(result))
if (result == 0) break
attempt++ // попытка потрачена только здесь
Теперь представьте типичный баг: attempt++ стоит в начале цикла или сразу после вывода номера попытки. Тогда неверный ввод начнёт «жечь» попытки (возможно, вы этого не хотели), а иногда вы получите победу «за попытку больше», потому что счётчик увеличился ещё до проверки победы.
Ещё одна тонкость — условие цикла. Например, если попытки считаются от 1 до maxAttempts, то «логичная» проверка выглядит как attempt <= maxAttempts. Если вы случайно напишете attempt < maxAttempts, вы отрежете последнюю попытку и получите проигрыш на шаг раньше. Вроде мелочь, но именно из таких мелочей и состоят «почему пользователь недоволен».
Если вы хотите быстро проверить, корректно ли работает граница попыток, используйте сценарий «проигрыш на 1 попытку»: поставьте maxAttempts = 1, зафиксируйте секрет, введите неверное число и посмотрите, что произойдёт. В таких маленьких настройках ошибки «на 1» выпрыгивают наружу почти мгновенно.
Проверяем сравнение и when: где ломаются подсказки
Когда подсказки работают неправильно, виноват обычно не Kotlin, а наша логика. Но Kotlin может помочь тем, что заставляет писать ветвления более явно. В нашей игре есть два ключевых места: функция сравнения compareGuess и функция текста hintText. Они должны быть согласованы: если compareGuess возвращает -1 для «меньше секрета», то hintText(-1) должна говорить «Слишком мало», а не «Слишком много» (иначе игра превращается в психологический триллер).
Для текста подсказки удобно использовать when как выражение: оно компактно и хорошо читается.
fun hintText(compareResult: Int): String {
return when (compareResult) {
-1 -> "Слишком мало"
1 -> "Слишком много"
else -> "Угадал!"
}
}
Чтобы проверять такие функции «в отрыве от игры», полезно сделать крошечную ручную проверку: вызвать compareGuess и hintText на заранее известных значениях. Это не unit-тесты, а просто маленький самоконтроль, который можно включить временно в main.
val secret = 42
println(hintText(compareGuess(10, secret))) // Слишком мало
println(hintText(compareGuess(99, secret))) // Слишком много
println(hintText(compareGuess(42, secret))) // Угадал!
Если здесь всё правильно, а в игре подсказки «пляшут», значит проблема не в hintText, а в том, что вы сравниваете не те значения (например, где-то перезаписали secret, или неправильно обработали guess, или попытка увеличивается не там и вы смотрите «не на ту итерацию»).
5. Инварианты и страховочные проверки: require и check
Инвариант — это правило, которое должно оставаться верным во время работы программы. В нашей игре типичные инварианты простые: диапазон должен быть корректным (min <= max), секрет должен попадать в диапазон, номер попытки не должен становиться отрицательным, а функция сравнения должна возвращать только -1, 0 или 1. В реальной жизни инварианты спасают от состояния «игра работает странно, но не падает, и это ещё хуже».
Проверки require и check — это способ сказать в коде: «если это не так, значит мы в неправильном мире». Мы уже использовали require в генерации секрета, и это отличный пример: лучше упасть сразу с понятным сообщением, чем получить секрет из “перепутанного диапазона” и потом часами ловить призраков.
fun generateSecret(min: Int, max: Int): Int {
require(min <= max) { "min должен быть <= max (min=$min, max=$max)" }
return kotlin.random.Random.nextInt(from = min, until = max + 1)
}
А вот пример «страховки» для compareGuess, если вы вдруг боитесь, что кто-то изменит код и начнёт возвращать странные значения. Это не обязательно для учебного проекта, но как приём отладки — полезно.
val result = compareGuess(guess, secret)
check(result == -1 || result == 0 || result == 1) {
"compareGuess() вернул невозможный результат: $result"
}
println(hintText(result))
Смысл таких проверок не в том, чтобы «наказать пользователя», а в том, чтобы поймать баг как можно ближе к месту его рождения. Чем дальше баг успел «убежать», тем сложнее потом восстановить, где он появился.
6. Мини-дебаг в IntelliJ IDEA: цикл, breakpoint и значения
Когда println("DEBUG...") уже не помогает (или помогает, но вам надоело вручную читать простыню текста), самое время вспомнить базовый отладчик. Мы уже знакомились с ним раньше, и сейчас нам достаточно самых простых действий: поставить breakpoint, сделать шаг по строкам и посмотреть значения переменных. Это похоже на «замедленную съёмку» вашей программы: вы видите, в какой момент attempt стал «не тем», где именно сработал continue, и почему ветка if не вошла туда, куда вы ожидали.
Самая удобная точка остановки в этой игре — внутри цикла попыток, сразу после ввода и сразу перед сравнением. Тогда вы в одном месте видите attempt, secret, guess, а также можете увидеть, почему guess оказался null (например, вы ввели пробелы, trim() дал пустую строку, toIntOrNull() вернул null — и это нормально). Если вы поставите breakpoint слишком рано, вы ещё не увидите интересные значения; если слишком поздно — вы уже пропустите момент, где всё пошло не так.
И ещё один нюанс: отладчик особенно хорошо ловит ошибки «на 1», потому что вы буквально видите изменение attempt. Если вы ожидали, что после неправильного ввода попытка не увеличится, а она увеличилась — вы сразу понимаете, в какой строке это произошло.
7. Типичные ошибки при сценариях и отладке
Ошибка №1: проверять “на удачу”, а не сценариями.
Если запускать игру каждый раз по-разному и надеяться, что «ну сейчас точно проявится», баг может исчезать или проявляться случайно, особенно когда в игре есть Random. Гораздо эффективнее зафиксировать секрет и пройти один и тот же сценарий несколько раз, пока не станет ясно, в каком месте логика расходится с ожиданием.
Ошибка №2: не фиксировать политику попыток при неверном вводе.
Очень часто программа «зависает» на одной попытке или, наоборот, сжигает попытки слишком быстро из‑за того, что разработчик сам не решил: неверный ввод тратит попытку или нет. В результате attempt++ оказывается то в одной ветке, то в другой, то вообще забывается рядом с continue, и поведение становится случайным.
Ошибка №3: ставить attempt++ в неправильном месте и ловить “победа за N+1 попыток”.
Если увеличивать attempt до проверки победы, можно получить красивое сообщение «Победа за 8 попыток!», когда попыток было 7 (и пользователь, возможно, тоже это заметит). Правило простое: сначала обработали ввод, сравнили, проверили победу; только потом увеличиваем счётчик, если игра продолжается.
Ошибка №4: печатать слишком много debug‑текста и перестать понимать, что происходит.
Отладочные сообщения должны отвечать на конкретный вопрос. Если вы печатаете 20 строк на каждую итерацию, мозг перестаёт искать закономерности и начинает скроллить как лента соцсетей. Лучше печатать 1–2 строки с ключевыми переменными (attempt, guess, secret, result) и обязательно уметь выключить debug одним флагом.
Ошибка №5: перепутать ветки сравнения (< и >) и “чинить” не там.
Классическая ситуация: подсказки всегда «не те». Начинающий разработчик начинает править hintText, потом readGuessOrNull, потом случайно ломает ввод… хотя проблема была в одном месте: перепутаны условия guess < secret и guess > secret. Проверка с фиксированным секретом и парой заранее выбранных значений (10, 99, 42) обычно вскрывает это за минуту.
Ошибка №6: оставить “спойлер” (печать секрета) в обычном режиме игры.
Во время отладки печатать секрет полезно. После отладки — это превращает игру в анекдот (причём не всегда смешной). Поэтому debug‑режим должен быть явно отключаемым, а строки вида println(secret) — жить только под if (debug) и исчезать из финальной версии.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ