Знайте игру, ее правила и как строить

Разработка через тестирование была краеугольным камнем хороших методов разработки программного обеспечения на протяжении большей части двадцати пяти лет. Я уверен, что все мы делаем это в нашей повседневной работе (да, мы делаем, не так ли!!), но иногда приятно взять новую проблему и создать ее с нуля, используя TDD. Ката, если хотите.

Нам нужна задача, которая обеспечивает баланс между чрезмерно упрощенной и умопомрачительной сложностью. Древняя игра Kalah прекрасно подходит для этой цели. Это довольно простая игра с несколькими правилами — что-то, что можно написать за полдня, в отличие от альтернатив, таких как шахматы или покер, которые имеют гораздо больше движущихся частей и могут стать долгосрочным проектом. Так что же такое Калах?

Что такое Калах?

Калах, также называемый Калаха или Манкала, — игра из семейства манкала, придуманная в США Уильямом Джулиусом Чемпионом-младшим (Википедия).

Правила

В игре есть доска Калах (см. рисунок вверху статьи) и несколько семян или жетонов. На каждой стороне доски есть 6 маленьких ямок, называемых домиками; и большая яма, называемая конечной зоной или магазином, на каждом конце. Цель игры — собрать больше семян, чем противник.

  1. В начале игры в каждый дом кладут по четыре семени. Это традиционный метод.
  2. Каждый игрок контролирует шесть домов и их семена на стороне игрока. Счет игрока — это количество семян в магазине справа от него.
  3. Игроки по очереди сеют семена. В свой ход игрок убирает все семена из одного из домов, находящихся под его контролем. Двигаясь против часовой стрелки, игрок по очереди бросает по одному семени в каждый дом, включая собственный магазин игрока, но не магазин противника.
  4. Если последнее посеянное семя попадает в пустой дом, принадлежащий игроку, а в противоположном доме есть семена, и последнее семя, и противоположные семена захватываются и помещаются в магазин игрока.
  5. Если последнее посеянное семя попадает в магазин игрока, игрок получает дополнительный ход. Количество ходов, которые игрок может сделать за свой ход, не ограничено.
  6. Когда у одного из игроков больше нет семян ни в одном из домов, игра заканчивается. Другой игрок перемещает все оставшиеся семена в свой магазин, и игрок с наибольшим количеством семян в своем магазине побеждает.
  7. Игра может закончиться вничью.

Надеюсь, это имеет смысл, но если это все еще не ясно — или вы просто хотите увидеть, как это выглядит в реальном мире — посмотрите следующее видео.

Как мы должны подходить к этому?

Прежде чем мы начнем, давайте подумаем, как мы должны подойти к проблеме. Здесь мы думаем не о коде, а о лучшем пути от нуля к чистому решению.

