玩游戏

在这一部分,我们将构建玩游戏的逻辑。游戏玩法包括对选定的单词进行多次猜测,查看该猜测的分数,并进行另一次猜测。游戏在单词被正确猜出或达到允许的最大尝试次数时结束。

我们将从假设我们处于典型游戏的开始阶段,即将进行第一次猜测开始。我们还将假设这个猜测并不完全正确。这使我们能够推迟关于游戏结束行为的决策,这是一件好事,因为我们已经有很多需要决定的事情了。

设计计分接口

我们首先需要做出的设计决策是在猜测单词后返回什么信息。我们需要向用户返回以下信息:

  • 当前猜测的得分

  • 游戏是否仍在进行中或已结束

  • 可能返回每次猜测的得分历史记录

  • 可能返回用户输入错误的报告

显然,对玩家来说最重要的信息是当前猜测的得分。没有这个信息,游戏无法进行。由于游戏的长度是可变的——当单词被猜中或达到最大猜测次数时结束——我们需要一个指示器来表明是否允许再次猜测。

返回之前猜测得分历史记录的想法是,它可能有助于我们的领域模型的消费者——最终是某种用户界面。如果我们只返回当前猜测的得分,用户界面很可能需要保留自己的得分历史记录,以便正确展示。如果我们返回整个游戏的得分历史记录,这些信息就很容易获得。软件设计中的一个好原则是遵循 “你不需要它”(YAGNI)原则。由于没有对得分历史记录的需求,我们现阶段不会构建这个功能。

我们需要做出的最后一个决策是为这个功能设计编程接口。我们将选择在`Wordz`类中创建一个 assess() 方法。它将接受一个 String 类型的参数,表示玩家的当前猜测。它将返回一个 record,这是现代 Java(自 Java 14 起)表示纯数据结构的方式。

现在我们已经有了足够的信息来编写测试。我们将为所有与猜测相关的行为创建一个新的测试类 GuessTest。测试代码如下:

@ExtendWith(MockitoExtension.class)
public class GuessTest {
    private static final Player PLAYER = new Player();
    private static final String CORRECT_WORD = "ARISE";
    private static final String WRONG_WORD = "RXXXX";

    @Mock
    private GameRepository gameRepository;

    @InjectMocks
    private Wordz wordz;

    @Test
    void returnsScoreForGuess() {
        givenGameInRepository(
            Game.create(PLAYER, CORRECT_WORD));
        GuessResult result = wordz.assess(PLAYER, WRONG_WORD);
        Letter firstLetter = result.score().letter(0);
        assertThat(firstLetter)
            .isEqualTo(Letter.PART_CORRECT);
    }

    private void givenGameInRepository(Game game) {
        when(gameRepository
            .fetchForPlayer(eq(PLAYER)))
            .thenReturn(Optional.of(game));
    }
}

这个测试中没有新的 TDD 技术。它驱动出了我们新的 assess() 方法的调用接口。我们使用了静态构造函数模式来创建 Game 对象,使用了 Game.create() 方法。这个方法已经被添加到 Game 类中:

static Game create(Player player, String correctWord) {
    return new Game(player, correctWord, 0, false);
}

这明确了创建一个新游戏所需的信息。为了让测试通过,我们创建了 GuessResult 记录:

package com.wordz.domain;
import java.util.List;

public record GuessResult(
    Score score,
    boolean isGameOver
) { }

我们可以通过在 Wordz 类中编写 assess() 方法的生产代码来使测试通过。为此,我们将重用已经编写的 Word 类:

public GuessResult assess(Player player, String guess) {
    var game = gameRepository.fetchForPlayer(player);
    var target = new Word(game.getWord());
    var score = target.guess(guess);
    return new GuessResult(score, false);
}

