diff --git a/Cargo.lock b/Cargo.lock index 3fd74c3e2b..ea4e7007f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -993,6 +993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2de9deab977a153492a1468d1b1c0662c1cf39e5ea87d0c060ecd59ef18d8c" dependencies = [ "byteorder 1.3.4", + "chrono", "diesel_derives", "libsqlite3-sys", ] diff --git a/assets/voxygen/data/achievements.ron b/assets/voxygen/data/achievements.ron new file mode 100644 index 0000000000..d09dbf65b4 --- /dev/null +++ b/assets/voxygen/data/achievements.ron @@ -0,0 +1,10 @@ +[ + ( + title: "Collect 10 Apples", + achievement_type: Collect("common.items.apple", 10) + ), + ( + title: "Collect 50 Apples", + achievement_type: Collect("common.items.apple", 50) + ) +] \ No newline at end of file diff --git a/common/src/achievement.rs b/common/src/achievement.rs new file mode 100644 index 0000000000..556298f75a --- /dev/null +++ b/common/src/achievement.rs @@ -0,0 +1,23 @@ +use crate::comp::item::{Item, ItemKind}; + +// TODO: Kill(Race, amount) +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AchievementType { + Collect(String, i32), + ReachLevel(i32), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct AchievementItem { + pub title: String, + pub achievement_type: AchievementType, +} + +impl Default for AchievementItem { + fn default() -> Self { + Self { + title: String::new(), + achievement_type: AchievementType::ReachLevel(9999), + } + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index c2d04628e3..908f3d87af 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -11,6 +11,7 @@ option_zip )] +pub mod achievement; pub mod assets; pub mod astar; pub mod character; diff --git a/server/Cargo.toml b/server/Cargo.toml index 2a5215866a..7b7a105dd7 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -36,6 +36,6 @@ tiny_http = "0.7.0" portpicker = { git = "https://github.com/xMAC94x/portpicker-rs" } authc = { git = "https://gitlab.com/veloren/auth.git", rev = "223a4097f7ebc8d451936dccb5e6517194bbf086" } libsqlite3-sys = { version = "0.18", features = ["bundled"] } -diesel = { version = "1.4.3", features = ["sqlite"] } +diesel = { version = "1.4.3", features = ["sqlite", "chrono"] } diesel_migrations = "1.4.0" dotenv = "0.15.0" diff --git a/server/src/data/achievements.ron b/server/src/data/achievements.ron new file mode 100644 index 0000000000..d09dbf65b4 --- /dev/null +++ b/server/src/data/achievements.ron @@ -0,0 +1,10 @@ +[ + ( + title: "Collect 10 Apples", + achievement_type: Collect("common.items.apple", 10) + ), + ( + title: "Collect 50 Apples", + achievement_type: Collect("common.items.apple", 50) + ) +] \ No newline at end of file diff --git a/server/src/lib.rs b/server/src/lib.rs index 1817330397..68cf20d5f1 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -263,6 +263,15 @@ impl Server { info!(?e, "Migration error"); } + // Sync Achievement Data + debug!("Syncing Achievement data..."); + + if let Some(e) = + persistence::achievement::sync(&this.server_settings.persistence_db_dir).err() + { + info!(?e, "Achievement data migration error"); + } + debug!(?settings, "created veloren server with"); let git_hash = *common::util::GIT_HASH; diff --git a/server/src/migrations/2020-06-11-231641_achievements/down.sql b/server/src/migrations/2020-06-11-231641_achievements/down.sql new file mode 100644 index 0000000000..93bd839496 --- /dev/null +++ b/server/src/migrations/2020-06-11-231641_achievements/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "achievement"; \ No newline at end of file diff --git a/server/src/migrations/2020-06-11-231641_achievements/up.sql b/server/src/migrations/2020-06-11-231641_achievements/up.sql new file mode 100644 index 0000000000..63ae336bd1 --- /dev/null +++ b/server/src/migrations/2020-06-11-231641_achievements/up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS "achievement" ( + id INTEGER PRIMARY KEY NOT NULL, + checksum VARCHAR(64) NOT NULL UNIQUE, + details TEXT NOT NULL +); \ No newline at end of file diff --git a/server/src/migrations/2020-06-24-154355_data_migrations/down.sql b/server/src/migrations/2020-06-24-154355_data_migrations/down.sql new file mode 100644 index 0000000000..3ed077a60f --- /dev/null +++ b/server/src/migrations/2020-06-24-154355_data_migrations/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "data_migration"; \ No newline at end of file diff --git a/server/src/migrations/2020-06-24-154355_data_migrations/up.sql b/server/src/migrations/2020-06-24-154355_data_migrations/up.sql new file mode 100644 index 0000000000..cc1d55e22d --- /dev/null +++ b/server/src/migrations/2020-06-24-154355_data_migrations/up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "data_migration" ( + id INTEGER PRIMARY KEY NOT NULL, + title VARCHAR(64) NOT NULL, + checksum VARCHAR(64) NOT NULL, + last_run TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/server/src/persistence/achievement.rs b/server/src/persistence/achievement.rs new file mode 100644 index 0000000000..93fc306424 --- /dev/null +++ b/server/src/persistence/achievement.rs @@ -0,0 +1,121 @@ +extern crate diesel; + +use super::{ + error::Error, + establish_connection, + models::{Achievement as AchievementModel, DataMigration, NewAchievement, NewDataMigration}, + schema, +}; +use common::achievement::*; +use diesel::{ + prelude::*, + result::{DatabaseErrorKind, Error as DieselError}, +}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, +}; +use tracing::{info, warn}; + +pub fn sync(db_dir: &str) -> Result<(), Error> { + let achievements = load_data(); + let connection = establish_connection(db_dir); + + // Get a hash of the Vec and compare to the migration table + let result = schema::data_migration::dsl::data_migration + .filter(schema::data_migration::title.eq(String::from("achievements"))) + .load::(&connection)?; + + // First check whether the table has an entry for this data type + if result.is_empty() { + let migration = NewDataMigration { + title: "achievements", + checksum: &hash(&achievements).to_string(), + last_run: chrono::Utc::now().naive_utc(), + }; + + diesel::insert_into(schema::data_migration::table) + .values(&migration) + .execute(&connection)?; + } + + // Also check checksum. Bail if same, continue if changed + if result.is_empty() { + info!("Achievements need updating..."); + + // Use the full dataset for checks + let persisted_achievements = + schema::achievement::dsl::achievement.load::(&connection)?; + + // Make use of the unique constraint in the DB, attempt to insert, on unique + // failure check if it needs updating and do so if necessary + for item in &achievements { + let new_item = NewAchievement::from(item); + + if let Err(error) = diesel::insert_into(schema::achievement::table) + .values(&new_item) + .execute(&connection) + { + match error { + DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _) => { + let entry = persisted_achievements + .iter() + .find(|&a| &a.checksum == &new_item.checksum); + + if let Some(existing_item) = entry { + if existing_item.details != new_item.details { + match diesel::update( + schema::achievement::dsl::achievement.filter( + schema::achievement::checksum + .eq(String::from(&existing_item.checksum)), + ), + ) + .set(schema::achievement::details.eq(new_item.details)) + .execute(&connection) + { + Ok(_) => warn!(?existing_item.checksum, "Updated achievement"), + Err(err) => return Err(Error::DatabaseError(err)), + } + } + } + }, + _ => return Err(Error::DatabaseError(error)), + } + } + } + + // Update the checksum for the migration + diesel::update(schema::data_migration::dsl::data_migration) + .filter(schema::data_migration::title.eq(String::from("achievements"))) + .set(( + schema::data_migration::checksum.eq(hash(&achievements).to_string()), + schema::data_migration::last_run.eq(chrono::Utc::now().naive_utc()), + )) + .execute(&connection)?; + } else { + info!("No achievement updates required"); + } + + Ok(()) +} + +fn load_data() -> Vec { + if let Ok(path) = std::fs::canonicalize("../data/achievements.ron") { + let path = std::path::PathBuf::from(path); + + info!(?path, "Path: "); + + match std::fs::File::open(path) { + Ok(file) => ron::de::from_reader(file).expect("Error parsing achievement data"), + Err(error) => panic!(error.to_string()), + } + } else { + Vec::new() + } +} + +pub fn hash(t: &T) -> u64 { + let mut s = DefaultHasher::new(); + t.hash(&mut s); + s.finish() +} diff --git a/server/src/persistence/mod.rs b/server/src/persistence/mod.rs index 34c1d4377a..f5ede536e9 100644 --- a/server/src/persistence/mod.rs +++ b/server/src/persistence/mod.rs @@ -5,7 +5,7 @@ //! for managing table migrations //! - [`diesel-cli`](https://github.com/diesel-rs/diesel/tree/master/diesel_cli/) //! for generating and testing migrations - +pub mod achievement; pub mod character; mod error; diff --git a/server/src/persistence/models.rs b/server/src/persistence/models.rs index b5ad85b438..8c84f96ce4 100644 --- a/server/src/persistence/models.rs +++ b/server/src/persistence/models.rs @@ -1,8 +1,12 @@ extern crate serde_json; -use super::schema::{body, character, inventory, loadout, stats}; +use super::{ + achievement::hash, + schema::{achievement, body, character, data_migration, inventory, loadout, stats}, +}; use crate::comp; -use common::character::Character as CharacterData; +use chrono::NaiveDateTime; +use common::{achievement::AchievementItem, character::Character as CharacterData}; use diesel::sql_types::Text; use serde::{Deserialize, Serialize}; use tracing::warn; @@ -348,6 +352,91 @@ impl From<(i32, &comp::Loadout)> for LoadoutUpdate { } } +/// Generic datamigration entry +#[derive(Queryable, Debug, Identifiable)] +#[table_name = "data_migration"] +pub struct DataMigration { + pub id: i32, + pub title: String, + pub checksum: String, + pub last_run: NaiveDateTime, +} + +#[derive(Insertable, PartialEq, Debug)] +#[table_name = "data_migration"] +pub struct NewDataMigration<'a> { + pub title: &'a str, + pub checksum: &'a str, + pub last_run: NaiveDateTime, +} + +/// Achievements hold the data related to achievements available in-game. They +/// are the data referenced for characters +/// A wrapper type for Loadout components used to serialise to and from JSON +/// If the column contains malformed JSON, a default loadout is returned, with +/// the starter sword set as the main weapon +#[derive(SqlType, AsExpression, Debug, Deserialize, Serialize, FromSqlRow, PartialEq)] +#[sql_type = "Text"] +pub struct AchievementData(AchievementItem); + +impl diesel::deserialize::FromSql for AchievementData +where + DB: diesel::backend::Backend, + String: diesel::deserialize::FromSql, +{ + fn from_sql( + bytes: Option<&::RawValue>, + ) -> diesel::deserialize::Result { + let t = String::from_sql(bytes)?; + + match serde_json::from_str(&t) { + Ok(data) => Ok(Self(data)), + Err(e) => { + warn!(?e, "Failed to deserialise achevement data"); + + Ok(Self(AchievementItem::default())) + }, + } + } +} + +impl diesel::serialize::ToSql for AchievementData +where + DB: diesel::backend::Backend, +{ + fn to_sql( + &self, + out: &mut diesel::serialize::Output, + ) -> diesel::serialize::Result { + let s = serde_json::to_string(&self.0)?; + >::to_sql(&s, out) + } +} + +#[derive(Queryable, Debug, Identifiable)] +#[table_name = "achievement"] +pub struct Achievement { + pub id: i32, + pub checksum: String, + pub details: AchievementData, +} + +#[derive(Insertable, PartialEq, Debug)] +#[table_name = "achievement"] +pub struct NewAchievement { + pub checksum: String, + pub details: AchievementData, +} + +impl From<&AchievementItem> for NewAchievement { + fn from(item: &AchievementItem) -> Self { + Self { + checksum: hash(item).to_string(), + details: AchievementData(item.clone()), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/persistence/schema.rs b/server/src/persistence/schema.rs index 09ebfdc871..4aa04f420b 100644 --- a/server/src/persistence/schema.rs +++ b/server/src/persistence/schema.rs @@ -1,3 +1,11 @@ +table! { + achievement (id) { + id -> Integer, + checksum -> Text, + details -> Text, + } +} + table! { body (character_id) { character_id -> Integer, @@ -22,6 +30,15 @@ table! { } } +table! { + data_migration (id) { + id -> Integer, + title -> Text, + checksum -> Text, + last_run -> Timestamp, + } +} + table! { inventory (character_id) { character_id -> Integer, @@ -54,4 +71,4 @@ joinable!(inventory -> character (character_id)); joinable!(loadout -> character (character_id)); joinable!(stats -> character (character_id)); -allow_tables_to_appear_in_same_query!(body, character, inventory, loadout, stats); +allow_tables_to_appear_in_same_query!(achievement, body, character, inventory, loadout, stats);