ron codec

This commit is contained in:
2025-01-13 23:44:40 +01:00
parent 5f17e49535
commit c523ad68e4
21 changed files with 416 additions and 89 deletions

View File

@@ -9,6 +9,7 @@ publish = false
crate-type = ["cdylib", "rlib"]
[dependencies]
moonboard-parser = { workspace = true, optional = true }
axum = { version = "0.7", optional = true }
console_error_panic_hook = "0.1"
leptos = { version = "0.7.3" }
@@ -23,16 +24,17 @@ http = "1"
serde = { version = "1", features = ["derive"] }
derive_more = { version = "1", features = ["display", "error", "from"] }
clap = { version = "4.5.7", features = ["derive"] }
serde_json = { version = "1", optional = true }
camino = { version = "1.1", optional = true }
type-toppings = { version = "0.2.1", features = ["result"] }
# Tracing
tracing = { version = "0.1", optional = true }
tracing-subscriber = { version = "0.3.18", features = [
"env-filter",
], optional = true }
ron = { version = "0.8" }
[dev-dependencies.serde_json]
version = "1"
[features]
hydrate = ["leptos/hydrate"]
@@ -43,7 +45,7 @@ ssr = [
"dep:tower-http",
"dep:leptos_axum",
"dep:camino",
"dep:serde_json",
"dep:moonboard-parser",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",

View File

@@ -20,11 +20,12 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
}
}
#[leptos::component]
#[component]
pub fn App() -> impl leptos::IntoView {
use leptos_meta::Stylesheet;
use leptos_meta::Title;
// TODO: look at tracing-subscriber-wasm
#[cfg(feature = "ssr")]
tracing::debug!("Rendering root component");
@@ -43,7 +44,9 @@ pub fn App() -> impl leptos::IntoView {
}
}
#[leptos::component]
#[component]
fn Ascend() -> impl leptos::IntoView {
leptos::view! { <crate::pages::wall::Wall /> }
use crate::pages::wall::Wall;
leptos::view! { <Wall /> }
}

View File

@@ -7,6 +7,8 @@ pub mod components {}
#[cfg(feature = "ssr")]
pub mod server;
pub mod models;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {

View File

@@ -0,0 +1,35 @@
//! Shared models between server and client code.
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct HoldPosition {
/// Starting from 0
pub row: u64,
/// Starting from 0
pub col: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Problem {
pub holds: BTreeMap<HoldPosition, HoldRole>,
}
/// The role of a hold on a route
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum HoldRole {
/// Start hold
Start,
/// Any hold on the route without a specific role
Normal,
/// Zone hold
Zone,
/// End hold
End,
}

View File

@@ -1,21 +1,128 @@
use crate::models;
use leptos::prelude::*;
use ron_codec::RonCodec;
#[leptos::component]
#[component]
pub fn Wall() -> impl leptos::IntoView {
// TODO: What to do about this unwrap?
// let random_problem = OnceResource::new(async move { get_random_problem().await.unwrap() });
let random_problem = async move { get_random_problem().await.unwrap() };
// TODO: No hardcoding wall dimensions
let cells = (1..=(12 * 12))
.map(|i| {
let i = i.to_string();
view! {
<div class="aspect-square rounded border-2 border-dashed border-sky-500 bg-indigo-100">
{i}
</div>
}
view! { <Cell label=i/> }
})
.collect_view();
leptos::view! {
<Await future=random_problem let:foo>
<p>"Got a problem!"</p>
</Await>
<div class="container mx-auto border">
<div class="grid grid-rows-4 grid-cols-12 gap-4">{cells}</div>
</div>
}
}
#[component]
fn Cell(label: String) -> impl leptos::IntoView {
view! {
<div class="aspect-square rounded border-2 border-dashed border-sky-500 bg-indigo-100">
{label}
</div>
}
}
#[server]
pub async fn get_random_problem() -> Result<RonCodec<models::Problem>, ServerFnError> {
use crate::server::state::State;
// TODO: Actually randomize
let state = expect_context::<State>();
let persistent_state = state.persistent.get().await;
let problem = persistent_state.wall.problems.iter().next().unwrap();
Ok(RonCodec::new(problem.to_owned()))
}
mod ron_codec {
//! Wrap T in RonCodec<T> that when serialized, always serializes to a [ron] string.
#[derive(Debug, Clone)]
pub struct RonCodec<T> {
t: T,
}
impl<T> RonCodec<T> {
pub fn into_inner(self) -> T {
self.t
}
pub fn new(t: T) -> Self {
Self { t }
}
}
impl<T> serde::Serialize for RonCodec<T>
where
T: serde::Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let serialized = ron::to_string(&self.t).map_err(serde::ser::Error::custom)?;
serializer.serialize_str(&serialized)
}
}
impl<'de, T> serde::Deserialize<'de> for RonCodec<T>
where
T: serde::de::DeserializeOwned + 'static,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let t: T = ron::from_str(&s).map_err(serde::de::Error::custom)?;
Ok(Self { t })
}
}
#[cfg(test)]
mod tests {
use super::RonCodec;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
struct TestStruct {
name: String,
value: i32,
}
#[test]
fn test_ron_codec() {
let original = TestStruct {
name: "Test".to_string(),
value: 42,
};
// Wrap in RonCodec
let wrapped = RonCodec::new(original.clone());
// Serialize
let serialized = serde_json::to_string(&wrapped).expect("Serialization failed");
println!("Serialized: {}", serialized);
// Deserialize
let deserialized: RonCodec<TestStruct> = serde_json::from_str(&serialized).expect("Deserialization failed");
// Compare
assert_eq!(deserialized.into_inner(), original);
}
}
}

