Кейс Decider: Uno как FSM с ходом и направлением
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Кейс 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 серии. Перечитай три утверждения: после восемнадцати кат они должны читаться как очевидность.