结束游戏

在本节中,我们将完成测试和生产代码,以驱动检测游戏结束的逻辑。游戏将在以下两种情况下结束:

  1. 正确猜出单词

  2. 达到允许的最大尝试次数

我们可以从编写正确猜出单词时检测游戏结束的代码开始。

回应正确猜测

在这种情况下,玩家正确猜出了目标单词。游戏结束,玩家将根据在正确猜测之前所需的尝试次数获得一定数量的分数。我们需要传达游戏已结束以及玩家获得了多少分数,这导致我们在 GuessResult 类中添加两个新字段。我们可以在现有的 GuessTest 类中添加一个测试,如下所示:

@Test
void reportsGameOverOnCorrectGuess() {
    var player = new Player();
    Game game = new Game(player, "ARISE", 0);
    when(gameRepository.fetchForPlayer(player))
        .thenReturn(game);
    var wordz = new Wordz(gameRepository,
        wordRepository, randomNumbers);

    var guess = "ARISE";
    GuessResult result = wordz.assess(player, guess);

    assertThat(result.isGameOver()).isTrue();
}

这个测试驱动出了 GuessResult 类中的一个新方法 isGameOver(),以及使其返回 true 的行为:

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

    gameRepository.update(game);
    return new GuessResult(score, false);
}

这本身又驱动出了 WordTest 类中的两个新测试:

@Test
void reportsAllCorrect() {
    var word = new Word("ARISE");
    var score = word.guess("ARISE");
    assertThat(score.allCorrect()).isTrue();
}

@Test
void reportsNotAllCorrect() {
    var word = new Word("ARISE");
    var score = word.guess("ARI*E");
    assertThat(score.allCorrect()).isFalse();
}

这些测试又驱动出了 Score 类中的一个实现:

public boolean allCorrect() {
    var totalCorrect = results.stream()
        .filter(letter -> letter == Letter.CORRECT)
        .count();
    return totalCorrect == results.size();
}

至此,我们为 GuessResult 记录中的 isGameOver 访问器提供了一个有效的实现。所有测试都通过了。似乎没有需要重构的地方。我们将继续编写下一个测试。

三角化因猜错次数过多而结束游戏

下一个测试将驱动出对超过允许的最大猜测次数的响应:

@Test
void gameOverOnTooManyIncorrectGuesses() {
    int maximumGuesses = 5;
    givenGameInRepository(
        Game.create(PLAYER, CORRECT_WORD,
            maximumGuesses - 1));

    GuessResult result = wordz.assess(PLAYER, WRONG_WORD);
    assertThat(result.isGameOver()).isTrue();
}

这个测试设置了 gameRepository,允许最后一次猜测。然后设置猜测为错误。我们断言在这种情况下 isGameOver()true。测试最初会失败,这是预期的。我们在 Game 类中添加一个额外的静态构造函数方法,以指定初始的尝试次数。

我们添加生产代码,以基于最大猜测次数结束游戏:

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

我们在 Game 类中添加这个决策支持方法:

public boolean hasNoRemainingGuesses() {
    return attemptNumber == MAXIMUM_NUMBER_ALLOWED_GUESSES;
}

现在所有的测试都通过了。然而,代码中有些地方值得怀疑。它被非常精细地调整为仅在猜测正确且在允许的猜测次数内,或者当猜测错误且正好达到允许的次数时工作。现在是时候添加一些边界条件测试并再次检查我们的逻辑了。

三角化游戏结束后回应猜测

我们需要围绕游戏结束检测的边界条件添加几个测试。第一个测试驱动出在正确猜测后提交错误猜测的响应:

@Test
void rejectsGuessAfterGameOver() {
    var gameOver = new Game(PLAYER, CORRECT_WORD,
        1, true);

    givenGameInRepository(gameOver);

    GuessResult result = wordz.assess(PLAYER, WRONG_WORD);

    assertThat(result.isError()).isTrue();
}

这个测试中体现了几个设计决策:

  1. 一旦游戏结束,我们会在 Game 类中记录一个新的字段 isGameOver

  2. 这个新字段需要在游戏结束时设置。我们需要更多的测试来驱动这一行为。

  3. 我们将使用一个简单的错误报告机制——在 GuessResult 类中添加一个新字段 isError

这导致了一些自动化重构,为 Game 类的构造函数添加了第四个参数。然后,我们可以添加代码使测试通过:

public GuessResult assess(Player player, String guess) {
    var game = gameRepository.fetchForPlayer(player);

    if (game.isGameOver()) {
        return GuessResult.ERROR;
    }

    Score score = game.attempt(guess);
    if (score.allCorrect()) {
        return new GuessResult(score, true, false);
    }

    gameRepository.update(game);
    return new GuessResult(score,
        game.hasNoRemainingGuesses(), false);
}

这里的设计决策是,一旦我们获取了 Game 对象,就检查游戏是否之前被标记为结束。如果是,我们报告错误并结束。这种方法简单且粗糙,但足以满足我们的需求。我们还添加了一个静态常量 GuessResult.ERROR 以提高可读性:

public static final GuessResult ERROR
    = new GuessResult(null, true, true);

