[Clone Coding] Make Wordle using Phoenix LiveView: Game Core
이전 시리즈
Phoenix LiveView로 wordle 만들어보기 3번째: Game 코어 모듈 만들기
게임의 핵심 로직을 담당하는 코어 레이어의 Game
모듈부터 만들어 보도록 하겠다. 게임 모듈은 외부 의존성 없이 순수한 함수들로 게임의 상태를 변경하는 모듈이다1.
한 게임마다 게임의 상태를 유지하기 위해 게임 구조체를 만들고, 게임 모듈의 각 함수들은 그 구조체의 상태를 변경할 것이다.
Game
모듈의 퍼블릭 함수 API는 새로운 게임을 생성하고, 게임을 진행하며, 게임의 현재 상태를 가공된 형태로 조회하는 3개의 기능을 제공할 것이다. 대략 다음과 같다.
defmodule MyWordle.Game do
# internal state of a game
defstruct [...some fields...]
@type t :: %__MODULE__{}
# create new game
@spec new_game(String.t()) :: t() | {:error, :invalid_length} | {:error, :not_found}
def new_game(word) do
# ...
end
# game action
@spec make_move(t(), String.t()) ::
{t(), tally()} | {:error, :invalid_length} | {:error, :not_found}
def make_move(game, guess) do
# ...
end
# readable state of the game
@spec tally(t()) :: tally()
def tally(game) do
# ...
end
end
new_game
은 새로운 게임 구조체를 생성한다. wordle 게임 시작시에는 당연히 문제 word를 가지고 있어야 할 것이고, 6번의 기회동안 몇 번째로 맞추고 있는지, 어떤 알파벳을 사용했고 그 중 어떤것이 정답 word와 일치하는지 나타내야할 것이다.
make_move
는 사용자가 정답을 맞추려고 시도할때 호출될 것이다. 5글자의 영어 단어를 받으면 정답과 비교하여 진행상황을 상태에 업데이트하고 결과를 반환해줄 것이다.
tally
는 게임의 상태를 프론트에서 원하는 형태로 가공해서 주는 함수이다. 이전에 추측한 단어들과 지금 몇번의 기회가 남았는지 등을 반환한다.
Game
모듈에 게임의 핵심 로직이 전부 들어가 있으므로, 테스트도 꼼꼼하게 작성한다.
테스트에는 StreamData
를 사용해서 속성 기반 테스트2를 적용해본다.
StreamData
를 이용한 속성 기반 테스트는 예를 들면 다음과 같은 형태가 된다.
describe "make_move/2" do
property "try 6 wrong guess, lose game" do
# (1)
check all word <- string(?A..?Z, length: 5),
guesses <- uniq_list_of(string(?A..?Z, length: 5), length: 6),
word not in guesses do
# (2)
game = Game.new_game(word)
alphabets = guesses |> Enum.flat_map(&String.to_charlist/1) |> Enum.uniq()
game =
guesses
|> Enum.reduce(game, fn guess_word, game ->
{game, _tally} = Game.make_move(game, guess_word)
game
end)
# (3)
assert %Game{turns_left: 0, game_status: :lost, used_charset: used_charset} = game
for c <- alphabets do
assert used_charset[c]
end
end
end
describe
로 테스트할 함수를 적었고 property
블록 에 테스트할 내용을 적었다.
property는 StreamData에서 사용하는 매크로로 ExUnit의 test를 대체한다.
테스트의 주석으로 표시한 각 부분을 풀어서 설명해보자면,
# (1)
check all word <- string(?A..?Z, length: 5),
guesses <- uniq_list_of(string(?A..?Z, length: 5), length: 6),
word not in guesses do
(1) 정답 단어는 5글자의 대문자 알파벳이며, 추측에 쓰일 단어 목록 역시 5글자의 무작위 단어로 구성되었고, 6개의 중복되지 않은 단어를 가지고 있다. 정답 단어는 추측 단어 목록에 포함되지 않는다.
# (2)
game = Game.new_game(word)
alphabets = guesses |> Enum.flat_map(&String.to_charlist/1) |> Enum.uniq()
game =
guesses
|> Enum.reduce(game, fn guess_word, game ->
{game, _tally} = Game.make_move(game, guess_word)
game
end)
(2) 정답 단어로 게임을 생성하고, 생성한 게임과 추측 단어 목록의 각 단어를 차례로 입력값으로 해서 테스트할 함수를 호출 하였다.
# (3)
assert %Game{turns_left: 0, game_status: :lost, used_charset: used_charset} = game
for c <- alphabets do
assert used_charset[c]
end
(3) 최종적으로 반환된 게임의 상태가 원하는 상태인지를 검사한다. 사용된 단어 목록이 기댓값과 같은지 검사한다.
이와 같이 속성 기반 테스트를 작성하여, 자동으로 랜덤한 문자열 단어를 생성 해 테스트 하도록 해볼 수 있었다. 속성 기반 테스트는 예제 기반 테스트와 반대로 개발자의 예상치 못한 실수를 방지하는데 도움이 된다. 실제로 나도 테스트를 진행하며 게임 로직의 잘못된 부분을 수정할 수 있었다
속성 기반 테스트와 별개로 각 함수에는 @doc
으로 간단한 example을 적으면서 동시에 doctest를 작성하여 문서화와 테스트를 동시에 할 수 있도록 했다. 이 역시 엘릭서 개발할 때 손쉽게 적용할 수 있는 큰 장점 중 하나이다. 개발자는 문서화와 테스트 작성을 코드 작성 중간에 녹여낼 수 있도록 했을 때 최고의 생산성을 발휘할 수 있다고 생각한다.
게임의 상세한 로직을 어떻게 짰는지는 여기서 중요하지 않으므로 더이상 적지는 않겠다. 궁금하다면 레포로 가서 확인해볼 수 있을 것이다.
게임 로직을 만들었으니 이제 게임이 실제로 돌아갈 수 있도록 프로세스를 띄워보도록 하자. 유저들을 게임을 각자 실행할 테고, 각 게임은 시작마다 각각의 상태를 유지하며 여러 유저들이 동시에 각자의 게임을 진행할 수 있도록 해야할 것이다. 이를 위해 분산 Elixir/Erlang OTP의 기본 구성요소 GenServer와 Supervisor를 사용한다. 게임 로직을 분리해 놓은 덕분에 게임 서버 코드는 매우 단순해질 것이다. 사실, 100줄도 안되는 코드가 될 것이다.
다음 포스트에서는 이 GenServer와 Supervisor, DynamicSupervisor를 소개하고 게임 서버를 완성시켜 보겠다.