断言只检查第一个字母的得分是否正确。这是一个故意设计得较弱的测试。详细的得分行为测试已经在之前编写的 WordTest 类中完成。这个测试被描述为弱测试,因为它没有完全测试返回的得分,只测试了第一个字母。得分逻辑的强测试发生在其他地方,即在 WordTest 类中。这里的弱测试确认了我们至少能够正确计算一个字母的得分,这足以让我们测试驱动生产代码。我们在这里避免了重复测试。

运行测试显示它通过了。我们可以审查测试代码和生产代码,看看是否可以通过重构改进设计。目前,没有什么需要紧急处理的。我们可以继续推进游戏进度的跟踪。

三角化游戏进度跟踪

我们需要跟踪已经进行的猜测次数,以便在达到最大尝试次数后结束游戏。我们的设计选择是更新 Game 对象中的 attemptNumber 字段,然后将其存储到 GameRepository 中:

  1. 我们添加一个测试来驱动这段代码的实现:

    @Test
    void updatesAttemptNumber() {
       givenGameInRepository(
           Game.create(PLAYER, CORRECT_WORD));
    
       wordz.assess(PLAYER, WRONG_WORD);
    
       var game = getUpdatedGameInRepository();
       assertThat(game.getAttemptNumber()).isEqualTo(1);
    }
    
    private Game getUpdatedGameInRepository() {
       ArgumentCaptor<Game> argument
           = ArgumentCaptor.forClass(Game.class);
       verify(gameRepository).update(argument.capture());
       return argument.getValue();
    }

    这个测试引入了一个新方法 update() 到我们的 GameRepository 接口中,负责将最新的游戏信息写入存储。断言步骤使用了 Mockito 的 ArgumentCaptor 来检查我们传递给 gameRepository.update()Game 对象。我们编写了 getUpdatedGameInRepository() 方法,以减少检查 gameRepository.update() 方法内部逻辑的复杂性。assertThat() 在测试中验证了 attemptNumber 是否已递增。由于我们创建了一个新游戏,它的初始值为 0,因此预期的新值为 1。这是跟踪猜测尝试次数的预期行为。

  2. GameRepository 接口中添加 update() 方法

    package com.wordz.domain;
    
    public interface GameRepository {
       void create(Game game);
       Game fetchForPlayer(Player player);
       void update(Game game);
    }
  3. Wordz 类的 assess() 方法中添加生产代码 我们更新 assess() 方法,增加 attemptNumber 并调用 update()

    public GuessResult assess(Player player, String guess) {
       var game = gameRepository.fetchForPlayer(player);
       game.incrementAttemptNumber();
       gameRepository.update(game);
       var target = new Word(game.getWord());
       var score = target.guess(guess);
       return new GuessResult(score, false);
    }
  4. Game 类中添加 incrementAttemptNumber() 方法 我们为 Game 类添加递增尝试次数的方法:

public void incrementAttemptNumber() {
   attemptNumber++;
}

现在测试通过了。我们可以考虑是否需要进行重构改进。有两个地方值得注意:

  • NewGameTestGuessTest 类之间的重复测试设置

    目前我们可以接受这种重复。可能的解决方案包括将两个测试合并到同一个测试类中、扩展一个共同的测试基类,或者使用组合。但这些方法似乎都不会显著提高可读性。目前将两个不同的测试用例分开似乎更合适。

  • assess() 方法中的三行代码必须作为一个单元调用

    可能会忘记调用其中某一行代码,因此最好通过重构来消除这种可能的错误。我们可以这样重构:

     public GuessResult assess(Player player, String guess) {
         var game = gameRepository.fetchForPlayer(player);
         Score score = game.attempt(guess);
         gameRepository.update(game);
         return new GuessResult(score, false);
     }

    我们将原本的代码移到新创建的 Game 类的 attempt() 方法中:

     public Score attempt(String latestGuess) {
         attemptNumber++;
         var target = new Word(targetWord);
         return target.guess(latestGuess);
     }

    将方法参数从 guess 重命名为 latestGuess 可以提高可读性。

至此,我们已经完成了猜测单词所需的代码。接下来,我们将继续测试驱动检测游戏何时结束的代码。