Вы не можете играть в игру без доски, и вы не можете построить доску без кусочков, из которых она состоит. Для начала мы создадим части платы, а затем соберем их вместе в работающее целое.

  1. Фрагменты игрового поля (дома, магазины и их взаимодействие)
  2. Сама плата (состав битов в #1)

Далее, вы не можете играть в игру без игроков и того, как игроки могут себя вести, поэтому мы создадим их.

3. Игроки (и их взаимодействие)

Наконец, теперь, когда у нас есть доска и игроки, мы можем собрать все вместе (вместе с некоторыми правилами), чтобы создать «Игру».

4. Соединяем все это вместе с некоторыми правилами, чтобы создать «Игру».

Без лишних слов, давайте начнем.

Дома, магазины и ямы (о боже!)

Первое, что нужно сделать, это создать части, которые мы можем использовать для построения нашей доски — маленькие ямки, в которых хранятся наши семена.

Есть два типа ям — дома (маленькие внизу/вверху) и магазины (последняя зона, где каждый игрок хранит свой тайник с очками).

На доске есть 6 маленьких ямок, называемых домиками.

Начнем с домов. Дом начинает игру с установленным количеством семян, обычно четырьмя. Это может быть нашим первым испытанием. Давайте создадим дом из четырех семян.

Достаточно простой объект с одним свойством — количеством семян, которые он содержит.

Большая яма, называемая магазином, находится на каждом конце.

Далее за другим видом ямы, магазином. В отличие от дома, магазин начинает игру пустым и мало что делает, кроме как собирает семена на протяжении всей игры.

Я пропускаю здесь один или два шага, но поскольку и дом, и магазин представляют собой тип ямы,а яма — это контейнер с семенами, мы можем реализовать такое поведение, что и дом, и магазин являются подклассами класса pit.

Обычно я не являюсь большим поклонником иерархий наследования, но этот кричит о своем существовании, так что давайте его допустим.

Вы должны быть в состоянии удалить семена из дома

Ключевым свойством дома является то, что игрок может взять из него семена и посеять их в другом месте. Из хранилища не могут быть удалены семена, поэтому мы должны оставить это нетронутым.

Опять же просто, мы хотим сбросить начальное число дома и вернуть исходное значение вызывающей стороне.

Посев семян

Как только игрок забрал семена из дома, он их сеет. И дома, и магазины могут быть заполнены большим количеством семян, но между ними есть одно различие (и здесь есть небольшое предзнаменование). Дома могут принимать только одно семя за раз, тогда как магазинможет принимать много.

Это суммирует поведение различных типов ям. Это наши маленькие кусочки состояния, которые составляют основу нашей игры. Теперь давайте начнем их составлять, чтобы построить что-то более интересное.

Создание доски

На игровой доске должны быть все «физические» предметы, которые позволяют нам играть в игру. Сюда входят дома, магазины, семена и связи между ними. Давайте быстро взглянем на настоящую доску Kalah.

У каждого игрока есть 6 домови магазин. Игроки смотрят друг на друга и владеют домами внизу и магазином справа от них. Они перемещаются против часовой стрелки вокруг доски на каждый ход. Давайте попробуем собрать его вместе.

Яма должна указывать на следующую яму на доске.

Наши дома и магазины парят в одиночестве в космосе, не подозревая, что что-то еще существует. Нам нужен способ упорядочить эти ямыв форме доски Калаха.

В идеале мы хотим уловить однонаправленный характер движения игры. За каждой ямой может следовать одна и только одна другая яма.

Односвязный список похож на то, что нам нужно, но нам не нужны многие общие операции со списками из общей библиотеки, поэтому давайте просто добавим следующий указатель на каждую яму. У каждой ямы есть следующая яма, поэтому мы можем проходить их подобно итератору. Это гарантирует, что игрок не сможет неправильно обойти доску или случайно пропустить яму. Идея состоит в том, чтобы упорядочить модель, чтобы уменьшить количество возможных ошибок в будущем.

Теперь, когда мы можем соединить питы, мы должны начать рассматривать их инкапсуляцию в объекте центральной платы.

На доске должно быть двенадцать домов, разделенных между двумя игроками.

У каждого игрока есть (по умолчанию) шесть домов, всего двенадцать домов на доске. Давайте удостоверимся, что это так, когда мы создадим новую доску.

Опять же, я пропустил здесь несколько шагов, но в основном это тяжелая работа. Мы просто добавляем метод create, который создает экземпляры всех нужных нам домов, соединяет их один за другим и вставляет в игровое поле. В дополнение к этому я ввел перечисление для представления Player.ONE и Player.TWO, а также для присвоения каждому игроку соответствующего количества домов.

У каждого игрока должно быть два магазина

Нам нужно добавить магазин для каждого игрока и разместить их на доске.

Доска должна представлять двух игроков.

Вскоре мы будем много говорить об игроках, а пока нам нужно разделить доску на две половины. На каждой половине по шесть домов и магазин.

Чтобы сгруппировать ямы, мы введем запись игрока (используя причудливые записи Java 17).

Дома должны иметь взаимные противоположности

Еще немного предзнаменований, но обратим внимание на следующее требование:

Если последнее посеянное семя попадает в пустой дом, принадлежащий игроку, а в доме напротив есть семена, последнее семя и противоположные семена захватываются и помещаются в магазин игрока.

Помимо следующей ямы в цепочке, дом должен отслеживать свою противоположность.

Ямы должны образовывать цикл

Мы почти закончили с доской, но наша цепочка ям распутана. Все ходы проходят через ямы в направлении против часовой стрелки, и мы хотим избежать падения игрока с конца доски в небытие. Нам нужно соединить последнюю яму с первой, чтобы сформировать цикл или петлю.

Готов Первый Игрок?

Теперь, когда у нас есть игровая поверхность, готовая к работе, нам нужно создать несколько игроков, чтобы противостоять друг другу. Прежде чем мы начнем кодировать, нам нужно сделать некоторые выборы дизайна, осталось распределить несколько ключевых обязанностей.

  • Что должно перемещать семена по доске?
  • Какие должны применяться правила, основанные на захвате семян?
  • К чему следует применять правила, основанные на чередовании?
  • Какие должны применяться правила, основанные на выигрыше/проигрыше?

Есть несколько способов разделить эти обязанности, и в какой-то степени это дело вкуса. Я лично являюсь поклонником богатых моделей предметной области, когда мы сохраняем соответствующее поведение внутри моделей, а не как некий контроллер, манипулирующий ими, поэтому я предлагаю разделить обязанности следующим образом:

  • игрок отвечает за манипулирование фигурами доски, особенно своей половиной доски. Любые проблемы, связанные с посевом семян или захватом семян у другого игрока, должны быть прерогативой игрока.
  • Сама игра использует книгу правил. Таким образом, хотя это не изменяет никакого состояния самой доски, оно направляет ход игры — например. чья сейчас очередь, завершена ли игра и кто стал победителем.

Теперь, когда мы правильно разделили поведение, давайте продолжим создание наших игроков.

Игроки должны сеять семена в свой ход.

Первое и, пожалуй, самое важное поведение игрока — сеять семена. Посев — достаточно простое действие — берем семена из одной ямы, а затем посещаем последующие ямы (как наборы домов, так и магазин игрока) против часовой стрелки, кладя в каждую по одному семени.

Тест упражнения короткая цепочка ям. Следует отметить, что мы будем использовать индексацию на основе 1 при выборе дома, то есть первый дом имеет индекс 1, а не 0, потому что это более интуитивно понятно для непрограммиста. В противном случае это просто случай повторения цепочки ям, добавляя одно семя из нашей захваченной стопки в каждую, пока мы не достигнем нуля.

Игрок не может выбрать пустой дом

Игрок должен выбрать населенный дом, иначе игра застопорится. Давайте добавим защитную оговорку, чтобы защититься от этого.

Невозможно выбрать дом вне диапазона

Игрок не может выбрать седьмой дом, когда у него есть только шесть на выбор. Время для еще одной защитной оговорки.

Игрок пропускает магазин противников

Последнее правило посева семян заключается в том, что игрок не должен сеять семена в магазине противника. Их следует пропускать при посеве семян.

Чтобы достичь этого и поддерживать полиморфизм различных типов ям, мы добавим метод #isSowable.

Дома всегда можно засевать независимо от игрока, но магазины разрешены только в том случае, если они принадлежат засевающему игроку.

И это подводит итог действиям наших игроков. Как мы обсуждали в начале этого раздела, роль игрока состоит в том, чтобы правильно перемещать семена по доске — они являются средством, с помощью которого мы меняем состояние.

Наша последняя задача — завернуть все в «игру», давайте посмотрим.

Игра началась

В последнем разделе мы упомянули, что основной обязанностью игры является «свод правил», определяющий такие вещи, как чей сейчас ход, когда игра завершена, кто является победителем в этой игре и т. д. «Игра ” также можно рассматривать как проводника других произведений — это ворота и координатор танца между двумя игроками и доской.

Из-за своих организационных обязанностей игра много взаимодействует с другими компонентами, которые мы создали, и из-за этого тесты в этом разделе будут больше склоняться к концу спектра социального модульного теста. Стоит признать, что мы это уже делали, но не в такой же степени. В частности, модульные тесты, которые мы напишем в этом разделе, будут иметь вид:

  1. Создайте доску в каком-то начальном состоянии и создайте игру на этой доске.
  2. Предпримите какое-либо действие (обычно делегируя полномочия игрокам).
  3. Утверждаем, что конечное состояние доски соответствует нашим ожиданиям.

Понимая это, давайте начнем.

Первый игрок должен сделать первый ход

В начале игры мы позволим первому игроку взять на себя инициативу.

Хотя это достаточно просто, чтобы пройти этот тест, мы воспользуемся этой возможностью, чтобы сделать несколько шагов вперед и создать основу класса Game.

Если подумать, игра представляет собой небольшой конечный автомат. В зависимости от комбинированного состояния игрока, доски и статуса игра решит, каким должно быть следующее действие. Частью начального состояния является то, что это ход первого игрока.

Должен отклонять ход, когда не ход игрока

Давайте добавим быстрое утверждение, чтобы ход терпел неудачу, если не тот игрок пытается сделать ход.

Здесь мы представим метод #move, который принимает номер игрока и номер дома. Вскоре мы углубимся в это, но сначала давайте просто создадим исключение, если не тот игрок попытается сделать ход.

(Небольшое отступление о красивой печати)

Состояние в игре в этих примерах становится немного более сложным. Прежде чем двигаться дальше, давайте сделаем небольшое отступление, чтобы упростить любые тесты, которые нам нужно писать с этого момента.

Как и в случае с любым кодом, сделать тесты легкими для чтения и написания — достойная цель как для нашего здравомыслия, так и для здравомыслия наших коллег-разработчиков. Я хочу иметь возможность взглянуть на тест и понять его суть, не прокручивая в голове десятки утверждений и собирая воедино их общий смысл.

Я хотел бы видеть, как выглядит поле Калах до/после того, как мы сделали ход. Воспользовавшись многострочными строками Java 17, мы можем сделать именно это — давайте напишем простую утилиту для симпатичного принтера.

Первый оборот (посев семян)

У нас есть начальное состояние нашей игры, готовое к работе, поэтому пришло время начать делать некоторые ходы. Первый ход принадлежит первому игроку, поэтому давайте смоделируем, как он выбирает дом номер один в качестве начальной игры.

Используя наш симпатичный принтер, мы доводим до нашего внимания ожидаемый результат. Первый дом игрока должен стать пустым, а следующие четыре ямы должны получить дополнительное семя.

Как обсуждалось ранее, игра в основном делегирует выполнение своей работы другим частям системы — в данном случае объекту игрока.

Кроме того, мы улучшим метод #move, заставив его возвращать результат. Результат сообщит вызывающему абоненту три ключевых элемента информации:

  • Статус игры (независимо от того, активна ли она, первый игрок является победителем и т. д.)
  • Игрок, который должен сделать следующий ход.
  • Ссылка на доску.

Игроки ходят по очереди

Как только первый игрок сделал свой ход, управление должно перейти ко второму игроку. Возьмем пример из прошлого и расширим его дополнительным ходом.

Чтобы разрешить передачу управления, нам просто нужно поменять активного игрока на запасного в конце хода. Хорошая возможность поэкспериментировать с еще одной новой функцией Java — переключением выражений.

Если последнее посеянное семя попадает в магазин игрока, игрок получает дополнительный ход.

Теперь, когда мы переключаемся между игроками, давайте добавим правило. Если игрок бросает последнее семя своего хода в свой магазин, контроль над игрой остается за ним.

Чтобы достичь этого, нам просто нужно улучшить наше поведение #swap, проверив, была ли последняя яма хода магазином активного игрока. Если это так, избегайте обмена и придерживайтесь активного игрока.

Если последнее посеянное семя падает в пустой дом

Если последнее посеянное семя попадает в пустой дом, принадлежащий игроку, а в противоположном доме есть семена, и последнее семя, и противоположные семена захватываются и помещаются в магазин игрока.

Чтобы упростить настройку сценариев, мы добавим Board#from factory для создания доски любого состояния по нашему выбору. Теперь мы можем легко построить тестовый пример, в котором игрок может сделать ход и захватить противоположную яму.

Сама логика захвата не требует пояснений, но стоит отметить, что поскольку именно игрок обрабатывает сиды, мы сохраним логику с игроком.

Когда у одного из игроков больше нет семян ни в одном из домов, игра заканчивается.

Игра заканчивается, когда любой из игроков очистит свои дома от семян. Когда это происходит, оба игрока перемещают все оставшиеся семена из своих домов в свои магазины, и подсчеты в магазине используются для объявления победителя.

Игра может закончиться вничью.

В конце игры, если оба игрока имеют одинаковое количество семян в своих магазинах, она заканчивается ничьей.

Мы уже позаботились об этой функциональности в предыдущем сегменте — не совсем на тест-драйве, но иногда так получается.

Пример игры

Мы закончили! Есть одна небольшая проблема — все примеры мы сфабриковали сами. Что, если мы допустили ошибку или неправильно поняли одно из требований.

В таком сложном случае, как этот, я хотел бы попробовать пример от третьей стороны, чтобы триангулировать и убедиться, что код делает то, что ожидают несколько разных источников. В этом случае воспользуемся примером из Программы CS Манчестерского университета.

Нам просто нужно закодировать несколько ходов, как описано в примере, и убедиться, что наш вывод совпадает с их выводом.

Заключительное слово

Фу, и мы закончили!

Мы перешли от нуля к функциональной игре Kalah и, надеюсь, продемонстрировали несколько нюансов TDD по мере того, как мы путешествовали туда. Некоторые решения могут прийтись по вкусу не всем, но я думаю, что мы придумали надежную и богатую модель Калаха, которую в будущем будет легко изменять/расширять по мере необходимости — и все благодаря TDD.

Для окончательного проекта, пожалуйста, проверьте https://github.com/notmattlucas/kalah-tdd.

Ресурсы