Раздел 27 · Gleam на практике

Кейс Decider: Uno как FSM с ходом и направлением

middle~35 мин

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

Кейс Decider: Uno как FSM с ходом и направлением

Финальная ката серии. Восемнадцать уроков мы строили отель: бронь, счёт, проекции, сага. Сегодня берём ту же форму Decider и роняем её на совсем другой домен, карточную игру. Если форма работает и тут, значит, она и правда универсальнее агрегата. Это и есть главный вывод всего блока.

Сцена · зачем игра в конце курса

Decider мы вывели на брони (урок 28) и потом всё время крутили вокруг отеля. Возникает законный вопрос: может, форма decide/evolve это просто удачная упаковка для FSM брони, а на другом домене она рассыплется?

Проверим на Uno. Карточная игра это чистый FSM, далёкий от бизнеса: ход переходит между игроками, направление меняется, карта обязана совпасть с верхней. Ни одной общей буквы с отелем. Если Decider ляжет и сюда без натяжки, форма доказана.

Спойлер: ляжет. initial, decide, evolve, те же три функции, тот же BDD-стиль тестов given/when/then. Меняется только содержимое, не каркас. Параллельные реализации в yreynhout/gleamuno и Papipo/uno подтверждают: это устоявшийся способ моделировать игры.

Карта урока · что заберёшь через 2.5 часа

  • Увидишь Decider на домене, не имеющем ничего общего с предыдущими уроками.
  • Смоделируешь ход, направление и правило совпадения карт как чистые decide/evolve.
  • Напишешь BDD-сценарии Uno в той же форме, что тесты брони.
  • Закрепишь главный вывод серии: Decider это переносимая форма, а не разовый паттерн.

Концепт · Uno как Decider

Раскладываем игру по форме Decider. Состояние партии:

// examples/ddd-uno/gleam/src/uno.gleam

pub type Game {
  NotStarted
  Playing(
    top: Card,
    current_player: Int,
    player_count: Int,
    direction: Direction,
  )
  Finished
}

Команды (что игрок просит) и события (что случилось):

pub type Command {
  StartGame(top: Card, player_count: Int)
  PlayCard(player: Int, card: Card)
  DrawCard(player: Int)
}

pub type Event {
  GameStarted(top: Card, player_count: Int)
  CardPlayed(player: Int, card: Card)
  CardDrawn(player: Int)
}

Грамматика та же, что в уроке 27: команды в настоящем времени (PlayCard), события в прошедшем (CardPlayed). Три инварианта домена: ходит только текущий игрок, сыгранная карта совпадает с верхней по цвету или номиналу, Skip и Reverse двигают очередь хода. Всё остальное (руки, победа, дикие карты) мы намеренно отрезали, чтобы FSM остался читаемым (это ДЗ).

Языковые механики Gleam · правило совпадения и движение хода

Правило Uno: карта подходит, если совпадает по цвету или по номиналу. Это одна строка:

pub fn matches(top: Card, played: Card) -> Bool {
  top.color == played.color || top.value == played.value
}

Движение хода интереснее. Обычная карта передаёт ход следующему, Skip перепрыгивает одного, Reverse меняет направление. По кругу это арифметика по модулю числа игроков:

fn advance(current: Int, count: Int, steps: Int, direction: Direction) -> Int {
  let delta = case direction {
    Clockwise -> steps
    CounterClockwise -> -steps
  }
  let assert Ok(seat) = int.modulo(current + delta, count)
  seat
}

int.modulo при положительном count всегда возвращает 0..count-1, поэтому отрицательный сдвиг (ход против часовой) корректно заворачивается по кругу, не уходя в минус. Это ровно то, что нужно для рассадки за столом.

Задача · сигнатуры

Собери uno.gleam:

pub fn initial() -> Game
pub fn matches(top: Card, played: Card) -> Bool
pub fn decide(state: Game, command: Command) -> Result(List(Event), UnoError)
pub fn evolve(state: Game, event: Event) -> Game

Контракт:

  • decide для PlayCard: партия идёт, ходит текущий игрок (NotYourTurn иначе), карта совпадает (CardDoesNotMatch иначе), тогда Ok([CardPlayed(...)]).
  • decide для StartGame: только из NotStarted и при player_count >= 2.
  • evolve для CardPlayed: новый top это сыгранная карта, направление разворачивается на Reverse, ход сдвигается (на 2 при Skip, иначе на 1).
  • decide чистая, без now: события Uno времени не несут (в отличие от брони). Форма Decider это допускает.

Подсказки

  • decide матчит case command, потом внутри case state. Сначала проверяй существование партии (NotStarted это GameNotStarted), потом очередь, потом совпадение. Порядок отражает приоритет ошибок.
  • evolve для CardPlayed должна знать текущее направление и число игроков, поэтому матчит Playing(...) и достаёт поля. На не-Playing состоянии (битый стрим) возвращает state: evolve тотальна и не судит.
  • Reverse переворачивает направление до сдвига хода. Сначала меняем направление, потом считаем следующего игрока уже в новом направлении.
  • Руки не моделируем: decide не проверяет, что у игрока есть сыгранная карта. Это сознательное упрощение, чтобы инвариант хода был виден. Полные руки это ДЗ.

