phihung commited on
Commit
ec04e24
·
1 Parent(s): 6ca8af4
.gitignore CHANGED
@@ -1,4 +1,5 @@
1
  .venv
2
  .sesskey
 
3
  target/
4
  __pycache__/
 
1
  .venv
2
  .sesskey
3
+ *.so
4
  target/
5
  __pycache__/
Cargo.toml CHANGED
@@ -4,10 +4,9 @@ version = "0.1.0"
4
  edition = "2021"
5
 
6
  [lib]
7
- name = "othello"
8
 
9
  crate-type = ["cdylib", "rlib"]
10
- # crate-type = ["cdylib"]
11
 
12
  [dependencies]
13
  pyo3 = { version = "0.22.2", features = ["extension-module"] }
 
4
  edition = "2021"
5
 
6
  [lib]
7
+ name = "_othello"
8
 
9
  crate-type = ["cdylib", "rlib"]
 
10
 
11
  [dependencies]
12
  pyo3 = { version = "0.22.2", features = ["extension-module"] }
README.md CHANGED
@@ -12,9 +12,14 @@ license: apache-2.0
12
 
13
  Othello Game implemented in Rust and python.
14
 
 
 
 
 
 
15
  ## Dev
16
 
17
  ```bash
18
  uv sync
19
- python ui.py
20
  ```
 
12
 
13
  Othello Game implemented in Rust and python.
14
 
15
+ ```bash
16
+ pip install .
17
+ othello-ui
18
+ ```
19
+
20
  ## Dev
21
 
22
  ```bash
23
  uv sync
24
+ python python/othello/ui.py
25
  ```
pyproject.toml CHANGED
@@ -17,5 +17,15 @@ managed = true
17
  dev-dependencies = [
18
  "ruff>=0.6.2",
19
  "pip>=24.2",
20
- "maturin>=1,<2"
 
21
  ]
 
 
 
 
 
 
 
 
 
 
17
  dev-dependencies = [
18
  "ruff>=0.6.2",
19
  "pip>=24.2",
20
+ "maturin>=1,<2",
21
+ "tqdm>=4.66.5",
22
  ]
23
+
24
+ [tool.maturin]
25
+ python-source = "python"
26
+ module-name = "othello._othello"
27
+ bindings = 'pyo3'
28
+ features = ["pyo3/extension-module"]
29
+
30
+ [project.scripts]
31
+ othello-ui = "othello.ui:main"
python/benchmark.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from othello import Game, AlphaBetaBot
2
+ import tqdm
3
+
4
+
5
+ def run_match(bot1, bot2):
6
+ game = Game.default()
7
+ while not game.state.ended:
8
+ bot = bot1 if game.state.player == "B" else bot2
9
+ pos = bot.find_move(game)
10
+ if pos >= 0:
11
+ game.make_move(pos)
12
+ else:
13
+ game.pass_move()
14
+ w, b = game.state.white_score, game.state.black_score
15
+ return 1 if b > w else 2 if w > b else 0
16
+
17
+
18
+ def run_matches(bot1, bot2, n):
19
+ cnts = [0, 0, 0]
20
+ for _ in tqdm.tqdm(range(n)):
21
+ result = run_match(bot1, bot2)
22
+ cnts[result] += 1
23
+ return cnts
24
+
25
+
26
+ if __name__ == "__main__":
27
+ for i in range(2, 10):
28
+ bot1 = AlphaBetaBot(i)
29
+ bot2 = AlphaBetaBot(i + 1)
30
+ print(f"AlphaBetaBot({bot1.depth}) vs AlphaBetaBot({bot2.depth})")
31
+ draw, win, lost = run_matches(bot1, bot2, 10)
32
+ print(f"Win: {win} | Draw: {draw} | Lost: {lost}")
33
+ print("----")
34
+
35
+ print(f"AlphaBetaBot({bot2.depth}) vs AlphaBetaBot({bot1.depth})")
36
+ draw, win, lost = run_matches(bot2, bot1, 10)
37
+ print(f"Win: {win} | Draw: {draw} | Lost: {lost}")
38
+ print("----")
python/othello/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from othello._othello import Game, AlphaBetaBot
2
+
3
+ __all__ = ["Game", "AlphaBetaBot"]
ui.py → python/othello/ui.py RENAMED
@@ -4,7 +4,6 @@ from fasthtml.common import (
4
  Div,
5
  serve,
6
  Script,
7
- Span,
8
  cookie,
9
  A,
10
  RedirectResponse,
@@ -16,7 +15,6 @@ app, rt = fast_app(
16
  hdrs=[Script(src="https://cdn.tailwindcss.com")],
17
  pico=False,
18
  ws_hdr=True,
19
- live=True,
20
  )
21
 
22
  games = {}
@@ -88,7 +86,7 @@ def make_status_bar(state):
88
  f"Black: {state.black_score}",
89
  cls="bg-black text-white w-32 h-12 text-center content-center",
90
  ),
