🎲
【Rust】ニムゲームをクリーンアーキテクチャで!
ニムゲームとは
ルールは以下の通りです。
- 二人で行う
- いくつかの山にいくつかの石がある
- プレーヤーは交互に石を取る ※とれる山は1つのみ
- 何個でも取ってOK
- 最後の石を取った方が勝利
ニム (英: Nim) は、2人で行うレクリエーション数学ゲーム(組合せゲーム)の一つである。起源は古代中国からあるとされ、16世紀初めの西欧で基本ルールが完成したが、名前については、一般的に1901年にハーバード大学のチャールズ・L・バウトンによって名付けられたとされる
おことわり
現在、クリーンアーキテクチャについての理解は十分ではありません。本稿では、ニムゲームを通じて自分なりに理解を深めるために試みている段階です。そのため、他の参考資料に頼ることなく、あくまで個人的な理解を元にしている点をご留意ください。
コード
早速ですが、以下の実装で正常に動作しました。山は石が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