这个设计决策的一个后果是,每当 Game.isGameOver 字段变为 true 时,我们必须更新 GameRepository。以下是其中一个测试的示例:

@Test
void recordsGameOverOnCorrectGuess() {
    givenGameInRepository(Game.create(PLAYER, CORRECT_WORD));
    wordz.assess(PLAYER, CORRECT_WORD);
    Game game = getUpdatedGameInRepository();
    assertThat(game.isGameOver()).isTrue();
}

以下是添加记录逻辑的生产代码:

public GuessResult assess(Player player, String guess) {
    var game = gameRepository.fetchForPlayer(player);
    if (game.isGameOver()) {
        return GuessResult.ERROR;
    }
    Score score = game.attempt(guess);
    if (score.allCorrect()) {
        game.end();
        gameRepository.update(game);
        return new GuessResult(score, true, false);
    }
    gameRepository.update(game);
    return new GuessResult(score, game.hasNoRemainingGuesses(), false);
}

我们还需要另一个测试来驱动当猜测次数用尽时记录游戏结束的逻辑。这将导致生产代码的更改。这些更改可以在本章开头提供的 GitHub 链接中找到,它们与之前的更改非常相似。

最后,让我们回顾一下我们的设计,看看是否还能进一步改进。

回顾我们的设计

我们在编写代码时一直在进行小的、战术性的重构步骤,这始终是一个好主意。就像园艺一样,如果我们在杂草生长之前将其拔除,花园就会更容易保持整洁。即便如此,在我们继续之前,值得从整体上审视一下我们的代码和测试的设计。我们可能再也没有机会接触这段代码了,而它上面有我们的名字。让我们把它变成我们引以为豪的东西,并且让我们的同事在未来能够安全、简单地使用它。

我们已经编写的测试使我们有很大的重构空间。它们避免测试具体的实现,而是测试期望的结果。它们还测试更大的代码单元——在这种情况下,是我们六边形架构的领域模型。因此,在不更改任何测试的情况下,可以将我们的 Wordz 类重构为如下形式:

package com.wordz.domain;

public class Wordz {
    private final GameRepository gameRepository;
    private final WordSelection selection;

    public Wordz(GameRepository repository,
                 WordRepository wordRepository,
                 RandomNumbers randomNumbers) {
        this.gameRepository = repository;
        this.selection =
            new WordSelection(wordRepository, randomNumbers);
    }

    public void newGame(Player player) {
        var word = wordSelection.chooseRandomWord();
        gameRepository.create(Game.create(player, word));
    }
}

我们重构后的 assess() 方法现在如下所示:

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

这看起来更简单了。GuessResult 的构造函数代码现在显得特别丑陋。它采用了经典的布尔标志值反模式。我们需要明确不同组合的实际含义,以简化对象的创建。一个有用的方法是再次应用静态构造函数模式:

package com.wordz.domain;

public record GuessResult(
    Score score,
    boolean isGameOver,
    boolean isError
) {
    static final GuessResult ERROR
        = new GuessResult(null, true, true);

    static GuessResult create(Score score,
                              boolean isGameOver) {
        return new GuessResult(score, isGameOver, false);
    }
}

这简化了 assess() 方法,消除了理解最后一个布尔标志的需要:

public GuessResult assess(Player player, String guess) {
    Game game = gameRepository.fetchForPlayer(player);
    if (game.isGameOver()) {
        return GuessResult.ERROR;
    }
    Score score = game.attempt(guess);
    gameRepository.update(game);
    return GuessResult.create(score, game.isGameOver());
}

另一个有助于理解的改进是关于创建 Game 类的新实例。rejectsGuessAfterGameOver() 测试使用四参数构造函数中的布尔标志值来设置测试为游戏结束状态。让我们明确创建游戏结束状态的目标。我们可以将 Game 构造函数设为私有,并增加 end() 方法的可见性,该方法已经用于结束游戏。我们修改后的测试如下所示:

@Test
void rejectsGuessAfterGameOver() {
    var game = Game.create(PLAYER, CORRECT_WORD);
    game.end();
    givenGameInRepository(game);
    GuessResult result = wordz.assess(PLAYER, WRONG_WORD);
    assertThat(result.isError()).isTrue();
}

现在,测试的 “Arrange” 步骤更具描述性。四参数构造函数不再可访问,引导未来的开发使用更安全、更具描述性的静态构造函数方法。这种改进的设计有助于防止未来引入缺陷。

我们在本章中取得了很大进展。经过这些最终的重构改进,我们有了一个易于阅读的游戏核心逻辑描述。它完全由 FIRST 单元测试支持。我们甚至实现了测试执行的代码行数的 100% 覆盖率。这在 IntelliJ 代码覆盖率工具中显示如下:

image 2025 01 12 19 34 35 053
Figure 1. Figure 13.6 – Code coverage report

这就是我们游戏的核心部分完成了。我们可以开始新游戏、玩游戏并结束游戏。游戏可以进一步开发,包括根据猜测单词的速度奖励分数和为玩家设置高分榜等功能。这些功能将使用我们在本章中一直应用的相同技术来添加。