91
- Div(Span(status, id="status", hx_swap_oob="true"), cls="content-center"),
92
  Div(
93
  f"White: {state.white_score}",
94
  cls="bg-white text-black w-32 h-12 text-center content-center",
@@ -110,7 +108,7 @@ def get_status(state):
110
  status = "Game draw!"
111
  elif state.player == "W":
112
  status = "White turn"
113
- return Span(status, id="status", hx_swap_oob="true")
114
 
115
 
116
  @app.ws("/wscon")
@@ -147,4 +145,9 @@ async def ws(uuid: str, pos: int, send):
147
  break
148
 
149
 
150
- serve()
 
 
 
 
 
 
4
  Div,
5
  serve,
6
  Script,
 
7
  cookie,
8
  A,
9
  RedirectResponse,
 
15
  hdrs=[Script(src="https://cdn.tailwindcss.com")],
16
  pico=False,
17
  ws_hdr=True,
 
18
  )
19
 
20
  games = {}
 
86
  f"Black: {state.black_score}",
87
  cls="bg-black text-white w-32 h-12 text-center content-center",
88
  ),
89
+ Div(status, cls="content-center"),
90
  Div(
91
  f"White: {state.white_score}",
92
  cls="bg-white text-black w-32 h-12 text-center content-center",
 
108
  status = "Game draw!"
109
  elif state.player == "W":
110
  status = "White turn"
111
+ return status
112
 
113
 
114
  @app.ws("/wscon")
 
145
  break
146
 
147
 
148
+ def main():
149
+ serve("othello.ui", reload=False)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ serve()
src/ai.rs CHANGED
@@ -1,21 +1,26 @@
1
- use crate::{bits::BitBoard, consts::ALPHA_BETA_SCORES, game::Game};
2
  use pyo3::prelude::*;
3
  use rand::prelude::*;
4
 
5
- pub trait AI {
6
- fn find_move(&self, board: &Game) -> usize;
 
 
7
  }
8
 
9
  #[pyclass]
10
  pub struct AlphaBetaBot {
 
11
  depth: usize,
12
  }
13
 
14
- impl AI for AlphaBetaBot {
15
- fn find_move(&self, board: &Game) -> usize {
 
16
  let (_, move_) = self.do_search(
 
17
  &mut rand::thread_rng(),
18
- &board.board,
19
  self.depth,
20
  -i32::MAX,
21
  i32::MAX,
@@ -32,7 +37,7 @@ impl AlphaBetaBot {
32
  }
33
 
34
  #[pyo3(name = "find_move")]
35
- fn run(&self, board: &Game) -> usize {
36
  self.find_move(board)
37
  }
38
  }
@@ -40,24 +45,25 @@ impl AlphaBetaBot {
40
  impl AlphaBetaBot {
41
  fn do_search(
42
  &self,
 
43
  rng: &mut ThreadRng,
44
  board: &BitBoard,
45
  depth: usize,
46
  mut alpha: i32,
47
  beta: i32,
48
- ) -> (i32, usize) {
49
  if depth == 0 {
50
- return (self.evaluate(board), 0);
51
  }
52
  let mut moves = board.available_moves_list();
53
  if moves.is_empty() {
54
  let board = board.pass_move();
55
  let moves = board.available_moves();
56
  if moves == 0 {
57
- return (self.final_evaluate(&board), 0);
58
  }
59
- let (score, _) = self.do_search(rng, &board, depth - 1, -beta, -alpha);
60
- return (-score, 0);
61
  }
62
  moves.shuffle(rng);
63
  let mut best_move = moves[0];
@@ -66,6 +72,7 @@ impl AlphaBetaBot {
66
  break;
67
  }
68
  let (score, _) = self.do_search(
 
69
  rng,
70
  &board.make_move(move_).unwrap(),
71
  depth - 1,
@@ -77,23 +84,31 @@ impl AlphaBetaBot {
77
  best_move = move_
78
  }
79
  }
80
- (alpha, best_move)
81
  }
 
 
 
 
 
 
82
 
 
83
  fn evaluate(&self, board: &BitBoard) -> i32 {
84
- // let (sc1, sc2) = board.count();
85
- // if sc1 + sc2 > 54 {
86
- // return 5 * (sc1 - sc2);
87
- // }
88
  let scorer = |mask: u64| {
89
  (0..64)
90
  .filter(|i| mask >> i & 1 == 1)
91
- .map(|i| ALPHA_BETA_SCORES[i])
92
  .sum::<i32>()
93
  };
94
  let n_moves0 = board.available_moves().count_ones() as i32;
95
  let n_moves1 = board.pass_move().available_moves().count_ones() as i32;
96
- scorer(board.0) - scorer(board.1) + 10 * n_moves0 - 10 * n_moves1
 
 
 
 
 
97
  }
98
 
99
  fn final_evaluate(&self, board: &BitBoard) -> i32 {
@@ -106,39 +121,41 @@ impl AlphaBetaBot {
106
  0
107
  }
108
  }
 
 
 
 
 
 
 
 
 
 
 
 
109
  }
110
 
111
  #[cfg(test)]
112
  mod tests {
113
- use crate::{bits::BitBoard, game::Game};
114
 
115
- use super::{AlphaBetaBot, AI};
116
 
117
  #[test]
118
  fn test() {
119
  let mut b = Game::default();
120
- let ai = [AlphaBetaBot { depth: 5 }, AlphaBetaBot { depth: 6 }];
121
 
122
- for i in 0..80 {
123
- if b.available_moves().is_empty() {
 
124
  b.pass_move();
125
  println!("PASS");
126
  } else {
127
- let move_ = ai[i % 2].find_move(&b);
128
- let state = b.make_move(move_);
129
  println!("{}\n---\n", b.__repr__());
130
- if state.ended {
131
- break;
132
- }
133
  }
134
  }
135
- }
136
-
137
- #[test]
138
- fn test_evaluate() {
139
- let ai = AlphaBetaBot { depth: 3 };
140
- let b = BitBoard(3, 12);
141
- println!("{:?}", b);
142
- assert_eq!(ai.evaluate(&BitBoard(3, 12)), 245);
143
  }
144
  }
 
1
+ use crate::{bits::BitBoard, game::Game};
2
  use pyo3::prelude::*;
3
  use rand::prelude::*;
4
 
5
+ pub trait Bot {
6
+ /// Find the next move (an int between 0 and 63)
7
+ /// Return -1 if there is no legal move
8
+ fn find_move(&self, board: &Game) -> i32;
9
  }
10
 
11
  #[pyclass]
12
  pub struct AlphaBetaBot {
13
+ #[pyo3(get)]
14
  depth: usize,
15
  }
16
 
17
+ impl Bot for AlphaBetaBot {
18
+ fn find_move(&self, g: &Game) -> i32 {
19
+ let count = (g.board.0 + g.board.1).count_ones() as usize;
20
  let (_, move_) = self.do_search(
21
+ &AlphaBetaEval { count },
22
  &mut rand::thread_rng(),
23
+ &g.board,
24
  self.depth,
25
  -i32::MAX,
26
  i32::MAX,
 
37
  }
38
 
39
  #[pyo3(name = "find_move")]
40
+ fn run(&self, board: &Game) -> i32 {
41
  self.find_move(board)
42
  }
43
  }
 
45
  impl AlphaBetaBot {
46
  fn do_search(
47
  &self,
48
+ eval: &AlphaBetaEval,
49
  rng: &mut ThreadRng,
50
  board: &BitBoard,
51
  depth: usize,
52
  mut alpha: i32,
53
  beta: i32,
54
+ ) -> (i32, i32) {
55
  if depth == 0 {
56
+ return (eval.evaluate(board), -1);
57
  }
58
  let mut moves = board.available_moves_list();
59
  if moves.is_empty() {
60
  let board = board.pass_move();
61
  let moves = board.available_moves();
62
  if moves == 0 {
63
+ return (eval.final_evaluate(&board), -1);
64
  }
65
+ let (score, _) = self.do_search(eval, rng, &board, depth - 1, -beta, -alpha);
66
+ return (-score, -1);
67
  }
68
  moves.shuffle(rng);
69
  let mut best_move = moves[0];
 
72
  break;
73
  }
74
  let (score, _) = self.do_search(
75
+ eval,
76
  rng,
77
  &board.make_move(move_).unwrap(),
78
  depth - 1,
 
84
  best_move = move_
85
  }
86
  }
87
+ (alpha, best_move as i32)
88
  }
89
+ }
90
+
91
+ struct AlphaBetaEval {
92
+ // Number of pieces at the beginning of search
93
+ count: usize,
94
+ }
95
 
96
+ impl AlphaBetaEval {
97
  fn evaluate(&self, board: &BitBoard) -> i32 {
 
 
 
 
98
  let scorer = |mask: u64| {
99
  (0..64)
100
  .filter(|i| mask >> i & 1 == 1)
101
+ .map(|i| Self::POSITION_SCORES[i])
102
  .sum::<i32>()
103
  };
104
  let n_moves0 = board.available_moves().count_ones() as i32;
105
  let n_moves1 = board.pass_move().available_moves().count_ones() as i32;
106
+ let mut score = scorer(board.0) - scorer(board.1) + 10 * n_moves0 - 10 * n_moves1;
107
+ if self.count > 54 {
108
+ let (cnt0, cnt1) = board.count();
109
+ score += 2 * (self.count as i32 - 54) * (cnt0 - cnt1) as i32;
110
+ }
111
+ score
112
  }
113
 
114
  fn final_evaluate(&self, board: &BitBoard) -> i32 {
 
121
  0
122
  }
123
  }
124
+
125
+ #[rustfmt::skip]
126
+ const POSITION_SCORES: [i32; 64] = [
127
+ 300, -40, 20, 5, 5, 20, -40, 300,
128
+ -40, -80, -5, -5, -5, -5, -80, -40,
129
+ 20, -5, 15, 1, 1, 15, -5, 20,
130
+ 5, -5, 1, 1, 1, 1, -5, 5,
131
+ 5, -5, 1, 1, 1, 1, -5, 5,
132
+ 20, -5, 15, 1, 1, 15, -5, 20,
133
+ -40, -80, -5, -5, -5, -5, -80, -40,
134
+ 300, -40, 20, 5, 5, 20, -40, 300,
135
+ ];
136
  }
137
 
138
  #[cfg(test)]
139
  mod tests {
140
+ use crate::game::Game;
141
 
142
+ use super::{AlphaBetaBot, Bot};
143
 
144
  #[test]
145
  fn test() {
146
  let mut b = Game::default();
147
+ let ai = [AlphaBetaBot { depth: 6 }, AlphaBetaBot { depth: 3 }];
148
 
149
+ while !b.state.ended {
150
+ let pos = ai[b.current_player].find_move(&b);
151
+ if pos < 0 {
152
  b.pass_move();
153
  println!("PASS");
154
  } else {
155
+ b.make_move(pos as usize);
 
156
  println!("{}\n---\n", b.__repr__());
 
 
 
157
  }
158
  }
159
+ assert!(b.state.black_score > b.state.white_score)
 
 
 
 
 
 
 
160
  }
161
  }
src/consts.rs CHANGED
@@ -1,17 +1,5 @@
1
  #![allow(clippy::unreadable_literal)]
2
 
3
- #[rustfmt::skip]
4
- pub static ALPHA_BETA_SCORES: [i32; 64] = [
5
- 300, -40, 20, 5, 5, 20, -40, 300,
6
- -40, -80, -5, -5, -5, -5, -80, -40,
7
- 20, -5, 15, 1, 1, 15, -5, 20,
8
- 5, -5, 1, 1, 1, 1, -5, 5,
9
- 5, -5, 1, 1, 1, 1, -5, 5,
10
- 20, -5, 15, 1, 1, 15, -5, 20,
11
- -40, -80, -5, -5, -5, -5, -80, -40,
12
- 300, -40, 20, 5, 5, 20, -40, 300,
13
- ];
14
-
15
  pub static MASK: [[[u64; 2]; 4]; 64] = [
16
  [
17
  [252, 126],
 
1
  #![allow(clippy::unreadable_literal)]
2
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  pub static MASK: [[[u64; 2]; 4]; 64] = [
4
  [
5
  [252, 126],
src/lib.rs CHANGED
@@ -9,7 +9,7 @@ pub mod consts;
9
  pub mod game;
10
 
11
  #[pymodule]
12
- fn othello(m: &Bound<'_, PyModule>) -> PyResult<()> {
13
  m.add_class::<BitBoard>()?;
14
  m.add_class::<Game>()?;
15
  m.add_class::<AlphaBetaBot>()?;
 
9
  pub mod game;
10
 
11
  #[pymodule]
12
+ fn _othello(m: &Bound<'_, PyModule>) -> PyResult<()> {
13
  m.add_class::<BitBoard>()?;
14
  m.add_class::<Game>()?;
15
  m.add_class::<AlphaBetaBot>()?;
uv.lock CHANGED
@@ -201,6 +201,7 @@ dev = [
201
  { name = "maturin" },
202
  { name = "pip" },
203
  { name = "ruff" },
 
204
  ]
205
 
206
  [package.metadata]
@@ -211,6 +212,7 @@ dev = [
211
  { name = "maturin", specifier = ">=1,<2" },
212
  { name = "pip", specifier = ">=24.2" },
213
  { name = "ruff", specifier = ">=0.6.2" },
 
214
  ]
215
 
216
  [[package]]
@@ -393,6 +395,18 @@ wheels = [
393
  { url = "https://files.pythonhosted.org/packages/c1/60/d976da9998e4f4a99e297cda09d61ce305919ea94cbeeb476dba4fece098/starlette-0.38.2-py3-none-any.whl", hash = "sha256:4ec6a59df6bbafdab5f567754481657f7ed90dc9d69b0c9ff017907dd54faeff", size = 72020 },
394
  ]
395
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  [[package]]
397
  name = "uvicorn"
398
  version = "0.30.6"
 
201
  { name = "maturin" },
202
  { name = "pip" },
203
  { name = "ruff" },
204
+ { name = "tqdm" },
205
  ]
206
 
207
  [package.metadata]
 
212
  { name = "maturin", specifier = ">=1,<2" },
213
  { name = "pip", specifier = ">=24.2" },
214
  { name = "ruff", specifier = ">=0.6.2" },
215
+ { name = "tqdm", specifier = ">=4.66.5" },
216
  ]
217
 
218
  [[package]]
 
395
  { url = "https://files.pythonhosted.org/packages/c1/60/d976da9998e4f4a99e297cda09d61ce305919ea94cbeeb476dba4fece098/starlette-0.38.2-py3-none-any.whl", hash = "sha256:4ec6a59df6bbafdab5f567754481657f7ed90dc9d69b0c9ff017907dd54faeff", size = 72020 },
396
  ]
397
 
398
+ [[package]]
399
+ name = "tqdm"
400
+ version = "4.66.5"
401
+ source = { registry = "https://pypi.org/simple" }
402
+ dependencies = [
403
+ { name = "colorama", marker = "platform_system == 'Windows'" },
404
+ ]
405
+ sdist = { url = "https://files.pythonhosted.org/packages/58/83/6ba9844a41128c62e810fddddd72473201f3eacde02046066142a2d96cc5/tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad", size = 169504 }
406
+ wheels = [
407
+ { url = "https://files.pythonhosted.org/packages/48/5d/acf5905c36149bbaec41ccf7f2b68814647347b72075ac0b1fe3022fdc73/tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd", size = 78351 },
408
+ ]
409
+
410
  [[package]]
411
  name = "uvicorn"
412
  version = "0.30.6"