View File

@@ -1,13 +1,22 @@
//! Server-only features
use crate::models;
use models::HoldPosition;
use models::HoldRole;
use models::Problem;
use persistence::Persistent;
use state::PersistentState;
use state::State;
use std::collections::BTreeMap;
use std::path::Path;
use tracing::level_filters::LevelFilter;
use type_toppings::ResultExt;
pub mod cli {
//! Server CLI interface
use std::path::PathBuf;
#[derive(clap::Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
@@ -22,6 +31,10 @@ pub mod cli {
/// Resets state, replacing it with defaults
ResetState,
ImportMiniMoonboardProblems {
file_path: PathBuf,
},
}
}
@@ -29,6 +42,7 @@ pub mod state {
//! Server state
use super::persistence::Persistent;
use crate::models;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
@@ -46,59 +60,25 @@ pub mod state {
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Wall {
pub rows: u32,
pub cols: u32,
pub holds: BTreeMap<HoldPosition, Hold>,
pub routes: BTreeSet<route::Route>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct HoldPosition {
pub row: u32,
pub col: u32,
pub rows: u64,
pub cols: u64,
pub holds: BTreeMap<models::HoldPosition, Hold>,
pub problems: BTreeSet<models::Problem>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Hold {
pub position: HoldPosition,
pub position: models::HoldPosition,
pub image: Option<Image>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Image {}
mod route {
use super::HoldPosition;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Route {
pub holds: BTreeMap<HoldPosition, HoldRole>,
}
/// The role of a hold on a route
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum HoldRole {
/// Start hold
Start,
/// Any hold on the route without a specific role
Normal,
/// Zone hold
Zone,
/// End hold
End,
}
}
}
pub mod persistence;
pub const STATE_FILE: &str = "state.json";
pub const STATE_FILE: &str = "state.ron";
#[tracing::instrument]
pub async fn main() {
@@ -120,6 +100,17 @@ pub async fn main() {
let cli = Cli::parse();
match cli.command {
Command::Serve => serve().await.unwrap_or_report(),
Command::ImportMiniMoonboardProblems { file_path } => {
let problems = import_mini_moonboard_problems(&file_path).await.unwrap_or_report();
let state = load_state().await.unwrap_or_report();
state
.persistent
.update(|s| {
s.wall.problems.extend(problems);
})
.await
.unwrap_or_report();
}
Command::ResetState => {
let s = PersistentState::default();
let p = camino::Utf8Path::new(STATE_FILE);
@@ -133,7 +124,6 @@ pub async fn main() {
async fn serve() -> Result<(), Error> {
use crate::app::App;
use crate::app::shell;
use crate::server::state::State;
use axum::Router;
use leptos::prelude::*;
use leptos_axum::LeptosRoutes;
@@ -149,10 +139,7 @@ async fn serve() -> Result<(), Error> {
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
tracing::info!("Loading state");
let server_state = State {
persistent: Persistent::<PersistentState>::load(STATE_FILE.into()).await?,
};
let server_state = load_state().await?;
// build our application with a route
let app = Router::new()
@@ -170,9 +157,46 @@ async fn serve() -> Result<(), Error> {
Ok(())
}
#[tracing::instrument]
async fn load_state() -> Result<State, Error> {
tracing::info!("Loading state");
let state = State {
persistent: Persistent::<PersistentState>::load(STATE_FILE.into()).await?,
};
Ok(state)
}
#[tracing::instrument]
async fn import_mini_moonboard_problems(file_path: &Path) -> Result<Vec<Problem>, Error> {
let mut problems = Vec::new();
tracing::info!("Parsing mini moonboard problems from {}", file_path.display());
let mini_moonboard = moonboard_parser::mini_moonboard::parse(file_path).await?;
for problem in mini_moonboard.problems {
let mut holds = BTreeMap::<HoldPosition, HoldRole>::new();
for mv in problem.moves {
let row = mv.description.row();
let col = mv.description.column();
let hold_position = HoldPosition { row, col };
let role = match (mv.is_start, mv.is_end) {
(true, true) => unreachable!(),
(true, false) => HoldRole::Start,
(false, true) => HoldRole::End,
(false, false) => HoldRole::Normal,
};
holds.insert(hold_position, role);
}
let route = Problem { holds };
problems.push(route);
}
Ok(problems)
}
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
#[display("Server crash")]
pub enum Error {
Io(std::io::Error),
Persistence(persistence::Error),
Parser(moonboard_parser::Error),
}

View File

@@ -31,7 +31,7 @@ impl<T> Persistent<T> {
source,
})?;
let t = serde_json::from_str(&content).map_err(|source| Error::Deserialize {
let t = ron::from_str(&content).map_err(|source| Error::Deserialize {
file_path: file_path.to_owned(),
source,
})?;
@@ -43,6 +43,7 @@ impl<T> Persistent<T> {
Ok(persistent)
}
// TODO: This is pretty poor - clones the entire state on access
/// Returns state
#[tracing::instrument(skip(self))]
pub async fn get(&self) -> T
@@ -53,7 +54,7 @@ impl<T> Persistent<T> {
state.clone()
}
/// Returns state
/// Returns state passed through given function
#[tracing::instrument(skip_all)]
pub async fn with<F, R>(&self, f: F) -> R
where
@@ -96,7 +97,8 @@ impl<T> Persistent<T> {
where
T: Serialize,
{
let serialized = serde_json::to_string(state).map_err(|source| Error::Serialize { source })?;
tracing::debug!("Persisting state");
let serialized = ron::to_string(state).map_err(|source| Error::Serialize { source })?;
tokio::fs::write(file_path, serialized).await.map_err(|source| Error::Write {
file_path: file_path.to_owned(),
source,
@@ -112,10 +114,13 @@ pub enum Error {
Read { file_path: Utf8PathBuf, source: std::io::Error },
#[display("Failed to deserialize state from file: {file_path}")]
Deserialize { file_path: Utf8PathBuf, source: serde_json::Error },
Deserialize {
file_path: Utf8PathBuf,
source: ron::error::SpannedError,
},
#[display("Failed to serialize state")]
Serialize { source: serde_json::Error },
Serialize { source: ron::Error },
#[display("Failed to write file: {file_path}")]
Write { file_path: Utf8PathBuf, source: std::io::Error },