Разбор · decide и evolve

decide это три инварианта, разложенные по веткам:

PlayCard(player, card) ->
  case state {
    NotStarted -> Error(GameNotStarted)
    Finished -> Error(GameNotStarted)
    Playing(top, current, _, _) ->
      case player == current {
        False -> Error(NotYourTurn(expected: current, got: player))
        True ->
          case matches(top, card) {
            False -> Error(CardDoesNotMatch(top: top, played: card))
            True -> Ok([CardPlayed(player, card)])
          }
      }
  }

Сравни с decide брони из урока 28: тот же приём, вложенные case, каждый отвергает свой класс невозможного, в конце Ok([событие]). Домен другой (карты вместо комнат), форма буква в букву та же.

evolve двигает партию, вся хитрость в движении хода спрятана в after_play:

fn after_play(card: Card, current: Int, count: Int, direction: Direction) -> Game {
  let new_direction = case card.value {
    Reverse -> flip(direction)
    _ -> direction
  }
  let steps = case card.value {
    Skip -> 2
    _ -> 1
  }
  Playing(
    top: card,
    current_player: advance(current, count, steps, new_direction),
    player_count: count,
    direction: new_direction,
  )
}

Тот же Decider, другой домен: погоняй переходы брони руками и сравни форму с Uno:

BDD · тот же given/when/then

Тесты Uno неотличимы по форме от тестов брони. Тот же verify, given прошлые события, when команда, then результат:

// examples/ddd-uno/gleam/test/uno_test.gleam

pub fn play_matching_value_across_colors_is_allowed_test() {
  verify(
    given: [GameStarted(Card(Red, Number(5)), 2)],
    when: PlayCard(0, Card(Blue, Number(5))),
    then: Ok([CardPlayed(0, Card(Blue, Number(5)))]),
  )
}

pub fn reverse_flips_direction_test() {
  let state =
    state_after([
      GameStarted(Card(Red, Number(5)), 3),
      CardPlayed(0, Card(Red, Reverse)),
    ])
  case state {
    Playing(_, current, _, direction) -> {
      direction |> should.equal(CounterClockwise)
      current |> should.equal(2)
    }
    _ -> panic as "ожидался Playing"
  }
}

Синяя карта на красную проходит по совпадению номинала. Reverse из игрока 0 при трёх игроках разворачивает направление и отдаёт ход игроку 2 (против часовой). Это ровно тот свимлейн, что мы рисовали для брони, только нарисованный для игры.

Критика · что упрощено

  • Нет рук. decide не проверяет, что у игрока есть карта, и не ловит победу по пустой руке. С руками появляется событие GameWon и состояние Finished обретает смысл (ДЗ).
  • Нет draw-then-play. По правилам после взятия карты можно её сразу сыграть. У нас DrawCard просто передаёт ход (ДЗ).
  • Нет диких карт. Wild подходит к любой и требует объявить цвет, это команда с дополнительным полем (ДЗ).
  • Reverse при двух игроках. По правилам он работает как Skip. Наш advance этого не выделяет: при двух игроках сдвиг на 1 против часовой и так возвращает ход тому же игроку, что близко, но не точная семантика.
  • Партия не персистится. Decider чистый, но мы не подключили его к event store. Подключение тривиально: партия это стрим, тот же handle_command урока 29 (ДЗ).

Takeaway · и итог всей серии

Одна фраза:

Decider это переносимая форма, а не паттерн под один домен: initial, decide, evolve лёг на Uno так же чисто, как на бронь, и BDD-тесты given/when/then сохранили тот же каркас. Освоив форму, ты моделируешь ей агрегат, FSM игры, процесс-менеджер, что угодно с состоянием.

Восемнадцать кат позади. Серия прошла путь от opaque-типа Email до распределённой саги: Value Object, Entity, Aggregate как FSM, доменные события, Repository и адаптеры, bounded context, composition root, SQLite, Event Modeling, Decider, event sourcing, CQRS, process manager и saga, aggregateless ES, и финальный кейс на Uno. Всё на чистом Gleam, без единого класса. Дальше та же модель ждёт тебя в портах на Effect.ts, Haskell и Rust: домен один, языки разные, и сравнение покажет, что меняется, а что остаётся.

ДЗ

Дальше

Gleam-трек функционального DDD закрыт. По плану docs/FUNCTIONAL_DDD_PLAN.md дальше идут порты того же домена на другие языки: Effect.ts (R5), Haskell (R6), Rust (R7), и сводный урок в 16-arch, сравнивающий четыре реализации одного Reservation Decider бок о бок.

Параллельно полезно перечитать:

  • 28. Decider, с которого началась форма. Сегодня видно, что она пережила смену домена без единой правки каркаса.
  • 16. Functional DDD intro, North Star серии. Перечитай три утверждения: после восемнадцати кат они должны читаться как очевидность.