Set up the library view again

This commit is contained in:
Savanni D'Gerinel 2024-03-21 22:39:28 -04:00
parent 225a6744a9
commit f2f1ae809b
18 changed files with 447 additions and 80 deletions

View File

@ -16,8 +16,8 @@ You should have received a copy of the GNU General Public License along with Kif
use crate::{ use crate::{
database::Database, database::Database,
library, settings,
types::{AppState, Config, ConfigOption, GameState, LibraryPath, Player, Rank}, types::{AppState, Config, ConfigOption, GameState, LibraryPath, Player, Rank},
settings,
}; };
use async_std::{ use async_std::{
channel::{Receiver, Sender}, channel::{Receiver, Sender},
@ -37,7 +37,8 @@ pub trait Observable<T> {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum CoreRequest { pub enum CoreRequest {
Settings(settings::SettingsRequest) Library(library::LibraryRequest),
Settings(settings::SettingsRequest),
/* /*
ChangeSetting(ChangeSettingRequest), ChangeSetting(ChangeSettingRequest),
CreateGame(CreateGameRequest), CreateGame(CreateGameRequest),
@ -49,6 +50,7 @@ pub enum CoreRequest {
*/ */
} }
/*
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChangeSettingRequest { pub enum ChangeSettingRequest {
LibraryPath(String), LibraryPath(String),
@ -85,16 +87,18 @@ impl From<HotseatPlayerRequest> for Player {
} }
} }
} }
*/
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum CoreResponse { pub enum CoreResponse {
Settings(settings::SettingsResponse) Library(library::LibraryResponse),
/* Settings(settings::SettingsResponse),
ConfigurationView(ConfigurationView), }
HomeView(HomeView),
PlayingFieldView(PlayingFieldView), impl From<library::LibraryResponse> for CoreResponse {
UpdatedConfigurationView(ConfigurationView), fn from(r: library::LibraryResponse) -> Self {
*/ Self::Library(r)
}
} }
impl From<settings::SettingsResponse> for CoreResponse { impl From<settings::SettingsResponse> for CoreResponse {
@ -120,10 +124,18 @@ pub struct Core {
impl Core { impl Core {
pub fn new(config: Config) -> Self { pub fn new(config: Config) -> Self {
println!("config: {:?}", config); println!("config: {:?}", config);
let library = if let Some(ref path) = config.get::<LibraryPath>() {
println!("loading initial library");
Some(Database::open_path(path.to_path_buf()).unwrap())
} else {
None
};
Self { Self {
config: Arc::new(RwLock::new(config)), config: Arc::new(RwLock::new(config)),
// state, // state,
library: Arc::new(RwLock::new(None)), library: Arc::new(RwLock::new(library)),
subscribers: Arc::new(RwLock::new(vec![])), subscribers: Arc::new(RwLock::new(vec![])),
} }
} }
@ -140,11 +152,20 @@ impl Core {
/// configuration is not a decision for the core library. /// configuration is not a decision for the core library.
pub async fn set_config(&self, config: Config) { pub async fn set_config(&self, config: Config) {
*self.config.write().unwrap() = config.clone(); *self.config.write().unwrap() = config.clone();
let subscribers = self.subscribers.read().unwrap().clone();
for subscriber in subscribers { // let db = library::read_library(self.config.read().unwrap().get::<LibraryPath>()).await;
let subscriber = subscriber.clone(); let library_path = self.config.read().unwrap().get::<LibraryPath>();
let _ = subscriber.send(CoreNotification::ConfigurationUpdated(config.clone())).await; if let Some(ref path) = library_path {
self.load_library(path);
} }
self.notify(CoreNotification::ConfigurationUpdated(config.clone()))
.await;
}
fn load_library(&self, path: &LibraryPath) {
let db = Database::open_path(path.to_path_buf()).unwrap();
*self.library.write().unwrap() = Some(db);
} }
pub fn library<'a>(&'a self) -> RwLockReadGuard<'_, Option<Database>> { pub fn library<'a>(&'a self) -> RwLockReadGuard<'_, Option<Database>> {
@ -153,10 +174,19 @@ impl Core {
pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse { pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse {
match request { match request {
CoreRequest::Library(request) => library::handle(&self, request).await.into(),
CoreRequest::Settings(request) => settings::handle(&self, request).await.into(), CoreRequest::Settings(request) => settings::handle(&self, request).await.into(),
} }
} }
pub async fn notify(&self, notification: CoreNotification) {
let subscribers = self.subscribers.read().unwrap().clone();
for subscriber in subscribers {
let subscriber = subscriber.clone();
let _ = subscriber.send(notification.clone()).await;
}
}
/* /*
pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse { pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse {
match request { match request {

View File

@ -1,3 +1,19 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of Kifu.
Kifu is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Kifu is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>.
*/
extern crate config_derive; extern crate config_derive;
mod api; mod api;
@ -8,6 +24,8 @@ pub use board::*;
mod database; mod database;
pub mod library;
mod types; mod types;
pub use types::{BoardError, Color, Config, ConfigOption, LibraryPath, Player, Rank, Size}; pub use types::{BoardError, Color, Config, ConfigOption, LibraryPath, Player, Rank, Size};

50
kifu/core/src/library.rs Normal file
View File

@ -0,0 +1,50 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of Kifu.
Kifu is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Kifu is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{Core, Config};
use serde::{Deserialize, Serialize};
use sgf::GameInfo;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum LibraryRequest {
ListGames
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum LibraryResponse {
Games(Vec<GameInfo>)
}
async fn handle_list_games(model: &Core) -> LibraryResponse {
println!("handle_list_games");
let library = model.library();
println!("library: {:?}", *library);
match *library {
Some(ref library) => {
let info = library.all_games().map(|g| g.info.clone()).collect::<Vec<GameInfo>>();
LibraryResponse::Games(info)
}
None => LibraryResponse::Games(vec![]),
}
}
pub async fn handle(model: &Core, request: LibraryRequest) -> LibraryResponse {
match request {
LibraryRequest::ListGames => handle_list_games(model).await,
}
}

View File

@ -1,5 +1,4 @@
use crate::{ use crate::{
api::PlayStoneRequest,
board::{Coordinate, Goban}, board::{Coordinate, Goban},
database::Database, database::Database,
}; };
@ -85,6 +84,7 @@ impl AppState {
} }
} }
/*
pub fn place_stone(&mut self, req: PlayStoneRequest) { pub fn place_stone(&mut self, req: PlayStoneRequest) {
if let Some(ref mut game) = self.game { if let Some(ref mut game) = self.game {
let _ = game.place_stone(Coordinate { let _ = game.place_stone(Coordinate {
@ -93,6 +93,7 @@ impl AppState {
}); });
} }
} }
*/
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@ -22,12 +22,11 @@ use kifu_core::CoreResponse;
use kifu_core::{settings::SettingsRequest, Config, CoreRequest}; use kifu_core::{settings::SettingsRequest, Config, CoreRequest};
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use crate::view_models::HomeViewModel; use crate::views::{SettingsView, HomeView};
use crate::views::SettingsView;
#[derive(Clone)] #[derive(Clone)]
enum AppView { enum AppView {
Home(HomeViewModel), Home,
} }
// An application window should generally contain // An application window should generally contain
@ -92,10 +91,11 @@ impl AppWindow {
glib::spawn_future_local({ glib::spawn_future_local({
let s = self.clone(); let s = self.clone();
async move { async move {
let CoreResponse::Settings(SettingsResponse(settings)) = s if let CoreResponse::Settings(SettingsResponse(settings)) = s
.core .core
.dispatch(CoreRequest::Settings(SettingsRequest::Get)) .dispatch(CoreRequest::Settings(SettingsRequest::Get))
.await; .await
{
let view_model = SettingsView::new( let view_model = SettingsView::new(
&s.window, &s.window,
settings, settings,
@ -105,9 +105,11 @@ impl AppWindow {
glib::spawn_future_local({ glib::spawn_future_local({
let s = s.clone(); let s = s.clone();
async move { async move {
s.core.dispatch(CoreRequest::Settings(SettingsRequest::Set( s.core
.dispatch(CoreRequest::Settings(SettingsRequest::Set(
config, config,
))).await )))
.await
} }
}); });
s.close_overlay(); s.close_overlay();
@ -123,6 +125,7 @@ impl AppWindow {
s.panel_overlay.add_overlay(&view_model); s.panel_overlay.add_overlay(&view_model);
*s.settings_view_model.write().unwrap() = Some(view_model); *s.settings_view_model.write().unwrap() = Some(view_model);
} }
}
}); });
} }
@ -171,17 +174,17 @@ impl AppWindow {
fn setup_content(core: CoreApi) -> (adw::NavigationView, Vec<AppView>) { fn setup_content(core: CoreApi) -> (adw::NavigationView, Vec<AppView>) {
let stack = adw::NavigationView::new(); let stack = adw::NavigationView::new();
let mut content = Vec::new(); let content = Vec::new();
let nothing_page = adw::StatusPage::builder().title("Nothing here").build(); let home = HomeView::new(core.clone());
let _ = stack.push( let _ = stack.push(
&adw::NavigationPage::builder() &adw::NavigationPage::builder()
.can_pop(false) .can_pop(false)
.title("Kifu") .title("Kifu")
.child(&nothing_page) .child(&home)
.build(), .build(),
); );
content.push(AppView::Home(HomeViewModel::new(core))); // content.push(AppView::Home(HomeViewModel::new(core)));
(stack, content) (stack, content)
} }

View File

@ -1,12 +1,13 @@
use adw::{prelude::*, subclass::prelude::*}; use adw::{prelude::*, subclass::prelude::*};
use glib::Object; use glib::Object;
use gtk::glib; use gtk::glib;
use kifu_core::ui::GamePreviewElement; // use kifu_core::ui::GamePreviewElement;
use sgf::GameInfo;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
#[derive(Default)] #[derive(Default)]
pub struct GameObjectPrivate { pub struct GameObjectPrivate {
game: Rc<RefCell<Option<GamePreviewElement>>>, game: Rc<RefCell<Option<GameInfo>>>,
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -22,13 +23,13 @@ glib::wrapper! {
} }
impl GameObject { impl GameObject {
pub fn new(game: GamePreviewElement) -> Self { pub fn new(game: GameInfo) -> Self {
let s: Self = Object::builder().build(); let s: Self = Object::builder().build();
*s.imp().game.borrow_mut() = Some(game); *s.imp().game.borrow_mut() = Some(game);
s s
} }
pub fn game(&self) -> Option<GamePreviewElement> { pub fn game(&self) -> Option<GameInfo> {
self.imp().game.borrow().clone() self.imp().game.borrow().clone()
} }
} }
@ -77,11 +78,14 @@ impl Default for LibraryPrivate {
*/ */
let selection_model = gtk::NoSelection::new(Some(model.clone())); let selection_model = gtk::NoSelection::new(Some(model.clone()));
let list_view = gtk::ColumnView::builder().model(&selection_model).build(); let list_view = gtk::ColumnView::builder()
.model(&selection_model)
.hexpand(true)
.build();
fn make_factory<F>(bind: F) -> gtk::SignalListItemFactory fn make_factory<F>(bind: F) -> gtk::SignalListItemFactory
where where
F: Fn(GamePreviewElement) -> String + 'static, F: Fn(GameInfo) -> String + 'static,
{ {
let factory = gtk::SignalListItemFactory::new(); let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(|_, list_item| { factory.connect_setup(|_, list_item| {
@ -106,25 +110,61 @@ impl Default for LibraryPrivate {
factory factory
} }
list_view.append_column(&gtk::ColumnViewColumn::new( list_view.append_column(
Some("date"), &gtk::ColumnViewColumn::builder()
Some(make_factory(|g| g.date)), .title("date")
)); .factory(&make_factory(|g| {
list_view.append_column(&gtk::ColumnViewColumn::new( g.date
Some("title"), .iter()
Some(make_factory(|g| g.name)), .map(|date| {
)); format!("{}", date)
list_view.append_column(&gtk::ColumnViewColumn::new( /*
Some("black"), let l = locale!("en-US").into();
Some(make_factory(|g| g.black_player)), let options = length::Bag::from_date_style(length::Date::Medium);
)); let date = Date::try_new_iso_date(date.
list_view.append_column(&gtk::ColumnViewColumn::new( let dtfmt =
Some("white"), DateFormatter::try_new_with_length(&l, options).unwrap();
Some(make_factory(|g| g.white_player)), dtfmt.format(date).unwrap()
)); */
})
.collect::<Vec<String>>()
.join(", ")
}))
.expand(true)
.build(),
);
list_view.append_column(
&gtk::ColumnViewColumn::builder()
.title("game")
.factory(&make_factory(|g| {
g.game_name.unwrap_or("Unnamed".to_owned())
}))
.expand(true)
.build(),
);
list_view.append_column(
&gtk::ColumnViewColumn::builder()
.title("black")
.factory(&make_factory(|g| {
g.black_player.unwrap_or("Black".to_owned())
}))
.expand(true)
.build(),
);
list_view.append_column(
&gtk::ColumnViewColumn::builder()
.title("white")
.factory(&make_factory(|g| {
g.white_player.unwrap_or("White".to_owned())
}))
.expand(true)
.build(),
);
list_view.append_column(&gtk::ColumnViewColumn::new( list_view.append_column(&gtk::ColumnViewColumn::new(
Some("result"), Some("result"),
Some(make_factory(|g| g.result)), Some(make_factory(|g| {
g.result.map(|d| format!("{}", d)).unwrap_or("".to_owned())
})),
)); ));
Self { model, list_view } Self { model, list_view }
@ -156,7 +196,7 @@ impl Default for Library {
} }
impl Library { impl Library {
pub fn set_games(&self, games: Vec<GamePreviewElement>) { pub fn set_games(&self, games: Vec<GameInfo>) {
let games = games let games = games
.into_iter() .into_iter()
.map(GameObject::new) .map(GameObject::new)

View File

@ -7,8 +7,8 @@
// mod game_preview; // mod game_preview;
// pub use game_preview::GamePreview; // pub use game_preview::GamePreview;
// mod library; mod library;
// pub use library::Library; pub use library::Library;
// mod player_card; // mod player_card;
// pub use player_card::PlayerCard; // pub use player_card::PlayerCard;

View File

@ -14,7 +14,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>. You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>.
*/ */
pub mod ui; pub mod components;
mod app_window; mod app_window;
pub use app_window::AppWindow; pub use app_window::AppWindow;

208
kifu/gtk/src/views/home.rs Normal file
View File

@ -0,0 +1,208 @@
/*
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
This file is part of Kifu.
Kifu is free software: you can redistribute it and/or modify it under the terms of the GNU
General Public License as published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Kifu is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License along with Kifu. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::{components::Library, CoreApi};
use glib::Object;
use gtk::{glib, prelude::*, subclass::prelude::*};
use kifu_core::{
library::{LibraryRequest, LibraryResponse},
CoreRequest, CoreResponse,
};
use std::{cell::RefCell, rc::Rc};
/*
struct PlayerDataEntryPrivate {
name: gtk::Text,
rank: gtk::DropDown,
}
impl Default for PlayerDataEntryPrivate {
fn default() -> Self {
let rank = gtk::DropDown::builder().build();
Self {
name: gtk::Text::builder().build(),
rank,
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for PlayerDataEntryPrivate {
const NAME: &'static str = "PlayerDataEntry";
type Type = PlayerDataEntry;
type ParentType = gtk::Box;
}
impl ObjectImpl for PlayerDataEntryPrivate {}
impl WidgetImpl for PlayerDataEntryPrivate {}
impl BoxImpl for PlayerDataEntryPrivate {}
glib::wrapper! {
struct PlayerDataEntry(ObjectSubclass<PlayerDataEntryPrivate>) @extends gtk::Box, gtk::Widget;
}
impl PlayerDataEntry {
pub fn new(element: PlayerElement) -> PlayerDataEntry {
let s: Self = Object::builder().build();
let rank_model = gio::ListStore::new::<gtk::StringObject>();
s.imp().rank.set_model(Some(&rank_model));
match element {
PlayerElement::Hotseat(player) => {
if let Some(placeholder) = player.placeholder {
s.imp().name.set_placeholder_text(Some(&placeholder));
}
player.ranks.iter().for_each(|rank| rank_model.append(&gtk::StringObject::new(rank)));
}
// PlayerElement::Remote(_) => s.imp().placeholder.set_text("remote player"),
// PlayerElement::Bot(_) => s.imp().placeholder.set_text("bot player"),
}
s.append(&s.imp().name);
s.append(&s.imp().rank);
s
}
pub fn text(&self) -> String {
let name = self.imp().name.buffer().text().to_string();
if name.is_empty() {
self.imp()
.name
.placeholder_text()
.map(|s| s.to_string())
.unwrap_or("".to_owned())
} else {
name
}
}
pub fn rank(&self) -> Option<String> {
self.imp().rank.selected_item().and_then(|obj| {
let str_obj = obj.downcast::<gtk::StringObject>().ok()?;
Some(str_obj.string().clone().to_string())
})
}
}
*/
pub struct HomePrivate {
// black_player: Rc<RefCell<Option<PlayerDataEntry>>>,
// white_player: Rc<RefCell<Option<PlayerDataEntry>>>,
}
impl Default for HomePrivate {
fn default() -> Self {
Self {
// black_player: Rc::new(RefCell::new(None)),
// white_player: Rc::new(RefCell::new(None)),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for HomePrivate {
const NAME: &'static str = "Home";
type Type = HomeView;
type ParentType = gtk::Box;
}
impl ObjectImpl for HomePrivate {}
impl WidgetImpl for HomePrivate {}
impl BoxImpl for HomePrivate {}
glib::wrapper! {
pub struct HomeView(ObjectSubclass<HomePrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
}
impl HomeView {
pub fn new(api: CoreApi) -> Self {
let s: Self = Object::builder().build();
s.set_spacing(4);
s.set_homogeneous(false);
s.set_orientation(gtk::Orientation::Vertical);
/*
let players = gtk::Box::builder()
.spacing(4)
.orientation(gtk::Orientation::Horizontal)
.build();
s.append(&players);
let black_player = PlayerDataEntry::new(view.black_player);
players.append(&black_player);
*s.imp().black_player.borrow_mut() = Some(black_player.clone());
let white_player = PlayerDataEntry::new(view.white_player);
players.append(&white_player);
*s.imp().white_player.borrow_mut() = Some(white_player.clone());
let new_game_button = gtk::Button::builder()
.css_classes(vec!["suggested-action"])
.label(&view.start_game.label)
.build();
s.append(&new_game_button);
*/
let library = Library::default();
let library_view = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.min_content_width(360)
.vexpand(true)
.hexpand(true)
.child(&library)
.build();
s.append(&library_view);
glib::spawn_future_local({
let library = library.clone();
let api = api.clone();
async move {
if let CoreResponse::Library(LibraryResponse::Games(games)) = api
.dispatch(CoreRequest::Library(LibraryRequest::ListGames))
.await
{
library.set_games(games);
}
}
});
/*
new_game_button.connect_clicked({
move |_| {
let black_player = black_player.clone();
let white_player = white_player.clone();
api.dispatch(CoreRequest::CreateGame(CreateGameRequest {
black_player: player_info(black_player.clone()),
white_player: player_info(white_player.clone()),
}));
}
});
*/
s
}
}
/*
fn player_info(player: PlayerDataEntry) -> PlayerInfoRequest {
PlayerInfoRequest::Hotseat(HotseatPlayerRequest {
name: player.text(),
rank: player.rank(),
})
}
*/

View File

@ -1,2 +1,5 @@
mod home;
pub use home::HomeView;
mod settings; mod settings;
pub use settings::SettingsView; pub use settings::SettingsView;

View File

@ -1,4 +1,6 @@
use crate::date::Date; use crate::date::Date;
use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error; use thiserror::Error;
@ -9,7 +11,7 @@ pub struct Game {
pub info: GameInfo, pub info: GameInfo,
} }
#[derive(Debug, Default)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct GameInfo { pub struct GameInfo {
pub black_player: Option<String>, pub black_player: Option<String>,
pub black_rank: Option<String>, pub black_rank: Option<String>,
@ -129,7 +131,7 @@ impl Color {
} }
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum GameResult { pub enum GameResult {
Draw, Draw,
Black(Win), Black(Win),
@ -138,6 +140,18 @@ pub enum GameResult {
Unknown(String), Unknown(String),
} }
impl fmt::Display for GameResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GameResult::Draw => write!(f, "draw"),
GameResult::Black(_) => write!(f, "B"),
GameResult::White(_) => write!(f, "W"),
GameResult::Void => write!(f, "void"),
GameResult::Unknown(s) => write!(f, "{}", s),
}
}
}
impl TryFrom<&str> for GameResult { impl TryFrom<&str> for GameResult {
type Error = String; type Error = String;
fn try_from(s: &str) -> Result<GameResult, Self::Error> { fn try_from(s: &str) -> Result<GameResult, Self::Error> {
@ -165,7 +179,7 @@ impl TryFrom<&str> for GameResult {
} }
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Win { pub enum Win {
Score(f32), Score(f32),
Resignation, Resignation,