From 82c176551365946635c076ee05554d0a2f3001cb Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 19 Oct 2023 09:57:02 -0400 Subject: [PATCH 1/4] Write the more semantic Game interpreter --- Cargo.lock | 1 + sgf/Cargo.toml | 1 + sgf/src/game.rs | 787 +++++++++++++++++++++++++++++++++++++++++++++++ sgf/src/lib.rs | 32 +- sgf/src/types.rs | 20 -- 5 files changed, 814 insertions(+), 27 deletions(-) create mode 100644 sgf/src/game.rs diff --git a/Cargo.lock b/Cargo.lock index 618d38e..ae4fd8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3687,6 +3687,7 @@ dependencies = [ "serde 1.0.193", "thiserror", "typeshare", + "uuid 0.8.2", ] [[package]] diff --git a/sgf/Cargo.toml b/sgf/Cargo.toml index 2dbd6bf..355516d 100644 --- a/sgf/Cargo.toml +++ b/sgf/Cargo.toml @@ -11,6 +11,7 @@ nom = { version = "7" } serde = { version = "1", features = [ "derive" ] } thiserror = { version = "1"} typeshare = { version = "1" } +uuid = { version = "0.8", features = ["v4", "serde"] } [dev-dependencies] cool_asserts = { version = "2" } diff --git a/sgf/src/game.rs b/sgf/src/game.rs new file mode 100644 index 0000000..003c0a7 --- /dev/null +++ b/sgf/src/game.rs @@ -0,0 +1,787 @@ +use crate::{ + parser::{self, Annotation, Evaluation, Move, SetupInstr, Size, UnknownProperty}, + Color, Date, GameResult, GameType, +}; +use std::{collections::HashSet, time::Duration}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq)] +pub enum GameError { + InvalidGame, + RequiredPropertiesMissing, + InvalidGameNode(GameNodeError), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum MoveNodeError { + IncompatibleProperty(parser::Property), + ConflictingProperty, + NotAMoveNode, + ChildError(Box), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SetupNodeError { + IncompatibleProperty(parser::Property), + ConflictingProperty, + ConflictingPosition, + NotASetupNode, + ChildError(Box), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GameNodeError { + UnsupportedGameNode(MoveNodeError, SetupNodeError), + ConflictingProperty, + ConflictingPosition, +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Player { + pub name: Option, + pub rank: Option, + pub team: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Game { + game_type: GameType, + + // TODO: board size is not necessary in all games. Hive has no defined board size. + board_size: Size, + black_player: Player, + white_player: Player, + + app: Option, + annotator: Option, + copyright: Option, + dates: Vec, + event: Option, + game_name: Option, + extra_info: Option, + opening_info: Option, + location: Option, + result: Option, + round: Option, + rules: Option, + source: Option, + time_limit: Option, + overtime: Option, + transcriber: Option, + + children: Vec, +} + +impl Game { + pub fn new( + game_type: GameType, + board_size: Size, + black_player: Player, + white_player: Player, + ) -> Self { + Self { + game_type, + board_size, + black_player, + white_player, + + app: None, + annotator: None, + copyright: None, + dates: vec![], + event: None, + game_name: None, + extra_info: None, + opening_info: None, + location: None, + result: None, + round: None, + rules: None, + source: None, + time_limit: None, + overtime: None, + transcriber: None, + + children: vec![], + } + } +} + +impl Node for Game { + fn children<'a>(&'a self) -> Vec<&'a GameNode> { + self.children.iter().collect::>() + } + + fn add_child<'a>(&'a mut self, node: GameNode) -> &'a mut GameNode { + self.children.push(node); + self.children.last_mut().unwrap() + } +} + +impl TryFrom<&parser::Tree> for Game { + type Error = GameError; + + fn try_from(tree: &parser::Tree) -> Result { + let mut ty = None; + let mut size = None; + let mut black_player = Player { + name: None, + rank: None, + team: None, + }; + let mut white_player = Player { + name: None, + rank: None, + team: None, + }; + + for prop in tree.root.properties.iter() { + match prop { + parser::Property::GameType(ty_) => ty = Some(ty_.clone()), + parser::Property::BoardSize(size_) => size = Some(size_.clone()), + parser::Property::BlackPlayer(name) => { + black_player.name = Some(name.clone()); + } + parser::Property::WhitePlayer(name) => { + white_player.name = Some(name.clone()); + } + parser::Property::BlackRank(rank) => { + black_player.rank = Some(rank.clone()); + } + parser::Property::WhiteRank(rank) => { + white_player.rank = Some(rank.clone()); + } + parser::Property::BlackTeam(team) => { + black_player.team = Some(team.clone()); + } + parser::Property::WhiteTeam(team) => { + white_player.team = Some(team.clone()); + } + _ => {} + } + } + + let mut s = match (ty, size) { + (Some(ty), Some(size)) => Ok(Self::new(ty, size, black_player, white_player)), + _ => Err(Self::Error::RequiredPropertiesMissing), + }?; + + for prop in tree.root.properties.iter() { + match prop { + parser::Property::GameType(_) + | parser::Property::BoardSize(_) + | parser::Property::BlackPlayer(_) + | parser::Property::WhitePlayer(_) + | parser::Property::BlackRank(_) + | parser::Property::WhiteRank(_) + | parser::Property::BlackTeam(_) + | parser::Property::WhiteTeam(_) => {} + parser::Property::Application(v) => s.app = Some(v.clone()), + parser::Property::Annotator(v) => s.annotator = Some(v.clone()), + parser::Property::Copyright(v) => s.copyright = Some(v.clone()), + parser::Property::EventDates(v) => s.dates = v.clone(), + parser::Property::EventName(v) => s.event = Some(v.clone()), + parser::Property::GameName(v) => s.game_name = Some(v.clone()), + parser::Property::ExtraGameInformation(v) => s.extra_info = Some(v.clone()), + parser::Property::GameOpening(v) => s.opening_info = Some(v.clone()), + parser::Property::GameLocation(v) => s.location = Some(v.clone()), + parser::Property::Result(v) => s.result = Some(v.clone()), + parser::Property::Round(v) => s.round = Some(v.clone()), + parser::Property::Ruleset(v) => s.rules = Some(v.clone()), + parser::Property::Source(v) => s.source = Some(v.clone()), + parser::Property::TimeLimit(v) => s.time_limit = Some(v.clone()), + parser::Property::Overtime(v) => s.overtime = Some(v.clone()), + // parser::Property::Data(v) => s.transcriber = Some(v.clone()), + _ => {} + } + } + + s.children = tree + .root + .next + .iter() + .map(|node| GameNode::try_from(node)) + .collect::, GameNodeError>>() + .map_err(GameError::InvalidGameNode)?; + + Ok(s) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum GameNode { + MoveNode(MoveNode), + SetupNode(SetupNode), +} + +pub trait Node { + /// Provide a pre-order traversal of all of the nodes in the game tree. + fn nodes<'a>(&'a self) -> Vec<&'a GameNode> { + self.children() + .iter() + .map(|node| { + let mut children = node.nodes(); + let mut v = vec![*node]; + v.append(&mut children); + v + }) + .flatten() + .collect::>() + } + + fn children<'a>(&'a self) -> Vec<&'a GameNode>; + fn add_child<'a>(&'a mut self, node: GameNode) -> &'a mut GameNode; +} + +impl GameNode { + pub fn id(&self) -> Uuid { + match self { + GameNode::MoveNode(node) => node.id, + GameNode::SetupNode(node) => node.id, + } + } +} + +impl Node for GameNode { + fn children<'a>(&'a self) -> Vec<&'a GameNode> { + match self { + GameNode::MoveNode(node) => node.children(), + GameNode::SetupNode(node) => node.children(), + } + } + + fn nodes<'a>(&'a self) -> Vec<&'a GameNode> { + match self { + GameNode::MoveNode(node) => node.nodes(), + GameNode::SetupNode(node) => node.nodes(), + } + } + + fn add_child<'a>(&'a mut self, new_node: GameNode) -> &'a mut GameNode { + match self { + GameNode::MoveNode(node) => node.add_child(new_node), + GameNode::SetupNode(node) => node.add_child(new_node), + } + } +} + +impl TryFrom<&parser::Node> for GameNode { + type Error = GameNodeError; + fn try_from(n: &parser::Node) -> Result { + let move_node = MoveNode::try_from(n); + let setup_node = SetupNode::try_from(n); + + match (move_node, setup_node) { + (Ok(node), _) => Ok(Self::MoveNode(node)), + (Err(_), Ok(node)) => Ok(Self::SetupNode(node)), + (Err(move_err), Err(setup_err)) => { + Err(Self::Error::UnsupportedGameNode(move_err, setup_err)) + } + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MoveNode { + id: Uuid, + color: Color, + mv: Move, + children: Vec, + + time_left: Option, + moves_left: Option, + name: Option, + evaluation: Option, + value: Option, + comments: Option, + annotation: Option, + unknown_props: Vec<(String, String)>, +} + +impl MoveNode { + pub fn new(color: Color, mv: Move) -> Self { + Self { + id: Uuid::new_v4(), + color, + mv, + children: Vec::new(), + + time_left: None, + moves_left: None, + name: None, + evaluation: None, + value: None, + comments: None, + annotation: None, + unknown_props: vec![], + } + } +} + +impl Node for MoveNode { + fn children<'a>(&'a self) -> Vec<&'a GameNode> { + self.children.iter().collect::>() + } + + fn add_child<'a>(&'a mut self, node: GameNode) -> &'a mut GameNode { + self.children.push(node); + self.children.last_mut().unwrap() + } +} + +impl TryFrom<&parser::Node> for MoveNode { + type Error = MoveNodeError; + + fn try_from(n: &parser::Node) -> Result { + let mut s = match n.mv() { + Some((color, mv)) => { + let mut s = Self::new(color, mv); + + for prop in n.properties.iter() { + match prop { + parser::Property::Move((color, mv)) => { + if s.color != *color || s.mv != *mv { + return Err(Self::Error::ConflictingProperty); + } + } + parser::Property::TimeLeft((color, duration)) => { + if s.color != *color { + return Err(Self::Error::ConflictingProperty); + } + if s.time_left.is_some() { + return Err(Self::Error::ConflictingProperty); + } + s.time_left = Some(duration.clone()); + } + parser::Property::Comment(cmt) => { + if s.comments.is_some() { + return Err(Self::Error::ConflictingProperty); + } + s.comments = Some(cmt.clone()); + } + parser::Property::Evaluation(evaluation) => { + if s.evaluation.is_some() { + return Err(Self::Error::ConflictingProperty); + } + s.evaluation = Some(*evaluation) + } + parser::Property::Annotation(annotation) => { + if s.annotation.is_some() { + return Err(Self::Error::ConflictingProperty); + } + s.annotation = Some(*annotation) + } + parser::Property::Unknown(UnknownProperty { ident, value }) => { + s.unknown_props.push((ident.clone(), value.clone())); + } + _ => return Err(Self::Error::IncompatibleProperty(prop.clone())), + } + } + + Ok(s) + } + None => Err(Self::Error::NotAMoveNode), + }?; + + s.children = n + .next + .iter() + .map(|node| { + GameNode::try_from(node).map_err(|err| Self::Error::ChildError(Box::new(err))) + }) + .collect::, MoveNodeError>>()?; + + Ok(s) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SetupNode { + id: Uuid, + + positions: Vec, + children: Vec, +} + +impl SetupNode { + pub fn new(positions: Vec) -> Result { + let mut board = HashSet::new(); + for position in positions.iter() { + let point = match position { + SetupInstr::Piece((_, point)) => point, + SetupInstr::Clear(point) => point, + }; + if board.contains(point) { + return Err(SetupNodeError::ConflictingPosition); + } + board.insert(point); + } + + Ok(Self { + id: Uuid::new_v4(), + positions, + children: Vec::new(), + }) + } +} + +impl Node for SetupNode { + fn children<'a>(&'a self) -> Vec<&'a GameNode> { + self.children.iter().collect::>() + } + + #[allow(dead_code)] + fn add_child<'a>(&'a mut self, _node: GameNode) -> &'a mut GameNode { + unimplemented!() + } +} + +impl TryFrom<&parser::Node> for SetupNode { + type Error = SetupNodeError; + + fn try_from(n: &parser::Node) -> Result { + match n.setup() { + Some(elements) => Self::new(elements), + None => Err(Self::Error::NotASetupNode), + } + } +} + +#[allow(dead_code)] +pub fn path_to_node<'a>(node: &'a GameNode, id: Uuid) -> Vec<&'a GameNode> { + if node.id() == id { + return vec![node]; + } + + for child in node.children() { + let mut path = path_to_node(child, id); + if path.len() > 1 { + path.push(child); + return path; + } + } + + Vec::new() +} + +#[cfg(test)] +mod test { + use super::*; + use cool_asserts::assert_matches; + + #[test] + fn it_can_create_an_empty_game_tree() { + let tree = Game::new( + GameType::Go, + Size { + width: 19, + height: 19, + }, + Player::default(), + Player::default(), + ); + assert_eq!(tree.nodes().len(), 0); + } + + #[test] + fn it_can_add_moves_to_a_game() { + let mut game = Game::new( + GameType::Go, + Size { + width: 19, + height: 19, + }, + Player::default(), + Player::default(), + ); + + let first_move = MoveNode::new(Color::Black, Move::Move("dd".to_owned())); + let first_ = game.add_child(GameNode::MoveNode(first_move.clone())); + let second_move = MoveNode::new(Color::White, Move::Move("qq".to_owned())); + first_.add_child(GameNode::MoveNode(second_move.clone())); + + let nodes = game.nodes(); + assert_eq!(nodes.len(), 2); + assert_eq!(nodes[0].id(), first_move.id); + assert_eq!(nodes[1].id(), second_move.id); + } + + #[ignore] + #[test] + fn it_can_set_up_a_game() { + unimplemented!() + } + + #[ignore] + #[test] + fn it_can_load_tree_from_sgf() { + unimplemented!() + } + + #[test] + fn game_node_can_parse_sgf_move_node() { + let n = parser::Node { + properties: vec![ + parser::Property::Move((Color::White, Move::Move("dp".to_owned()))), + parser::Property::TimeLeft((Color::White, Duration::from_secs(176))), + parser::Property::Comment("Comments in the game".to_owned()), + ], + next: vec![], + }; + assert_matches!(GameNode::try_from(&n), Ok(GameNode::MoveNode(_))); + } +} + +#[cfg(test)] +mod root_node_tests { + #[ignore] + #[test] + fn it_rejects_move_properties() { + unimplemented!() + } + + #[ignore] + #[test] + fn it_rejects_setup_properties() { + unimplemented!() + } + + #[ignore] + #[test] + fn it_can_parse_a_root_sgf() { + unimplemented!() + } +} + +#[cfg(test)] +mod move_node_tests { + use crate::parser::PositionList; + + use super::*; + use cool_asserts::assert_matches; + + #[test] + fn it_can_parse_an_sgf_move_node() { + let n = parser::Node { + properties: vec![ + parser::Property::Move((Color::White, Move::Move("dp".to_owned()))), + parser::Property::TimeLeft((Color::White, Duration::from_secs(176))), + parser::Property::Comment("Comments in the game".to_owned()), + ], + next: vec![], + }; + assert_matches!(MoveNode::try_from(&n), Ok(node) => { + assert_eq!(node.color, Color::White); + assert_eq!(node.mv, Move::Move("dp".to_owned())); + assert_eq!(node.children, vec![]); + assert_eq!(node.time_left, Some(Duration::from_secs(176))); + assert_eq!(node.comments, Some("Comments in the game".to_owned())); + }); + } + + #[test] + fn it_rejects_an_sgf_setup_node() { + let n = parser::Node { + properties: vec![ + parser::Property::Move((Color::White, Move::Move("dp".to_owned()))), + parser::Property::TimeLeft((Color::White, Duration::from_secs(176))), + parser::Property::SetupBlackStones(PositionList(vec![ + "dd".to_owned(), + "de".to_owned(), + ])), + ], + next: vec![], + }; + assert_matches!( + MoveNode::try_from(&n), + Err(MoveNodeError::IncompatibleProperty(_)) + ); + } +} + +#[cfg(test)] +mod setup_node_tests { + use crate::parser::SetupInstr; + + use super::*; + use cool_asserts::assert_matches; + + #[ignore] + #[test] + fn it_can_parse_an_sgf_setup_node() { + unimplemented!() + } + + #[test] + fn it_rejects_conflicting_placement_properties() { + assert_matches!( + SetupNode::new(vec![ + SetupInstr::Piece((Color::Black, "dd".to_owned())), + SetupInstr::Piece((Color::Black, "dd".to_owned())), + ]), + Err(SetupNodeError::ConflictingPosition) + ); + assert_matches!( + SetupNode::new(vec![ + SetupInstr::Piece((Color::Black, "dd".to_owned())), + SetupInstr::Piece((Color::Black, "ee".to_owned())), + SetupInstr::Piece((Color::White, "ee".to_owned())), + ]), + Err(SetupNodeError::ConflictingPosition) + ); + } +} + +#[cfg(test)] +mod path_test { + #[ignore] + #[test] + fn returns_empty_list_if_no_game_nodes() { + unimplemented!() + } + + #[ignore] + #[test] + fn returns_empty_list_if_node_not_found() { + unimplemented!() + } + + #[ignore] + #[test] + fn path_excludes_root_node() { + unimplemented!() + } +} + +#[cfg(test)] +mod file_test { + use super::*; + use crate::Win; + use cool_asserts::assert_matches; + use parser::parse_collection; + use std::{fs::File, io::Read}; + + fn with_text(text: &str, f: impl FnOnce(Vec)) { + let (_, games) = parse_collection::>(text).unwrap(); + let games = games + .into_iter() + .map(|game| Game::try_from(&game).expect("game to parse")) + .collect::>(); + f(games); + } + + fn with_file(path: &std::path::Path, f: impl FnOnce(Vec)) { + let mut file = File::open(path).unwrap(); + let mut text = String::new(); + let _ = file.read_to_string(&mut text); + with_text(&text, f); + } + + #[ignore] + #[test] + fn it_can_load_basic_game_records() { + with_file( + std::path::Path::new("test_data/2020 USGO DDK, Round 1.sgf"), + |games| { + assert_eq!(games.len(), 1); + let game = &games[0]; + + assert_eq!(game.game_type, GameType::Go); + assert_eq!( + game.board_size, + Size { + width: 19, + height: 19 + } + ); + assert_eq!( + game.black_player, + Player { + name: Some("savanni".to_owned()), + rank: Some("23k".to_owned()), + team: None + } + ); + assert_eq!( + game.white_player, + Player { + name: Some("Geckoz".to_owned()), + rank: None, + team: None + } + ); + assert_eq!(game.app, Some("CGoban:3".to_owned())); + + assert_eq!(game.annotator, None); + assert_eq!(game.copyright, None); + assert_eq!( + game.dates, + vec![Date::Date( + chrono::NaiveDate::from_ymd_opt(2020, 8, 5).unwrap() + )] + ); + assert_eq!(game.event, None); + assert_eq!(game.game_name, None); + assert_eq!(game.extra_info, None); + assert_eq!(game.opening_info, None); + assert_eq!( + game.location, + Some("The KGS Go Server at http://www.gokgs.com/".to_owned()) + ); + assert_eq!(game.result, Some(GameResult::White(Win::Score(17.5)))); + assert_eq!(game.round, None); + assert_eq!(game.rules, Some("AGA".to_owned())); + assert_eq!(game.source, None); + assert_eq!(game.time_limit, Some(Duration::from_secs(1800))); + assert_eq!(game.overtime, Some("5x30 byo-yomi".to_owned())); + assert_eq!(game.transcriber, None); + + /* + Property { + ident: "KM".to_owned(), + values: vec!["7.50".to_owned()], + }, + ]; + + for i in 0..16 { + assert_eq!(node.properties[i], expected_properties[i]); + } + */ + + let children = game.children(); + let node = children.first().unwrap(); + assert_matches!(node, GameNode::MoveNode(node) => { + assert_eq!(node.color, Color::Black); + assert_eq!(node.mv, Move::Move("pp".to_owned())); + assert_eq!(node.time_left, Some(Duration::from_secs(1795))); + assert_eq!(node.comments, Some("Geckoz [?]: Good game\nsavanni [23k?]: There we go! This UI is... tough.\nsavanni [23k?]: Have fun! Talk to you at the end.\nGeckoz [?]: Yeah, OGS is much better; I'm a UX professional\n".to_owned()) + )}); + + let children = node.children(); + let node = children.first().unwrap(); + assert_matches!(node, GameNode::MoveNode(node) => { + assert_eq!(node.color, Color::White); + assert_eq!(node.mv, Move::Move("dp".to_owned())); + assert_eq!(node.time_left, Some(Duration::from_secs(1765))); + assert_eq!(node.comments, None); + }); + /* + let node = node.next().unwrap(); + let expected_properties = vec![ + Property { + ident: "W".to_owned(), + values: vec!["dp".to_owned()], + }, + Property { + ident: "WL".to_owned(), + values: vec!["1765.099".to_owned()], + }, + ]; + for i in 0..2 { + assert_eq!(node.properties[i], expected_properties[i]); + } + */ + }, + ); + } +} diff --git a/sgf/src/lib.rs b/sgf/src/lib.rs index bf47349..9b88bf2 100644 --- a/sgf/src/lib.rs +++ b/sgf/src/lib.rs @@ -1,12 +1,14 @@ mod date; -pub use date::Date; - +mod game; mod parser; -pub use parser::parse_collection; - -use thiserror::Error; - mod types; + +pub use date::Date; +pub use game::Game; +use game::Player; +pub use parser::parse_collection; +use parser::Size; +use thiserror::Error; pub use types::*; #[derive(Debug)] @@ -58,5 +60,21 @@ impl From> for ParseError { } pub fn parse_sgf(_input: &str) -> Result, Error> { - Ok(vec![Game::default()]) + Ok(vec![Game::new( + GameType::Go, + Size { + width: 19, + height: 19, + }, + Player { + name: None, + rank: None, + team: None, + }, + Player { + name: None, + rank: None, + team: None, + }, + )]) } diff --git a/sgf/src/types.rs b/sgf/src/types.rs index 605f12a..cdd0730 100644 --- a/sgf/src/types.rs +++ b/sgf/src/types.rs @@ -1,25 +1,5 @@ -use crate::date::Date; - use thiserror::Error; -/// This is a placeholder structure. It is not meant to represent a game, only to provide a mock -/// interface for code already written that expects a Game data type to exist. -#[derive(Debug, Default)] -pub struct Game { - pub info: GameInfo, -} - -#[derive(Debug, Default)] -pub struct GameInfo { - pub black_player: Option, - pub black_rank: Option, - pub white_player: Option, - pub white_rank: Option, - pub result: Option, - pub game_name: Option, - pub date: Vec, -} - #[derive(Clone, Debug, PartialEq)] pub enum GameType { Go, -- 2.44.1 From bd6d5b62e3febedaf4639c4fcee66bc9cbac8e6e Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 20 Oct 2023 00:36:03 -0400 Subject: [PATCH 2/4] Reduce the recursion amount of parser Node to GameNode --- sgf/src/game.rs | 59 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/sgf/src/game.rs b/sgf/src/game.rs index 003c0a7..ad90d78 100644 --- a/sgf/src/game.rs +++ b/sgf/src/game.rs @@ -43,6 +43,14 @@ pub struct Player { pub team: Option, } +/// This represents the more semantic version of the game parser. Where the `parser` crate pulls +/// out a raw set of nodes, this structure is guaranteed to be a well-formed game. Getting to this +/// level, the interpreter will reject any games that have setup properties and move properties +/// mixed in a single node. If there are other semantic problems, the interpreter will reject +/// those, as well. Where the function of the parser is to understand and correct fundamental +/// syntax issues, the result of the Game is to have a fully-understood game. However, this doesn't +/// (yet?) go quite to the level of apply the game type (i.e., this is Go, Chess, Yinsh, or +/// whatever). #[derive(Clone, Debug, PartialEq)] pub struct Game { game_type: GameType, @@ -267,17 +275,45 @@ impl Node for GameNode { impl TryFrom<&parser::Node> for GameNode { type Error = GameNodeError; + fn try_from(n: &parser::Node) -> Result { + // I originally wrote this recursively. However, on an ordinary game of a couple hundred + // moves, that meant that I was recursing 500 functions, and that exceeded the stack limit. + // So, instead, I need to unroll everything to non-recursive form. + // + // So, I can treat each branch of the tree as a single line. Iterate over that line. I can + // only use the MoveNode::try_from and SetupNode::try_from if those functions don't + // recurse. Instead, I'm going to process just that node, then return to here and process + // the children. let move_node = MoveNode::try_from(n); let setup_node = SetupNode::try_from(n); - match (move_node, setup_node) { - (Ok(node), _) => Ok(Self::MoveNode(node)), - (Err(_), Ok(node)) => Ok(Self::SetupNode(node)), + // I'm much too tired when writing this. I'm still recursing, but I did cut the number of + // recursions in half. This helps, but it still doesn't guarantee that I'm going to be able + // to parse all possible games. So, still, treat each branch of the game as a single line. + // Iterate over that line, don't recurse. Create bookmarks at each branch point, and then + // come back to each one. + let children = n + .next + .iter() + .map(|n| GameNode::try_from(n)) + .collect::, Self::Error>>()?; + + let node = match (move_node, setup_node) { + (Ok(mut node), _) => { + node.children = children; + Ok(Self::MoveNode(node)) + } + (Err(_), Ok(mut node)) => { + node.children = children; + Ok(Self::SetupNode(node)) + } (Err(move_err), Err(setup_err)) => { Err(Self::Error::UnsupportedGameNode(move_err, setup_err)) } - } + }?; + + Ok(node) } } @@ -333,7 +369,7 @@ impl TryFrom<&parser::Node> for MoveNode { type Error = MoveNodeError; fn try_from(n: &parser::Node) -> Result { - let mut s = match n.mv() { + let s = match n.mv() { Some((color, mv)) => { let mut s = Self::new(color, mv); @@ -383,14 +419,6 @@ impl TryFrom<&parser::Node> for MoveNode { None => Err(Self::Error::NotAMoveNode), }?; - s.children = n - .next - .iter() - .map(|node| { - GameNode::try_from(node).map_err(|err| Self::Error::ChildError(Box::new(err))) - }) - .collect::, MoveNodeError>>()?; - Ok(s) } } @@ -677,9 +705,10 @@ mod file_test { with_text(&text, f); } - #[ignore] + /// This test checks against an ordinary game from SGF. It is unannotated and should contain + /// only move nodes with no setup nodes. The original source is from a game I played on KGS. #[test] - fn it_can_load_basic_game_records() { + fn it_can_load_an_ordinary_unannotated_game() { with_file( std::path::Path::new("test_data/2020 USGO DDK, Round 1.sgf"), |games| { -- 2.44.1 From a5990a2a303b3207795ca58c975c2aecc44f6307 Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 21 Mar 2024 22:48:53 -0400 Subject: [PATCH 3/4] Ensure that the territory property is accepted --- sgf/src/game.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sgf/src/game.rs b/sgf/src/game.rs index ad90d78..1b6a0cb 100644 --- a/sgf/src/game.rs +++ b/sgf/src/game.rs @@ -407,6 +407,9 @@ impl TryFrom<&parser::Node> for MoveNode { } s.annotation = Some(*annotation) } + parser::Property::Territory(..) => { + eprintln!("not processing territory property"); + } parser::Property::Unknown(UnknownProperty { ident, value }) => { s.unknown_props.push((ident.clone(), value.clone())); } -- 2.44.1 From 1d959117aab953b6c3b18eac98dfc47ca2489eaf Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Thu, 21 Mar 2024 23:12:30 -0400 Subject: [PATCH 4/4] Write a little app to demonstrate reading an an SGF file --- sgf/src/bin/read_sgf.rs | 19 +++++++++++++++++++ sgf/src/game.rs | 42 ++++++++++++++++++++--------------------- sgf/src/lib.rs | 22 +++++++++++++++++++-- 3 files changed, 60 insertions(+), 23 deletions(-) create mode 100644 sgf/src/bin/read_sgf.rs diff --git a/sgf/src/bin/read_sgf.rs b/sgf/src/bin/read_sgf.rs new file mode 100644 index 0000000..af3c5e9 --- /dev/null +++ b/sgf/src/bin/read_sgf.rs @@ -0,0 +1,19 @@ +use sgf::parse_sgf_file; +use std::path::PathBuf; +use std::env; + +fn main() { + let mut args = env::args(); + + let _ = args.next(); + let file = PathBuf::from(args.next().unwrap()); + + println!("{:?}", file); + + let games = parse_sgf_file(&file).unwrap(); + for sgf in games { + if let Ok(sgf) = sgf { + println!("{:?}", sgf.white_player); + } + } +} diff --git a/sgf/src/game.rs b/sgf/src/game.rs index 1b6a0cb..e1d967b 100644 --- a/sgf/src/game.rs +++ b/sgf/src/game.rs @@ -53,31 +53,31 @@ pub struct Player { /// whatever). #[derive(Clone, Debug, PartialEq)] pub struct Game { - game_type: GameType, + pub game_type: GameType, // TODO: board size is not necessary in all games. Hive has no defined board size. - board_size: Size, - black_player: Player, - white_player: Player, + pub board_size: Size, + pub black_player: Player, + pub white_player: Player, - app: Option, - annotator: Option, - copyright: Option, - dates: Vec, - event: Option, - game_name: Option, - extra_info: Option, - opening_info: Option, - location: Option, - result: Option, - round: Option, - rules: Option, - source: Option, - time_limit: Option, - overtime: Option, - transcriber: Option, + pub app: Option, + pub annotator: Option, + pub copyright: Option, + pub dates: Vec, + pub event: Option, + pub game_name: Option, + pub extra_info: Option, + pub opening_info: Option, + pub location: Option, + pub result: Option, + pub round: Option, + pub rules: Option, + pub source: Option, + pub time_limit: Option, + pub overtime: Option, + pub transcriber: Option, - children: Vec, + pub children: Vec, } impl Game { diff --git a/sgf/src/lib.rs b/sgf/src/lib.rs index 9b88bf2..c37cad2 100644 --- a/sgf/src/lib.rs +++ b/sgf/src/lib.rs @@ -3,11 +3,10 @@ mod game; mod parser; mod types; +use std::{fs::File, io::Read}; pub use date::Date; pub use game::Game; -use game::Player; pub use parser::parse_collection; -use parser::Size; use thiserror::Error; pub use types::*; @@ -59,6 +58,24 @@ impl From> for ParseError { } } +pub fn parse_sgf(input: &str) -> Result>, Error> { + let (_, games) = parse_collection::>(&input)?; + let games = games.into_iter() + .map(|game| Game::try_from(&game)) + .collect::>>(); + + Ok(games) +} + +pub fn parse_sgf_file(path: &std::path::Path) -> Result>, Error> { + let mut file = File::open(path).unwrap(); + let mut text = String::new(); + let _ = file.read_to_string(&mut text); + + parse_sgf(&text) +} + +/* pub fn parse_sgf(_input: &str) -> Result, Error> { Ok(vec![Game::new( GameType::Go, @@ -78,3 +95,4 @@ pub fn parse_sgf(_input: &str) -> Result, Error> { }, )]) } +*/ -- 2.44.1