It is now possible to move backwards and forwards on the mainline of a tree
This commit is contained in:
parent
cbfb3f2e37
commit
7dd531b493
|
@ -213,7 +213,7 @@ impl Goban {
|
||||||
///
|
///
|
||||||
/// assert_eq!(goban.stone(&Coordinate{ row: 3, column: 3 }), Some(Color::Black));
|
/// assert_eq!(goban.stone(&Coordinate{ row: 3, column: 3 }), Some(Color::Black));
|
||||||
/// assert_eq!(goban.stone(&Coordinate{ row: 15, column: 15 }), Some(Color::White));
|
/// assert_eq!(goban.stone(&Coordinate{ row: 15, column: 15 }), Some(Color::White));
|
||||||
/// assert_eq!(goban.stone(&Coordinate{ row: 3, column: 15 }), Some(Color::Black));
|
/// assert_eq!(goban.stone(&Coordinate{ row: 15, column: 3 }), Some(Color::Black));
|
||||||
/// ```
|
/// ```
|
||||||
pub fn apply_moves<'a>(
|
pub fn apply_moves<'a>(
|
||||||
self,
|
self,
|
||||||
|
|
|
@ -28,6 +28,8 @@ use sgf::{GameRecord, Player};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
struct GameReviewViewModelPrivate {
|
struct GameReviewViewModelPrivate {
|
||||||
|
// This is the ID of the current position in the game. The ID is specific to the GameRecord,
|
||||||
|
// not the ReviewTree.
|
||||||
current_position: Option<NodeId>,
|
current_position: Option<NodeId>,
|
||||||
game: GameRecord,
|
game: GameRecord,
|
||||||
review_tree: DepthTree,
|
review_tree: DepthTree,
|
||||||
|
@ -35,7 +37,7 @@ struct GameReviewViewModelPrivate {
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct GameReviewViewModel {
|
pub struct GameReviewViewModel {
|
||||||
imp: Arc<RwLock<GameReviewViewModelPrivate>>,
|
inner: Arc<RwLock<GameReviewViewModelPrivate>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameReviewViewModel {
|
impl GameReviewViewModel {
|
||||||
|
@ -49,7 +51,7 @@ impl GameReviewViewModel {
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
imp: Arc::new(RwLock::new(GameReviewViewModelPrivate {
|
inner: Arc::new(RwLock::new(GameReviewViewModelPrivate {
|
||||||
current_position,
|
current_position,
|
||||||
game,
|
game,
|
||||||
review_tree,
|
review_tree,
|
||||||
|
@ -58,52 +60,222 @@ impl GameReviewViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn black_player(&self) -> Player {
|
pub fn black_player(&self) -> Player {
|
||||||
self.imp.read().unwrap().game.black_player.clone()
|
self.inner.read().unwrap().game.black_player.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn white_player(&self) -> Player {
|
pub fn white_player(&self) -> Player {
|
||||||
self.imp.read().unwrap().game.white_player.clone()
|
self.inner.read().unwrap().game.white_player.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn game_view(&self) -> Goban {
|
pub fn game_view(&self) -> Goban {
|
||||||
let imp = self.imp.read().unwrap();
|
let inner = self.inner.read().unwrap();
|
||||||
let mainline = imp.game.mainline();
|
|
||||||
match mainline {
|
let mut path: Vec<NodeId> = vec![];
|
||||||
Some(mainline) => Goban::default()
|
let mut current_id = inner.current_position;
|
||||||
.apply_moves(mainline.map(|nr| nr.data()))
|
while current_id.is_some() {
|
||||||
.unwrap(),
|
let current = current_id.unwrap();
|
||||||
None => Goban::default(),
|
path.push(current);
|
||||||
|
current_id = inner.game.trees[0]
|
||||||
|
.get(current)
|
||||||
|
.unwrap()
|
||||||
|
.parent()
|
||||||
|
.map(|parent| parent.node_id());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path.reverse();
|
||||||
|
Goban::default()
|
||||||
|
.apply_moves(
|
||||||
|
path.into_iter()
|
||||||
|
.map(|node_id| inner.game.trees[0].get(node_id).unwrap().data()),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
|
||||||
|
/*
|
||||||
|
if let Some(start) = inner.current_position {
|
||||||
|
let mut current_id = start;
|
||||||
|
let mut path = vec![current_id.clone()];
|
||||||
|
|
||||||
|
while let
|
||||||
|
/*
|
||||||
|
let mut current_node = inner.game.trees[0].get(current_position).unwrap();
|
||||||
|
let mut path = vec![current_node.data()];
|
||||||
|
while let Some(parent) = current_node.parent() {
|
||||||
|
path.push(parent.data());
|
||||||
|
current_node = parent;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
path.reverse();
|
||||||
|
Goban::default().apply_moves(path).unwrap()
|
||||||
|
} else {
|
||||||
|
Goban::default()
|
||||||
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn map_tree<F>(&self, f: F)
|
pub fn map_tree<F>(&self, f: F)
|
||||||
where
|
where
|
||||||
F: Fn(NodeRef<'_, SizeNode>, Option<NodeId>),
|
F: Fn(NodeRef<'_, SizeNode>, Option<NodeId>),
|
||||||
{
|
{
|
||||||
let imp = self.imp.read().unwrap();
|
let inner = self.inner.read().unwrap();
|
||||||
|
|
||||||
for node in imp.review_tree.bfs_iter() {
|
for node in inner.review_tree.bfs_iter() {
|
||||||
f(node, imp.current_position);
|
f(node, inner.current_position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tree_max_depth(&self) -> usize {
|
pub fn tree_max_depth(&self) -> usize {
|
||||||
self.imp.read().unwrap().review_tree.max_depth()
|
self.inner.read().unwrap().review_tree.max_depth()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_forward(&self) {
|
// When moving forward on the tree, I grab the first child by default. I can then just advance
|
||||||
unimplemented!()
|
// the board state by applying the child.
|
||||||
|
pub fn next_move(&self) {
|
||||||
|
let mut inner = self.inner.write().unwrap();
|
||||||
|
let current_position = inner.current_position.clone();
|
||||||
|
match current_position {
|
||||||
|
Some(current_position) => {
|
||||||
|
let current_id = current_position.clone();
|
||||||
|
let node = inner.game.trees[0].get(current_id).unwrap();
|
||||||
|
if let Some(next_id) = node.first_child().map(|child| child.node_id()) {
|
||||||
|
inner.current_position = Some(next_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
inner.current_position = inner.game.trees[0].root().map(|node| node.node_id());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_backward(&self) {
|
// When moving backwards, I jump up to the parent. I'll then rebuild the board state from the
|
||||||
unimplemented!()
|
// root.
|
||||||
|
pub fn previous_move(&mut self) {
|
||||||
|
let mut inner = self.inner.write().unwrap();
|
||||||
|
if let Some(current_position) = inner.current_position {
|
||||||
|
let current_node = inner.game.trees[0]
|
||||||
|
.get(current_position)
|
||||||
|
.expect("current_position should always correspond to a node in the tree");
|
||||||
|
if let Some(parent_node) = current_node.parent() {
|
||||||
|
inner.current_position = Some(parent_node.node_id());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_variant(&self) {
|
pub fn next_variant(&self) {
|
||||||
unimplemented!()
|
println!("move to the next variant amongst the options available");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn previous_variant(&self) {
|
pub fn previous_variant(&self) {
|
||||||
unimplemented!()
|
println!("move to the previous variant amongst the options available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use crate::{Color, Coordinate};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn with_game_record<F>(test: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(GameReviewViewModel),
|
||||||
|
{
|
||||||
|
let records = sgf::parse_sgf_file(&Path::new("../../sgf/test_data/branch_test.sgf"))
|
||||||
|
.expect("to successfully load the test file");
|
||||||
|
let record = records[0]
|
||||||
|
.as_ref()
|
||||||
|
.expect("to have successfully loaded the test record");
|
||||||
|
|
||||||
|
let view_model = GameReviewViewModel::new(record.clone());
|
||||||
|
|
||||||
|
test(view_model);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_generates_a_mainline_board() {
|
||||||
|
with_game_record(|view| {
|
||||||
|
let goban = view.game_view();
|
||||||
|
|
||||||
|
for row in 0..18 {
|
||||||
|
for column in 0..18 {
|
||||||
|
if row == 3 && column == 3 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White));
|
||||||
|
} else if row == 15 && column == 3 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White));
|
||||||
|
} else if row == 3 && column == 15 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black));
|
||||||
|
} else if row == 15 && column == 14 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black));
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
goban.stone(&Coordinate { row, column }),
|
||||||
|
None,
|
||||||
|
"{} {}",
|
||||||
|
row,
|
||||||
|
column
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_moves_to_the_previous_mainline_move() {
|
||||||
|
with_game_record(|mut view| {
|
||||||
|
view.previous_move();
|
||||||
|
let goban = view.game_view();
|
||||||
|
|
||||||
|
for row in 0..18 {
|
||||||
|
for column in 0..18 {
|
||||||
|
if row == 3 && column == 3 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White));
|
||||||
|
} else if row == 3 && column == 15 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black));
|
||||||
|
} else if row == 15 && column == 14 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black));
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
goban.stone(&Coordinate { row, column }),
|
||||||
|
None,
|
||||||
|
"{} {}",
|
||||||
|
row,
|
||||||
|
column
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_moves_to_the_next_node() {
|
||||||
|
with_game_record(|mut view| {
|
||||||
|
view.previous_move();
|
||||||
|
view.next_move();
|
||||||
|
let goban = view.game_view();
|
||||||
|
|
||||||
|
for row in 0..18 {
|
||||||
|
for column in 0..18 {
|
||||||
|
if row == 3 && column == 3 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White));
|
||||||
|
} else if row == 15 && column == 3 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::White));
|
||||||
|
} else if row == 3 && column == 15 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black));
|
||||||
|
} else if row == 15 && column == 14 {
|
||||||
|
assert_eq!(goban.stone(&Coordinate { row, column }), Some(Color::Black));
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
goban.stone(&Coordinate { row, column }),
|
||||||
|
None,
|
||||||
|
"{} {}",
|
||||||
|
row,
|
||||||
|
column
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,8 @@ You should have received a copy of the GNU General Public License along with On
|
||||||
use crate::{CoreApi, ResourceManager};
|
use crate::{CoreApi, ResourceManager};
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
|
|
||||||
|
use glib::Propagation;
|
||||||
|
use gtk::{gdk::Key, EventControllerKey};
|
||||||
use otg_core::{
|
use otg_core::{
|
||||||
settings::{SettingsRequest, SettingsResponse},
|
settings::{SettingsRequest, SettingsResponse},
|
||||||
CoreRequest, CoreResponse, GameReviewViewModel,
|
CoreRequest, CoreResponse, GameReviewViewModel,
|
||||||
|
@ -102,6 +104,22 @@ impl AppWindow {
|
||||||
layout.append(&header);
|
layout.append(&header);
|
||||||
layout.append(&game_review.widget());
|
layout.append(&game_review.widget());
|
||||||
|
|
||||||
|
// This controller ensures that navigational keypresses get sent to the game review so that
|
||||||
|
// they're not changing the cursor focus in the app.
|
||||||
|
let keypress_controller = EventControllerKey::new();
|
||||||
|
keypress_controller.connect_key_pressed({
|
||||||
|
move |s, key, _, _| {
|
||||||
|
println!("layout keypress: {}", key);
|
||||||
|
if s.forward(&game_review.widget()) {
|
||||||
|
Propagation::Stop
|
||||||
|
} else {
|
||||||
|
Propagation::Proceed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
layout.add_controller(keypress_controller);
|
||||||
|
|
||||||
let page = adw::NavigationPage::builder()
|
let page = adw::NavigationPage::builder()
|
||||||
.can_pop(true)
|
.can_pop(true)
|
||||||
.title("Game Review")
|
.title("Game Review")
|
||||||
|
|
|
@ -101,6 +101,12 @@ impl Goban {
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_board_state(&mut self, board_state: otg_core::Goban) {
|
||||||
|
println!("updating board state");
|
||||||
|
*self.imp().board_state.borrow_mut() = board_state;
|
||||||
|
self.queue_draw();
|
||||||
|
}
|
||||||
|
|
||||||
fn redraw(&self, ctx: &cairo::Context, width: i32, height: i32) {
|
fn redraw(&self, ctx: &cairo::Context, width: i32, height: i32) {
|
||||||
println!("{} x {}", width, height);
|
println!("{} x {}", width, height);
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -22,8 +22,14 @@ You should have received a copy of the GNU General Public License along with On
|
||||||
// I'll get all of the information about the game from the core, and then render everything in the
|
// I'll get all of the information about the game from the core, and then render everything in the
|
||||||
// UI. So this will be a heavy lift on the UI side.
|
// UI. So this will be a heavy lift on the UI side.
|
||||||
|
|
||||||
use crate::{components::{Goban, PlayerCard, ReviewTree}, ResourceManager};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
use gtk::{prelude::*};
|
|
||||||
|
use crate::{
|
||||||
|
components::{Goban, PlayerCard, ReviewTree},
|
||||||
|
ResourceManager,
|
||||||
|
};
|
||||||
|
use glib::Propagation;
|
||||||
|
use gtk::{gdk::Key, prelude::*, EventControllerKey};
|
||||||
use otg_core::{Color, GameReviewViewModel};
|
use otg_core::{Color, GameReviewViewModel};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -57,23 +63,54 @@ impl GameReview {
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct GameReview {
|
pub struct GameReview {
|
||||||
widget: gtk::Box,
|
widget: gtk::Box,
|
||||||
|
goban: Rc<RefCell<Option<Goban>>>,
|
||||||
|
|
||||||
resources: ResourceManager,
|
resources: ResourceManager,
|
||||||
view: GameReviewViewModel,
|
view: Rc<RefCell<GameReviewViewModel>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameReview {
|
impl GameReview {
|
||||||
pub fn new(view: GameReviewViewModel, resources: ResourceManager) -> Self {
|
pub fn new(view: GameReviewViewModel, resources: ResourceManager) -> Self {
|
||||||
let widget = gtk::Box::builder().build();
|
let widget = gtk::Box::builder().build();
|
||||||
|
|
||||||
|
let view = Rc::new(RefCell::new(view));
|
||||||
|
|
||||||
let s = Self {
|
let s = Self {
|
||||||
widget,
|
widget,
|
||||||
|
goban: Rc::new(RefCell::new(None)),
|
||||||
resources,
|
resources,
|
||||||
view,
|
view,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let keypress_controller = EventControllerKey::new();
|
||||||
|
keypress_controller.connect_key_pressed({
|
||||||
|
let s = s.clone();
|
||||||
|
move |_, key, _, _| {
|
||||||
|
println!("keystroke: {}", key);
|
||||||
|
let mut view = s.view.borrow_mut();
|
||||||
|
match key {
|
||||||
|
Key::Down => view.next_move(),
|
||||||
|
Key::Up => view.previous_move(),
|
||||||
|
Key::Left => view.previous_variant(),
|
||||||
|
Key::Right => view.next_variant(),
|
||||||
|
_ => {
|
||||||
|
return Propagation::Proceed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match *s.goban.borrow_mut() {
|
||||||
|
Some(ref mut goban) => goban.set_board_state(view.game_view()),
|
||||||
|
None => {}
|
||||||
|
};
|
||||||
|
Propagation::Stop
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
s.widget.add_controller(keypress_controller);
|
||||||
|
|
||||||
s.render();
|
s.render();
|
||||||
|
|
||||||
s
|
s
|
||||||
|
@ -83,7 +120,7 @@ impl GameReview {
|
||||||
// It's actually really bad to be just throwing away errors. Panics make everyone unhappy.
|
// It's actually really bad to be just throwing away errors. Panics make everyone unhappy.
|
||||||
// This is not a fatal error, so I'll replace this `unwrap` call with something that
|
// This is not a fatal error, so I'll replace this `unwrap` call with something that
|
||||||
// renders the board and notifies the user of a problem that cannot be resolved.
|
// renders the board and notifies the user of a problem that cannot be resolved.
|
||||||
let board_repr = self.view.game_view();
|
let board_repr = self.view.borrow().game_view();
|
||||||
let board = Goban::new(board_repr, self.resources.clone());
|
let board = Goban::new(board_repr, self.resources.clone());
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -101,7 +138,7 @@ impl GameReview {
|
||||||
// The review tree needs to know the record for being able to render all of the nodes. Once
|
// The review tree needs to know the record for being able to render all of the nodes. Once
|
||||||
// keyboard input is being handled, the tree will have to be updated on each keystroke in
|
// keyboard input is being handled, the tree will have to be updated on each keystroke in
|
||||||
// order to show the user where they are within the game record.
|
// order to show the user where they are within the game record.
|
||||||
let review_tree = ReviewTree::new(self.view.clone());
|
let review_tree = ReviewTree::new(self.view.borrow().clone());
|
||||||
|
|
||||||
// I think most keyboard focus is going to end up being handled here in GameReview, as
|
// I think most keyboard focus is going to end up being handled here in GameReview, as
|
||||||
// keystrokes need to affect both the goban and the review tree simultanesouly. Possibly
|
// keystrokes need to affect both the goban and the review tree simultanesouly. Possibly
|
||||||
|
@ -114,14 +151,19 @@ impl GameReview {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
player_information_section
|
player_information_section
|
||||||
.append(&PlayerCard::new(Color::Black, &self.view.black_player()));
|
.append(&PlayerCard::new(Color::Black, &self.view.borrow().black_player()));
|
||||||
player_information_section
|
player_information_section
|
||||||
.append(&PlayerCard::new(Color::White, &self.view.white_player()));
|
.append(&PlayerCard::new(Color::White, &self.view.borrow().white_player()));
|
||||||
|
|
||||||
self.widget.append(&board);
|
self.widget.append(&board);
|
||||||
sidebar.append(&player_information_section);
|
sidebar.append(&player_information_section);
|
||||||
sidebar.append(&review_tree.widget());
|
sidebar.append(&review_tree.widget());
|
||||||
self.widget.append(&sidebar);
|
self.widget.append(&sidebar);
|
||||||
|
|
||||||
|
*self.goban.borrow_mut() = Some(board);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redraw(&self) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn widget(&self) -> gtk::Widget {
|
pub fn widget(&self) -> gtk::Widget {
|
||||||
|
|
|
@ -4,7 +4,7 @@ mod game;
|
||||||
pub use game::{GameNode, GameRecord, GameTree, MoveNode, Player};
|
pub use game::{GameNode, GameRecord, GameTree, MoveNode, Player};
|
||||||
|
|
||||||
mod parser;
|
mod parser;
|
||||||
pub use parser::{parse_collection, Move};
|
pub use parser::{parse_collection, Move, Size};
|
||||||
|
|
||||||
mod types;
|
mod types;
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|
|
@ -302,10 +302,11 @@ impl Move {
|
||||||
Move::Move(s) => {
|
Move::Move(s) => {
|
||||||
if s.len() == 2 {
|
if s.len() == 2 {
|
||||||
let mut parts = s.chars();
|
let mut parts = s.chars();
|
||||||
let row_char = parts.next().unwrap();
|
|
||||||
let row = row_char as u8 - b'a';
|
|
||||||
let column_char = parts.next().unwrap();
|
let column_char = parts.next().unwrap();
|
||||||
let column = column_char as u8 - b'a';
|
let column = column_char as u8 - b'a';
|
||||||
|
let row_char = parts.next().unwrap();
|
||||||
|
let row = row_char as u8 - b'a';
|
||||||
|
println!("[{}] {} [{}] {}", row_char, row, column_char, column);
|
||||||
Some((row, column))
|
Some((row, column))
|
||||||
} else {
|
} else {
|
||||||
unimplemented!("moves must contain exactly two characters");
|
unimplemented!("moves must contain exactly two characters");
|
||||||
|
|
Loading…
Reference in New Issue