🎲

【Rust】ニムゲームをクリーンアーキテクチャで!

2025/01/17に公開

ニムゲームとは

ルールは以下の通りです。

  1. 二人で行う
  2. いくつかの山にいくつかの石がある
  3. プレーヤーは交互に石を取る ※とれる山は1つのみ
  4. 何個でも取ってOK
  5. 最後の石を取った方が勝利

ニム (英: Nim) は、2人で行うレクリエーション数学ゲーム(組合せゲーム)の一つである。起源は古代中国からあるとされ、16世紀初めの西欧で基本ルールが完成したが、名前については、一般的に1901年にハーバード大学のチャールズ・L・バウトンによって名付けられたとされる

https://ja.wikipedia.org/wiki/ニム

おことわり

現在、クリーンアーキテクチャについての理解は十分ではありません。本稿では、ニムゲームを通じて自分なりに理解を深めるために試みている段階です。そのため、他の参考資料に頼ることなく、あくまで個人的な理解を元にしている点をご留意ください。

コード

早速ですが、以下の実装で正常に動作しました。山は石が3個、4個、5個で固定としています。

use rand::prelude::SliceRandom;
use std::io::{self, Write};

// -----------------------------
// ドメイン層
// -----------------------------

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Player {
    pub name: String,
    pub player_type: PlayerType,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlayerType {
    Human,
    Auto,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NimGame {
    pub piles: Vec<u32>,
    pub current_player: Player,
    pub player_1: Player,
    pub player_2: Player,
}

impl NimGame {
    pub fn new_game(player_1_name: String, player_2_name: String) -> Self {
        let player_1 = Player {
            name: player_1_name,
            player_type: PlayerType::Human,
        };
        let player_2 = Player {
            name: player_2_name,
            player_type: PlayerType::Auto,
        };
        Self {
            piles: vec![3, 4, 5],
            current_player: player_1.clone(),
            player_1,
            player_2,
        }
    }

    pub fn is_game_over(&self) -> bool {
        self.piles.iter().all(|&pile| pile == 0)
    }

    pub fn apply_move(&mut self, pile_index: usize, stones: u32) -> Result<(), String> {
        if pile_index >= self.piles.len() {
            return Err("Invalid pile index".to_string());
        }
        if stones > self.piles[pile_index] {
            return Err("Not enough stones in the pile".to_string());
        }
        self.piles[pile_index] -= stones;
        self.switch_player();
        Ok(())
    }

    fn switch_player(&mut self) {
        if self.current_player.name == self.player_1.name {
            self.current_player = self.player_2.clone();
        } else {
            self.current_player = self.player_1.clone();
        }
    }

    pub fn get_current_player_name(&self) -> &str {
        &self.current_player.name
    }

    pub fn get_previous_player_name(&self) -> &str {
        if self.current_player == self.player_1 {
            &self.player_2.name
        } else {
            &self.player_1.name
        }
    }
}

#[mockall::automock]
pub trait GameRepository {
    fn save(&mut self, game: &NimGame);
    fn load(&self) -> Option<NimGame>;
}

// -----------------------------
// ユースケース層
// -----------------------------

#[mockall::automock]
pub trait OutputPort {
    fn present_game_state(&self, game: &NimGame);
    fn present_error(&self, error: &str);
    fn present_game_over(&self, winner: &str);
    fn prompt_player_name(&self);
    fn prompt_player_move(&self);
    fn invalid_input(&self, message: &str);
}

#[mockall::automock]
pub trait InputPort {
    fn handle_move(&mut self, pile_index: usize, stones: u32) -> Result<(), String>;
    fn validate_move(&self, pile_index: usize, stones: u32) -> Result<(), String>;
    fn handle_auto_move(&mut self) -> Result<(), String>;
    fn start_game(&mut self, player_1_name: String) -> Result<(), String>;
    fn exist_winner(&self) -> bool;
    fn prompt_player_name(&self);
    fn prompt_player_move(&self);
    fn invalid_input(&self, message: &str);
}

pub struct GameInteractor<R: GameRepository, O: OutputPort> {
    game: NimGame,
    repository: R,
    output_port: O,
}

impl<R: GameRepository, O: OutputPort> GameInteractor<R, O> {
    pub fn new(repository: R, output_port: O) -> Self {
        let game = repository
            .load()
            .unwrap_or_else(|| NimGame::new_game("Player 1".to_string(), "AutoPlayer".to_string()));
        Self {
            game,
            repository,
            output_port,
        }
    }

    pub fn start_game(&mut self, player_1_name: String) -> Result<(), String> {
        let player_2_name = "AutoPlayer".to_string();
        let game = NimGame::new_game(player_1_name, player_2_name);

        self.game = game;
        self.output_port.present_game_state(&self.game);
        self.repository.save(&self.game);
        Ok(())
    }

    fn handle_player_move(&mut self, pile_index: usize, stones: u32) -> Result<(), String> {
        self.game
            .apply_move(pile_index, stones)
            .inspect_err(|err| {
                self.output_port.present_error(err);
            })?;

        if self.game.is_game_over() {
            let winner = self.game.get_previous_player_name().to_string();
            self.output_port.present_game_over(&winner);
            Err("Game over".to_string())
        } else {
            self.output_port.present_game_state(&self.game);
            self.repository.save(&self.game);
            Ok(())
        }
    }

    fn auto_play(&mut self) {
        let nim_sum = self.game.piles.iter().fold(0, |acc, &pile| acc ^ pile);

        for (index, &pile) in self.game.piles.iter().enumerate() {
            let target = pile ^ nim_sum;
            if target < pile {
                let stones_to_remove = pile - target;
                self.game.apply_move(index, stones_to_remove).unwrap();
                self.output_port.present_game_state(&self.game);
                return;
            }
        }

        self.auto_play_random();
    }

    fn auto_play_random(&mut self) {
        let valid_moves = self
            .game
            .piles
            .iter()
            .enumerate()
            .flat_map(|(index, &stones)| (1..=stones).map(move |n| (index, n)))
            .collect::<Vec<_>>();

        if let Some(&(pile_index, stones)) = valid_moves.choose(&mut rand::thread_rng()) {
            self.game.apply_move(pile_index, stones).unwrap();
            self.output_port.present_game_state(&self.game);
        }
    }
}

impl<R: GameRepository, O: OutputPort> InputPort for GameInteractor<R, O> {
    fn handle_move(&mut self, pile_index: usize, stones: u32) -> Result<(), String> {
        match self.game.current_player.player_type {
            PlayerType::Human => self.handle_player_move(pile_index, stones),
            PlayerType::Auto => self.handle_auto_move(),
        }
    }

    fn validate_move(&self, pile_index: usize, stones: u32) -> Result<(), String> {
        if pile_index >= self.game.piles.len() {
            return Err("Invalid pile index".to_string());
        }
        if stones > self.game.piles[pile_index] {
            return Err("Not enough stones in the pile".to_string());
        }
        if stones == 0 {
            return Err("Cannot remove 0 stones".to_string());
        }

        Ok(())
    }

    fn handle_auto_move(&mut self) -> Result<(), String> {
        self.auto_play();

        if self.game.is_game_over() {
            let winner = self.game.get_previous_player_name().to_string();
            self.output_port.present_game_over(&winner);
            Err("Game over".to_string())
        } else {
            self.repository.save(&self.game);
            Ok(())
        }
    }

    fn start_game(&mut self, player_1_name: String) -> Result<(), String> {
        let player_2_name = "AutoPlayer".to_string();
        let game = NimGame::new_game(player_1_name, player_2_name);

        self.game = game;
        self.output_port.present_game_state(&self.game);
        self.repository.save(&self.game);
        Ok(())
    }

    fn exist_winner(&self) -> bool {
        self.game.is_game_over()
    }

    fn prompt_player_name(&self) {
        self.output_port.prompt_player_name();
    }

    fn prompt_player_move(&self) {
        self.output_port.prompt_player_move();
    }

    fn invalid_input(&self, message: &str) {
        self.output_port.invalid_input(message);
    }
}

// -----------------------------
// インフラストラクチャー層
// -----------------------------

pub struct InMemoryGameRepository {
    game_state: Option<NimGame>,
}

impl Default for InMemoryGameRepository {
    fn default() -> Self {
        Self::new()
    }
}

impl InMemoryGameRepository {
    pub fn new() -> Self {
        Self { game_state: None }
    }
}

impl GameRepository for InMemoryGameRepository {
    fn save(&mut self, game: &NimGame) {
        let mut state = self.game_state.clone().unwrap_or_else(|| NimGame {
            piles: vec![3, 4, 5],
            current_player: game.current_player.clone(),
            player_1: game.player_1.clone(),
            player_2: game.player_2.clone(),
        });

        state.piles = game.piles.clone();
        state.current_player = game.current_player.clone();
        state.player_1 = game.player_1.clone();
        state.player_2 = game.player_2.clone();

        self.game_state = Some(state);
    }

    fn load(&self) -> Option<NimGame> {
        self.game_state.clone()
    }
}

// -----------------------------
// プレゼンテーション層
// -----------------------------

pub struct CliPresenter;

impl OutputPort for CliPresenter {
    fn present_game_state(&self, game: &NimGame) {
        println!("Current game state: {:?}", game.piles);
        println!("Player {}'s turn", game.get_current_player_name());
    }

    fn present_error(&self, error: &str) {
        println!("Error: {}", error);
    }

    fn present_game_over(&self, winner: &str) {
        println!("Game over! {} wins! Thanks for playing.", winner);
    }

    fn prompt_player_name(&self) {
        println!("Enter your name:");
    }

    fn prompt_player_move(&self) {
        println!("Enter pile index and stones to remove (e.g., `1 2`): ");
    }

    fn invalid_input(&self, message: &str) {
        println!("Invalid input: {}", message);
    }
}

pub struct CliController<I: InputPort> {
    input_port: I,
}

impl<I: InputPort> CliController<I> {
    pub fn new(input_port: I) -> Self {
        Self { input_port }
    }

    pub fn run(&mut self) {
        self.input_port.prompt_player_name();

        let mut input = String::new();
        io::stdin().read_line(&mut input).unwrap();
        let player_1_name = input.trim().to_string();

        if let Err(e) = self.input_port.start_game(player_1_name) {
            self.input_port.invalid_input(&e);
            return;
        }

        loop {
            self.input_port.prompt_player_move();

            let mut input = String::new();
            io::stdout().flush().unwrap();
            io::stdin().read_line(&mut input).unwrap();

            let parts: Vec<&str> = input.split_whitespace().collect();
            if parts.len() != 2 {
                self.input_port
                    .invalid_input("Expected two values: pile index and stone count.");
                continue;
            }

            let pile_index = match parts[0].parse() {
                Ok(n) => n,
                Err(_) => {
                    self.input_port.invalid_input("Invalid pile index.");
                    continue;
                }
            };

            let stones = match parts[1].parse() {
                Ok(n) => n,
                Err(_) => {
                    self.input_port.invalid_input("Invalid stone count.");
                    continue;
                }
            };

            if let Err(e) = self.input_port.validate_move(pile_index, stones) {
                self.input_port.invalid_input(&e);
                continue;
            }

            if self.input_port.handle_move(pile_index, stones).is_err() {
                break;
            }

            if self.input_port.exist_winner() {
                break;
            }

            if self.input_port.handle_auto_move().is_err() {
                break;
            }

            if self.input_port.exist_winner() {
                break;
            }
        }
    }
}

fn main() {
    let repository = InMemoryGameRepository::new();
    let output_port = CliPresenter;
    let interactor = GameInteractor::new(repository, output_port);

    CliController::new(interactor).run();
}

#[cfg(test)]
mod domain_tests {
    use super::*;

    #[test]
    fn test_player_creation() {
        let player = Player {
            name: String::from("Player 1"),
            player_type: PlayerType::Human,
        };
        assert_eq!(player.name, "Player 1");
        assert_eq!(player.player_type, PlayerType::Human);
    }

    #[test]
    fn test_nim_game_creation() {
        let game = NimGame::new_game("Player 1".to_string(), "AutoPlayer".to_string());
        assert_eq!(game.piles, vec![3, 4, 5]);
        assert_eq!(game.player_1.name, "Player 1");
        assert_eq!(game.player_2.name, "AutoPlayer");
        assert_eq!(game.current_player.name, "Player 1");
    }

    #[test]
    fn test_apply_move() {
        let mut game = NimGame::new_game("Player 1".to_string(), "AutoPlayer".to_string());
        let result = game.apply_move(0, 2);
        assert!(result.is_ok());
        assert_eq!(game.piles[0], 1); // 3 - 2 = 1
        assert_eq!(game.current_player.name, "AutoPlayer");
    }

    #[test]
    fn test_invalid_move() {
        let mut game = NimGame::new_game("Player 1".to_string(), "AutoPlayer".to_string());
        let result = game.apply_move(0, 5); // Invalid move, as there are only 3 stones
        assert!(result.is_err());
    }

    #[test]
    fn test_is_game_over() {
        let mut game = NimGame::new_game("Player 1".to_string(), "AutoPlayer".to_string());
        game.piles = vec![0, 0, 0]; // Set piles to be empty
        assert!(game.is_game_over());
    }
}

#[cfg(test)]
mod use_case_tests {
    use super::*;
    use mockall::predicate;

    #[test]
    fn test_invalid_move() {
        let mut repository_mock = MockGameRepository::new();
        let mut output_mock = MockOutputPort::new();

        repository_mock.expect_load().returning(|| {
            Some(NimGame::new_game(
                "Player 1".to_string(),
                "AutoPlayer".to_string(),
            ))
        });

        output_mock
            .expect_present_error()
            .with(predicate::eq("Not enough stones in the pile".to_string()))
            .times(1)
            .returning(|_| {});

        let mut interactor = GameInteractor::new(repository_mock, output_mock);

        // 不正な手(山から取りすぎ)
        let result = interactor.handle_player_move(0, 5);
        assert!(result.is_err()); // エラーが発生
    }
}

感想

コントローラが少し肥えてしまった印象です。本来、データの受け渡しのみに専念すべきだと思いますが、初歩的なバリデーションチェックや、ゲームループをここに書いてしまったので後々の修正が面倒で。。。

このコードは約半日かけて書きましたが、それでも単なる数理ゲームにしては少し重たく感じます。今後、全体の微調整や設計の見直し、単体テストの強化を行うと、さらに時間がかかることが予想されます。

余談

chatGPT先生の力をお借りして、「短くして」と頼んだら以下の通りリファクタリングしてくれました。400行ほどのコードが40行に収まり、これはこれで好きです。

use rand::Rng;

fn main() {
    let mut g: (Vec<usize>, [String; 2], usize) =
        (vec![3, 4, 5], ["You".to_string(), "AI".to_string()], 0);
    while g.0.iter().any(|&x| x > 0) {
        println!("Piles: {:?}", g.0);
        println!("{}'s turn", g.1[g.2]);
        if g.2 == 0 {
            let mut i = String::new();
            std::io::stdin().read_line(&mut i).unwrap();
            let mut p = i.split_whitespace().map(|x| x.parse::<usize>());
            if let (Some(Ok(i)), Some(Ok(s))) = (p.next(), p.next()) {
                g.0[i] -= s;
                g.2 ^= 1;
            }
        } else {
            let n = g.0.iter().fold(0, |a, &x| a ^ x);
            if let Some(i) =
                g.0.iter()
                    .enumerate()
                    .find(|&(_, &x)| (x ^ n) < x)
                    .map(|(i, _)| i)
            {
                g.0[i] -= g.0[i] ^ n;
                g.2 ^= 1;
            } else {
                let mut r = rand::thread_rng();
                let (i, s) =
                    g.0.iter()
                        .enumerate()
                        .filter(|&(_, &x)| x > 0)
                        .map(|(i, &x)| (i, r.gen_range(1..=x)))
                        .next()
                        .unwrap();
                g.0[i] -= s;
                g.2 ^= 1;
            }
        }
    }
    println!("Game over! {} wins!", g.1[1 - g.2]);
}

Discussion