diff --git a/common/src/comp/achievement.rs b/common/src/comp/achievement.rs index 656b730b9b..91ec301566 100644 --- a/common/src/comp/achievement.rs +++ b/common/src/comp/achievement.rs @@ -1,13 +1,13 @@ use crate::comp::item::{Consumable, Item, ItemKind}; use hashbrown::HashMap; use serde::{Deserialize, Serialize}; -use specs::{Component, Entity, FlaggedStorage}; +use specs::{Component, Entity}; use specs_idvs::IdvStorage; /// Used for in-game events that contribute towards player achievements. /// -/// For example, when an `InventoryManip` is detected, we record that event in -/// order to process achievements which depend on collecting items. +/// For example, when an item is collected in the world, we a trigger +/// to process an entities achievements which depend on collecting items. pub struct AchievementTrigger { pub entity: Entity, pub event: AchievementEvent, @@ -75,8 +75,18 @@ pub struct CharacterAchievement { pub progress: usize, } +impl From<&Achievement> for CharacterAchievement { + fn from(achievement: &Achievement) -> Self { + Self { + achievement: achievement.clone(), + completed: false, + progress: 0, + } + } +} + impl CharacterAchievement { - /// Increment the progress of this Achievement based on its type + /// Increment the progress of this item based on its type /// /// By default, when an achievement is incremented, its `progress` value is /// incremented by 1. This covers many cases, but using this method allows @@ -104,18 +114,6 @@ impl CharacterAchievement { } } -/// For initialisation of a new CharacterAchievement item when a new achievement -/// is progressed -impl From for CharacterAchievement { - fn from(achievement: Achievement) -> Self { - Self { - achievement, - completed: false, - progress: 0, - } - } -} - /// Each character is assigned an achievement list, which holds information /// about which achievements that the player has made some progress on, or /// completed. @@ -127,11 +125,13 @@ impl Default for AchievementList { } impl AchievementList { - pub fn from(data: HashMap) -> Self { Self(data) } + pub fn new(items: HashMap) -> Self { Self(items) } + + pub fn items(&self) -> HashMap { self.0.clone() } } impl Component for AchievementList { - type Storage = FlaggedStorage>; // TODO check + type Storage = IdvStorage; } impl AchievementList { @@ -150,11 +150,17 @@ impl AchievementList { achievement: &Achievement, event: &AchievementEvent, ) -> Option { + tracing::info!(?achievement, "Processing achievement"); + let uuid = achievement.uuid.clone(); self.0 .entry(uuid) - .or_insert(CharacterAchievement::from(achievement.clone())) + .or_insert(CharacterAchievement { + achievement: achievement.clone(), + completed: false, + progress: 0, + }) .increment_progress(event) .cloned() } diff --git a/common/src/state.rs b/common/src/state.rs index edae2d01b0..03d2a4c526 100644 --- a/common/src/state.rs +++ b/common/src/state.rs @@ -158,6 +158,7 @@ impl State { ecs.register::(); ecs.register::(); ecs.register::(); + ecs.register::(); // Register synced resources used by the ECS. ecs.insert(TimeOfDay(0.0)); diff --git a/server/src/events/player.rs b/server/src/events/player.rs index aae303c47f..db0e48d8f3 100644 --- a/server/src/events/player.rs +++ b/server/src/events/player.rs @@ -67,17 +67,25 @@ pub fn handle_client_disconnect(server: &mut Server, entity: EcsEntity) -> Event } // Sync the player's character data to the database - if let (Some(player), Some(stats), Some(inventory), Some(loadout), updater) = ( + if let ( + Some(player), + Some(stats), + Some(inventory), + Some(loadout), + Some(achievement_list), + updater, + ) = ( state.read_storage::().get(entity), state.read_storage::().get(entity), state.read_storage::().get(entity), state.read_storage::().get(entity), + state.read_storage::().get(entity), state .ecs() .read_resource::(), ) { if let Some(character_id) = player.character_id { - updater.update(character_id, stats, inventory, loadout); + updater.update(character_id, stats, inventory, loadout, achievement_list); } } diff --git a/server/src/lib.rs b/server/src/lib.rs index f357b0f690..d627566768 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -40,6 +40,7 @@ use common::{ use futures_executor::block_on; use futures_timer::Delay; use futures_util::{select, FutureExt}; +use hashbrown::HashSet; use metrics::{ServerMetrics, TickMetrics}; use network::{Address, Network, Pid}; use persistence::{ @@ -90,8 +91,8 @@ pub struct Server { metrics: ServerMetrics, tick_metrics: TickMetrics, - /// Holds a list of all available achievements - achievement_data: Vec, + /// A list of all achievements available to characetrs on this server. + achievement_data: HashSet, } impl Server { @@ -266,11 +267,14 @@ impl Server { debug!("Syncing Achievement data..."); let achievement_data = match persistence::achievement::sync(&settings.persistence_db_dir) { - Ok(achievements) => achievements, + Ok(achievements) => achievements + .iter() + .map(comp::CharacterAchievement::from) + .collect::>(), Err(e) => { - warn!(?e, "Achievement data migration error"); + warn!(?e, "Achievement data migration or load error"); - Vec::new() + HashSet::new() }, }; @@ -445,8 +449,8 @@ impl Server { for trigger in achievement_events { // Get the achievement that matches this event - for achievement in &self.achievement_data { - if achievement.matches_event(&trigger.event) { + for item in &self.achievement_data { + if item.achievement.matches_event(&trigger.event) { // Calls to `process_achievement` return true to indicate that the // achievement is complete. In this case, we notify the client to notify them of // completing the achievement @@ -457,7 +461,7 @@ impl Server { .get_mut(trigger.entity) { if let Some(character_achievement) = - achievement_list.process_achievement(achievement, &trigger.event) + achievement_list.process_achievement(&item.achievement, &trigger.event) { self.notify_client( trigger.entity, @@ -526,10 +530,22 @@ impl Server { entity, result, )) => match result { - Ok(achievement_data) => self.notify_client( - entity, - ServerMsg::CharacterAchievementDataLoaded(achievement_data), - ), + Ok(character_achievements) => { + // Merge the character's achievement data with the server list + + tracing::info!(?character_achievements, "character_achievements"); + tracing::info!(?self.achievement_data, "achievement_data"); + + self.notify_client( + entity, + ServerMsg::CharacterAchievementDataLoaded(comp::AchievementList::new( + self.achievement_data + .union(&character_achievements) + .map(|ca| (ca.achievement.uuid.to_owned(), ca.clone())) + .collect(), + )), + ) + }, Err(error) => self.notify_client( entity, ServerMsg::CharacterAchievementDataError(error.to_string()), diff --git a/server/src/migrations/2020-06-25-172916_character_achievement/down.sql b/server/src/migrations/2020-06-25-172916_character_achievement/down.sql deleted file mode 100644 index 4497af0cb7..0000000000 --- a/server/src/migrations/2020-06-25-172916_character_achievement/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS "character_achievement"; \ No newline at end of file diff --git a/server/src/migrations/2020-06-25-172916_character_achievement/up.sql b/server/src/migrations/2020-06-25-172916_character_achievement/up.sql deleted file mode 100644 index ee1d72a688..0000000000 --- a/server/src/migrations/2020-06-25-172916_character_achievement/up.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE IF NOT EXISTS "character_achievement" ( - character_id INTEGER PRIMARY KEY NOT NULL, - achievement_uuid TEXT NOT NULL, - completed INTEGER NOT NULL DEFAULT 0, - progress INTEGER NOT NULL DEFAULT 0, - FOREIGN KEY(character_id) REFERENCES "character"(id) ON DELETE CASCADE, - FOREIGN KEY(achievement_uuid) REFERENCES "achievement"(uuid) ON DELETE CASCADE -); \ No newline at end of file diff --git a/server/src/migrations/2020-06-25-172916_character_achievements/down.sql b/server/src/migrations/2020-06-25-172916_character_achievements/down.sql new file mode 100644 index 0000000000..bc33d6d62a --- /dev/null +++ b/server/src/migrations/2020-06-25-172916_character_achievements/down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "character_achievements"; \ No newline at end of file diff --git a/server/src/migrations/2020-06-25-172916_character_achievements/up.sql b/server/src/migrations/2020-06-25-172916_character_achievements/up.sql new file mode 100644 index 0000000000..affee6790d --- /dev/null +++ b/server/src/migrations/2020-06-25-172916_character_achievements/up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS "character_achievements" ( + character_id INTEGER PRIMARY KEY NOT NULL, + items TEXT NOT NULL, + FOREIGN KEY(character_id) REFERENCES "character"(id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/server/src/persistence/achievement.rs b/server/src/persistence/achievement.rs index f8e85b9afb..f932941253 100644 --- a/server/src/persistence/achievement.rs +++ b/server/src/persistence/achievement.rs @@ -4,8 +4,7 @@ use super::{ error::Error, establish_connection, models::{ - Achievement as AchievementModel, CharacterAchievement, CharacterAchievementJoinData, - DataMigration, NewDataMigration, + Achievement as AchievementModel, CharacterAchievements, DataMigration, NewDataMigration, }, schema, }; @@ -15,6 +14,7 @@ use diesel::{ prelude::*, result::{DatabaseErrorKind, Error as DieselError}, }; +use hashbrown::HashSet; use std::{ collections::hash_map::DefaultHasher, hash::{Hash, Hasher}, @@ -29,7 +29,10 @@ enum AchievementLoaderRequestKind { }, } -type LoadCharacterAchievementsResult = (specs::Entity, Result); +type LoadCharacterAchievementsResult = ( + specs::Entity, + Result, Error>, +); /// Wrapper for results #[derive(Debug)] @@ -100,26 +103,15 @@ impl Drop for AchievementLoader { fn load_character_achievement_list( character_id: i32, db_dir: &str, -) -> Result { - let character_achievements = schema::character_achievement::dsl::character_achievement - .filter(schema::character_achievement::character_id.eq(character_id)) - .inner_join(schema::achievements::table) - .load::<(CharacterAchievement, AchievementModel)>(&establish_connection(db_dir))?; +) -> Result, Error> { + let character_achievements = schema::character_achievements::dsl::character_achievements + .filter(schema::character_achievements::character_id.eq(character_id)) + .first::(&establish_connection(db_dir))?; - Ok(comp::AchievementList::from( - character_achievements - .iter() - .map(|(character_achievement, achievement)| { - ( - achievement.uuid.clone(), - comp::CharacterAchievement::from(CharacterAchievementJoinData { - character_achievement, - achievement, - }), - ) - }) - .collect(), - )) + let result: HashSet = + character_achievements.items.0.iter().cloned().collect(); + + Ok(result) } pub fn sync(db_dir: &str) -> Result, Error> { diff --git a/server/src/persistence/character.rs b/server/src/persistence/character.rs index 1fe6f8f657..d9c112f036 100644 --- a/server/src/persistence/character.rs +++ b/server/src/persistence/character.rs @@ -11,8 +11,8 @@ use super::{ error::Error, establish_connection, models::{ - Body, Character, Inventory, InventoryUpdate, Loadout, LoadoutUpdate, NewCharacter, - NewLoadout, Stats, StatsJoinData, StatsUpdate, + Body, Character, CharacterAchievements, Inventory, InventoryUpdate, Loadout, LoadoutUpdate, + NewCharacter, NewLoadout, Stats, StatsJoinData, StatsUpdate, }, schema, }; @@ -446,7 +446,12 @@ fn check_character_limit(uuid: &str, db_dir: &str) -> Result<(), Error> { } } -type CharacterUpdateData = (StatsUpdate, InventoryUpdate, LoadoutUpdate); +type CharacterUpdateData = ( + StatsUpdate, + InventoryUpdate, + LoadoutUpdate, + CharacterAchievements, +); /// A unidirectional messaging resource for saving characters in a /// background thread. @@ -476,16 +481,25 @@ impl CharacterUpdater { /// Updates a collection of characters based on their id and components pub fn batch_update<'a>( &self, - updates: impl Iterator, + updates: impl Iterator< + Item = ( + i32, + &'a comp::Stats, + &'a comp::Inventory, + &'a comp::Loadout, + &'a comp::AchievementList, + ), + >, ) { let updates = updates - .map(|(id, stats, inventory, loadout)| { + .map(|(id, stats, inventory, loadout, achievements)| { ( id, ( StatsUpdate::from(stats), InventoryUpdate::from(inventory), LoadoutUpdate::from((id, loadout)), + CharacterAchievements::from((id, achievements)), ), ) }) @@ -503,8 +517,15 @@ impl CharacterUpdater { stats: &comp::Stats, inventory: &comp::Inventory, loadout: &comp::Loadout, + achievements: &comp::AchievementList, ) { - self.batch_update(std::iter::once((character_id, stats, inventory, loadout))); + self.batch_update(std::iter::once(( + character_id, + stats, + inventory, + loadout, + achievements, + ))); } } @@ -513,12 +534,16 @@ fn batch_update(updates: impl Iterator, db_di if let Err(e) = connection.transaction::<_, diesel::result::Error, _>(|| { updates.for_each( - |(character_id, (stats_update, inventory_update, loadout_update))| { + |( + character_id, + (stats_update, inventory_update, loadout_update, achievements_update), + )| { update( character_id, &stats_update, &inventory_update, &loadout_update, + &achievements_update, &connection, ) }, @@ -535,6 +560,7 @@ fn update( stats: &StatsUpdate, inventory: &InventoryUpdate, loadout: &LoadoutUpdate, + achievements: &CharacterAchievements, connection: &SqliteConnection, ) { // Update Stats @@ -569,6 +595,19 @@ fn update( { warn!(?e, ?character_id, "Failed to update loadout for character",) } + + // Update Achievements. Use `replace_into` as 'insert or update' as the + // characetr may not have a row + if let Err(e) = diesel::replace_into(schema::character_achievements::table) + .values(achievements) + .execute(connection) + { + warn!( + ?e, + ?character_id, + "Failed to update achievements for character", + ) + } } impl Drop for CharacterUpdater { diff --git a/server/src/persistence/models.rs b/server/src/persistence/models.rs index 2ff6fa034f..dc24a33823 100644 --- a/server/src/persistence/models.rs +++ b/server/src/persistence/models.rs @@ -1,7 +1,8 @@ extern crate serde_json; use super::schema::{ - achievements, body, character, character_achievement, data_migration, inventory, loadout, stats, + achievements, body, character, character_achievements, data_migration, inventory, loadout, + stats, }; use crate::comp; use chrono::NaiveDateTime; @@ -436,32 +437,77 @@ impl From<&Achievement> for comp::Achievement { /// In the interest of storing as little achievement data per-character as /// pssible, we only store a 'character_achievement' entry when a character has /// made some progress towards the completion of an achievement. -#[derive(Queryable, Debug, Identifiable)] +#[derive(Associations, AsChangeset, Identifiable, Queryable, Debug, Insertable)] +#[belongs_to(Character)] #[primary_key(character_id)] -#[table_name = "character_achievement"] -pub struct CharacterAchievement { - pub character_id: i32, - pub achievement_uuid: String, - pub completed: i32, - pub progress: i32, +#[table_name = "character_achievements"] +pub struct CharacterAchievements { + character_id: i32, + pub items: CharacterAchievementData, } -/// The required elements to build comp::CharacterAchievement from database data -pub struct CharacterAchievementJoinData<'a> { - pub character_achievement: &'a CharacterAchievement, - pub achievement: &'a Achievement, +/// The representation of each achievement in the Vec of achievement items +/// within achievement data. The structure of that data follows the format: +/// { +/// character_id, +/// items: [ CharacterAchievementItem ] +/// } +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub struct CharacterAchievementItem { + achievement_uuid: String, + progress: i32, } -impl From> for comp::CharacterAchievement { - fn from(data: CharacterAchievementJoinData) -> comp::CharacterAchievement { - comp::CharacterAchievement { - achievement: comp::Achievement::from(data.achievement), - completed: data.character_achievement.completed == 1, - progress: data.character_achievement.progress as usize, +/// When persisting the component to the database +impl From<(i32, &comp::AchievementList)> for CharacterAchievements { + fn from(data: (i32, &comp::AchievementList)) -> CharacterAchievements { + let (character_id, achievements) = data; + + let items = achievements + .items() + .iter() + .map(|(_, char_achievement)| char_achievement.clone()) + .collect::>(); + + CharacterAchievements { + character_id, + items: CharacterAchievementData(items), } } } +/// A wrapper type for Inventory components used to serialise to and from JSON +/// If the column contains malformed JSON, a default inventory is returned +#[derive(SqlType, AsExpression, Debug, Deserialize, Serialize, FromSqlRow, PartialEq)] +#[sql_type = "Text"] +pub struct CharacterAchievementData(pub Vec); + +impl diesel::deserialize::FromSql for CharacterAchievementData +where + DB: diesel::backend::Backend, + String: diesel::deserialize::FromSql, +{ + fn from_sql( + bytes: Option<&::RawValue>, + ) -> diesel::deserialize::Result { + let t = String::from_sql(bytes)?; + serde_json::from_str(&t).map_err(Box::from) + } +} + +impl diesel::serialize::ToSql for CharacterAchievementData +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) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/persistence/schema.rs b/server/src/persistence/schema.rs index 7fed7fe8d7..3fc1eb9559 100644 --- a/server/src/persistence/schema.rs +++ b/server/src/persistence/schema.rs @@ -32,14 +32,11 @@ table! { } table! { - character_achievement (character_id) { + character_achievements (character_id) { character_id -> Integer, - achievement_uuid -> Text, - completed -> Integer, - progress -> Integer, + items -> Text, } } - table! { data_migration (id) { id -> Integer, @@ -77,8 +74,7 @@ table! { } joinable!(body -> character (character_id)); -joinable!(character_achievement -> character (character_id)); -joinable!(character_achievement -> achievements (achievement_uuid)); +joinable!(character_achievements -> character (character_id)); joinable!(inventory -> character (character_id)); joinable!(loadout -> character (character_id)); joinable!(stats -> character (character_id)); @@ -87,7 +83,7 @@ allow_tables_to_appear_in_same_query!( achievements, body, character, - character_achievement, + character_achievements, inventory, loadout, stats, diff --git a/server/src/sys/persistence.rs b/server/src/sys/persistence.rs index bc6d8fc493..a749eacec6 100644 --- a/server/src/sys/persistence.rs +++ b/server/src/sys/persistence.rs @@ -2,7 +2,7 @@ use crate::{ persistence::character, sys::{SysScheduler, SysTimer}, }; -use common::comp::{Inventory, Loadout, Player, Stats}; +use common::comp::{AchievementList, Inventory, Loadout, Player, Stats}; use specs::{Join, ReadExpect, ReadStorage, System, Write}; pub struct Sys; @@ -14,6 +14,7 @@ impl<'a> System<'a> for Sys { ReadStorage<'a, Stats>, ReadStorage<'a, Inventory>, ReadStorage<'a, Loadout>, + ReadStorage<'a, AchievementList>, ReadExpect<'a, character::CharacterUpdater>, Write<'a, SysScheduler>, Write<'a, SysTimer>, @@ -26,6 +27,7 @@ impl<'a> System<'a> for Sys { player_stats, player_inventories, player_loadouts, + player_achievements, updater, mut scheduler, mut timer, @@ -33,19 +35,23 @@ impl<'a> System<'a> for Sys { ) { if scheduler.should_run() { timer.start(); + updater.batch_update( ( &players, &player_stats, &player_inventories, &player_loadouts, + &player_achievements, ) .join() - .filter_map(|(player, stats, inventory, loadout)| { - player - .character_id - .map(|id| (id, stats, inventory, loadout)) - }), + .filter_map( + |(player, stats, inventory, loadout, achievements)| { + player + .character_id + .map(|id| (id, stats, inventory, loadout, achievements)) + }, + ), ); timer.end(); }