Recreate a game from SGF

This commit is contained in:
Savanni D'Gerinel 2023-08-11 12:23:53 -04:00
parent f28a64d9a6
commit e630c062c2
5 changed files with 139 additions and 11 deletions

1
Cargo.lock generated
View File

@ -2148,6 +2148,7 @@ name = "sgf"
version = "0.1.0"
dependencies = [
"chrono",
"cool_asserts",
"nom",
"serde",
"thiserror",

View File

@ -7,8 +7,7 @@
;W[fd]
;B[qf]
;W[qh]
(;B[pf]
)(;B[of]
(;B[of]
;W[nd]
;B[mf]
;W[pk]
@ -221,4 +220,5 @@
;B[ai]
;W[qg]
;B[pf]
)(;B[pf]
))

View File

@ -1,3 +1,5 @@
use sgf::{go::Game, parse_sgf};
use crate::{BoardError, Color, Size};
use std::collections::HashSet;
@ -77,6 +79,41 @@ pub struct Coordinate {
pub row: u8,
}
impl Coordinate {
fn from_sgf(s: &str) -> Self {
fn parse(s: char) -> u8 {
match s {
'a' => 0,
'b' => 1,
'c' => 2,
'd' => 3,
'e' => 4,
'f' => 5,
'g' => 6,
'h' => 7,
'i' => 8,
'j' => 9,
'k' => 10,
'l' => 11,
'm' => 12,
'n' => 13,
'o' => 14,
'p' => 15,
'q' => 16,
'r' => 17,
's' => 18,
_ => panic!("invalid character in the SGF coordinates"),
}
}
let s = s.chars().collect::<Vec<char>>();
Coordinate {
column: parse(s[0]),
row: parse(s[1]),
}
}
}
impl Board {
pub fn place_stone(mut self, coordinate: Coordinate, color: Color) -> Result<Self, BoardError> {
if let Some(_) = self.stone(&coordinate) {
@ -123,17 +160,17 @@ impl Board {
.map(|g| g.color)
}
pub fn group(&self, coordinate: &Coordinate) -> Option<&Group> {
fn group(&self, coordinate: &Coordinate) -> Option<&Group> {
self.groups
.iter()
.find(|g| g.coordinates.contains(coordinate))
}
pub fn remove_group(&mut self, group: &Group) {
fn remove_group(&mut self, group: &Group) {
self.groups.retain(|g| g != group);
}
pub fn adjacent_groups(&self, group: &Group) -> Vec<Group> {
fn adjacent_groups(&self, group: &Group) -> Vec<Group> {
let adjacent_spaces = self.group_halo(group).into_iter();
let mut grps: Vec<Group> = Vec::new();
@ -153,7 +190,7 @@ impl Board {
grps
}
pub fn group_halo(&self, group: &Group) -> HashSet<Coordinate> {
fn group_halo(&self, group: &Group) -> HashSet<Coordinate> {
group
.coordinates
.iter()
@ -162,14 +199,14 @@ impl Board {
.collect::<HashSet<Coordinate>>()
}
pub fn liberties(&self, group: &Group) -> usize {
fn liberties(&self, group: &Group) -> usize {
self.group_halo(group)
.into_iter()
.filter(|c| self.stone(&c) == None)
.count()
}
pub fn adjacencies(&self, coordinate: &Coordinate) -> Vec<Coordinate> {
fn adjacencies(&self, coordinate: &Coordinate) -> Vec<Coordinate> {
let mut v = Vec::new();
if coordinate.column > 0 {
v.push(Coordinate {
@ -194,7 +231,7 @@ impl Board {
v.into_iter().filter(|c| self.within_board(c)).collect()
}
pub fn within_board(&self, coordinate: &Coordinate) -> bool {
fn within_board(&self, coordinate: &Coordinate) -> bool {
coordinate.column < self.size.width && coordinate.row < self.size.height
}
}
@ -211,9 +248,37 @@ impl Group {
}
}
impl TryFrom<&Game> for Board {
type Error = BoardError;
fn try_from(record: &Game) -> Result<Self, Self::Error> {
let mut board = Board::new();
let mut root = Some(&record.root);
while let Some(node) = root {
match (node.find_prop("B"), node.find_prop("W")) {
(Some(prop), _) => {
let coordinate = Coordinate::from_sgf(prop.values[0].as_ref());
board = board.place_stone(coordinate, Color::Black)?;
}
(None, Some(prop)) => {
let coordinate = Coordinate::from_sgf(prop.values[0].as_ref());
board = board.place_stone(coordinate, Color::White)?;
}
(None, None) => (),
};
root = node.next()
}
Ok(board)
}
}
#[cfg(test)]
mod test {
use std::{fs::File, io::Read};
use super::*;
use cool_asserts::assert_matches;
use sgf::{parse_sgf, Game};
/* Two players (Black and White) take turns and Black plays first
* Stones are placed on the line intersections and not moved.
@ -225,6 +290,25 @@ mod test {
* A stone placed in a suicidal position is legal if it captures other stones first.
*/
fn with_text(text: &str, f: impl FnOnce(Vec<sgf::go::Game>)) {
let games = parse_sgf(text)
.unwrap()
.into_iter()
.filter_map(|game| match game {
Game::Go(g) => Some(g),
Game::Unsupported(_) => None,
})
.collect::<Vec<sgf::go::Game>>();
f(games);
}
fn with_file(path: &std::path::Path, f: impl FnOnce(Vec<sgf::go::Game>)) {
let mut file = File::open(path).unwrap();
let mut text = String::new();
let _ = file.read_to_string(&mut text);
with_text(&text, f);
}
fn with_example_board(test: impl FnOnce(Board)) {
let board = Board::from_coordinates(
vec![
@ -630,4 +714,46 @@ mod test {
assert_eq!(board, b2);
}
#[test]
fn loads_board_from_sgf() {
with_file(
std::path::Path::new("fixtures/five_games/2022.10.05.sgf"),
|trees| {
let game = &trees[0];
let board = Board::try_from(game).expect("game to be valid");
assert_eq!(
board.stone(&Coordinate { column: 14, row: 5 }),
Some(Color::Black)
);
/* This game has a fork in the game tree here. This block verifies that we have
* reached the move right before the fork. */
let mut node = &game.root;
for _ in 0..8 {
node = node.next().unwrap();
}
let prop = assert_matches!(node.find_prop("W"), Some(prop) => prop);
assert_eq!(
prop,
sgf::Property {
ident: "W".to_owned(),
values: vec!["qh".to_owned()]
}
);
/* And now we verify that we have gone down the leftmost side of the fork, which is
* traditionally the mainline of the game. */
let node = node.next().unwrap();
let prop = assert_matches!(node.find_prop("B"), Some(prop) => prop);
assert_eq!(
prop,
sgf::Property {
ident: "B".to_owned(),
values: vec!["of".to_owned()]
}
);
},
);
}
}

View File

@ -88,7 +88,7 @@ mod test {
assert_eq!(game.info.black_player, Some("Steve".to_owned()));
assert_eq!(game.info.white_player, Some("Savanni".to_owned()));
assert_eq!(game.info.date, vec![Date::Date(chrono::NaiveDate::from_ymd_opt(2023, 4, 19).unwrap())]);
assert_eq!(game.info.komi, Some(6.5));
// assert_eq!(game.info.komi, Some(6.5));
}
);
}

View File

@ -5,6 +5,7 @@ pub mod go;
mod tree;
use tree::parse_collection;
pub use tree::Property;
use thiserror::Error;