diff --git a/common/src/comp/mod.rs b/common/src/comp/mod.rs index 9027cc50bc..106f995d2c 100644 --- a/common/src/comp/mod.rs +++ b/common/src/comp/mod.rs @@ -13,7 +13,7 @@ mod location; mod phys; mod player; pub mod projectile; -mod skills; +pub mod skills; mod stats; mod visual; @@ -43,5 +43,6 @@ pub use location::{Waypoint, WaypointArea}; pub use phys::{Collider, ForceUpdate, Gravity, Mass, Ori, PhysicsState, Pos, Scale, Sticky, Vel}; pub use player::Player; pub use projectile::Projectile; +pub use skills::{Skill, SkillGroup, SkillGroupType, SkillSet}; pub use stats::{Exp, HealthChange, HealthSource, Level, Stats}; pub use visual::{LightAnimation, LightEmitter}; diff --git a/common/src/comp/skills.rs b/common/src/comp/skills.rs index 6b9f3cd0a3..a6115e0883 100644 --- a/common/src/comp/skills.rs +++ b/common/src/comp/skills.rs @@ -1,7 +1,37 @@ use lazy_static::lazy_static; -use std::collections::HashMap; +use std::{ + collections::{HashMap, HashSet}, + hash::Hash, +}; use tracing::warn; +lazy_static! { + // Determines the skills that comprise each skill group - this data is used to determine + // which of a player's skill groups a particular skill should be added to when a skill unlock + // is requested. TODO: Externalise this data in a RON file for ease of modification + pub static ref SKILL_GROUP_DEFS: HashMap> = { + let mut defs = HashMap::new(); + defs.insert(SkillGroupType::T1, [ Skill::TestT1Skill1, + Skill::TestT1Skill2, + Skill::TestT1Skill3, + Skill::TestT1Skill4, + Skill::TestT1Skill5] + .iter().cloned().collect::>()); + + defs.insert(SkillGroupType::Swords, [ Skill::TestSwordSkill1, + Skill::TestSwordSkill2, + Skill::TestSwordSkill3] + .iter().cloned().collect::>()); + + defs.insert(SkillGroupType::Axes, [ Skill::TestAxeSkill1, + Skill::TestAxeSkill2, + Skill::TestAxeSkill3] + .iter().cloned().collect::>()); + + defs + }; +} + /// Represents a skill that a player can unlock, that either grants them some /// kind of active ability, or a passive effect etc. Obviously because this is /// an enum it doesn't describe what the skill actually -does-, this will be @@ -31,56 +61,30 @@ pub enum SkillGroupType { /// A group of skills that have been unlocked by a player. Each skill group has /// independent exp and skill points which are used to unlock skills in that /// skill group. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct SkillGroup { - pub skills: Vec, + pub skill_group_type: SkillGroupType, pub exp: u32, pub available_sp: u8, } -impl Default for SkillGroup { - fn default() -> Self { - Self { - skills: Vec::new(), +impl SkillGroup { + fn new(skill_group_type: SkillGroupType) -> SkillGroup { + SkillGroup { + skill_group_type, exp: 0, available_sp: 0, } } } -lazy_static! { - // Determines the skills that comprise each skill group - this data is used to determine - // which of a player's skill groups a particular skill should be added to when a skill unlock - // is requested. TODO: Externalise this data in a RON file for ease of modification - static ref SKILL_GROUP_DEFS: HashMap> = { - let mut defs = HashMap::new(); - defs.insert(SkillGroupType::T1, vec![ - Skill::TestT1Skill1, - Skill::TestT1Skill2, - Skill::TestT1Skill3, - Skill::TestT1Skill4, - Skill::TestT1Skill5]); - - defs.insert(SkillGroupType::Swords, vec![ - Skill::TestSwordSkill1, - Skill::TestSwordSkill2, - Skill::TestSwordSkill3]); - - defs.insert(SkillGroupType::Axes, vec![ - Skill::TestAxeSkill1, - Skill::TestAxeSkill2, - Skill::TestAxeSkill3]); - - defs - }; -} - -/// Contains all of a player's skill groups and provides methods for -/// manipulating assigned skills including unlocking skills, refunding skills -/// etc. -#[derive(Clone, Debug, Serialize, Deserialize)] +/// Contains all of a player's skill groups and skills. Provides methods for +/// manipulating assigned skills and skill groups including unlocking skills, +/// refunding skills etc. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct SkillSet { - pub skill_groups: HashMap, + pub skill_groups: Vec, + pub skills: HashSet, } impl Default for SkillSet { @@ -88,49 +92,99 @@ impl Default for SkillSet { /// unlocked skills in them - used when adding a skill set to a new /// player fn default() -> Self { - let mut skill_groups = HashMap::new(); - skill_groups.insert(SkillGroupType::T1, SkillGroup::default()); - skill_groups.insert(SkillGroupType::Swords, SkillGroup::default()); - skill_groups.insert(SkillGroupType::Axes, SkillGroup::default()); - Self { skill_groups } + // TODO: Default skill groups for new players? + Self { + skill_groups: Vec::new(), + skills: HashSet::new(), + } } } impl SkillSet { - pub fn refund_skill(&mut self, _skill: Skill) { - // TODO: check player has skill, remove skill and increase SP in skill group by 1 + pub fn new() -> Self { + Self { + skill_groups: Vec::new(), + skills: HashSet::new(), + } } - pub fn unlock_skill(&mut self, skill: Skill) { - // Find the skill group type for the skill from the static skill definitions - let skill_group_type = SKILL_GROUP_DEFS.iter().find_map(|(key, val)| { - if val.contains(&skill) { - Some(*key) - } else { - None - } - }); + // TODO: Game design to determine how skill groups are unlocked + /// Unlocks a skill group for a player, starting with 0 exp and 0 sp + pub fn unlock_skill_group(&mut self, skill_group_type: SkillGroupType) { + if !self + .skill_groups + .iter() + .any(|x| x.skill_group_type == skill_group_type) + { + self.skill_groups.push(SkillGroup::new(skill_group_type)); + } else { + warn!("Tried to unlock already known skill group"); + } + } - // Find the skill group for the skill on the player, check that the skill is not - // already unlocked and that they have available SP in that group, and then - // allocate the skill and reduce the player's SP in that skill group by 1. - if let Some(skill_group_type) = skill_group_type { - if let Some(skill_group) = self.skill_groups.get_mut(&skill_group_type) { - if !skill_group.skills.contains(&skill) { + /// Unlocks a skill for a player, assuming they have the relevant Skill + /// Group unlocked and available SP in that skill group + pub fn unlock_skill(&mut self, skill: Skill) { + if self.skills.contains(&skill) { + if let Some(skill_group_type) = SkillSet::get_skill_group_type_for_skill(&skill) { + if let Some(mut skill_group) = self + .skill_groups + .iter_mut() + .find(|x| x.skill_group_type == skill_group_type) + { if skill_group.available_sp > 0 { - skill_group.skills.push(skill); skill_group.available_sp -= 1; } else { warn!("Tried to unlock skill for skill group with no available SP"); } } else { - warn!("Tried to unlock already unlocked skill"); + warn!("Tried to unlock skill for a skill group that player does not have"); } } else { - warn!("Tried to unlock skill for a skill group that player does not have"); + warn!( + ?skill, + "Tried to unlock skill that does not exist in any skill group!" + ); } } else { - warn!("Tried to unlock skill that does not exist in any skill group!"); + warn!("Tried to unlock already unlocked skill"); } } + + /// Removes a skill for a player and refunds 1 SP in the relevant Skill + /// Group + pub fn refund_skill(&mut self, skill: Skill) { + if !self.skills.contains(&skill) { + if let Some(skill_group_type) = SkillSet::get_skill_group_type_for_skill(&skill) { + if let Some(mut skill_group) = self + .skill_groups + .iter_mut() + .find(|x| x.skill_group_type == skill_group_type) + { + skill_group.available_sp += 1; + } else { + warn!("Tried to refund skill for a skill group that player does not have"); + } + } else { + warn!( + ?skill, + "Tried to refund skill that does not exist in any skill group" + ) + } + } else { + warn!("Tried to refund skill that has not been unlocked"); + } + } + + /// Returns the skill group type for a skill from the static skill group + /// definitions + fn get_skill_group_type_for_skill(skill: &Skill) -> Option { + SKILL_GROUP_DEFS.iter().find_map(|(key, val)| { + if val.contains(&skill) { + Some(*key) + } else { + None + } + }) + } } diff --git a/common/src/msg/client.rs b/common/src/msg/client.rs index a609c751ea..59022b9a78 100644 --- a/common/src/msg/client.rs +++ b/common/src/msg/client.rs @@ -1,4 +1,8 @@ -use crate::{comp, terrain::block::Block}; +use crate::{ + comp, + comp::{Skill, SkillGroupType}, + terrain::block::Block, +}; use vek::*; /// Messages sent from the client to the server @@ -40,4 +44,7 @@ pub enum ClientMsg { }, Disconnect, Terminate, + UnlockSkill(Skill), + RefundSkill(Skill), + UnlockSkillGroup(SkillGroupType), } diff --git a/server/src/migrations/2020-07-03-194516_skills/down.sql b/server/src/migrations/2020-07-03-194516_skills/down.sql new file mode 100644 index 0000000000..f70c7b6cc9 --- /dev/null +++ b/server/src/migrations/2020-07-03-194516_skills/down.sql @@ -0,0 +1,22 @@ +PRAGMA foreign_keys=off; + +-- SQLite does not support removing columns from tables so we must rename the current table, +-- recreate the previous version of the table, then copy over the data from the renamed table +ALTER TABLE stats RENAME TO _stats_old; + +CREATE TABLE "stats" ( + character_id INT NOT NULL PRIMARY KEY, + level INT NOT NULL DEFAULT 1, + exp INT NOT NULL DEFAULT 0, + endurance INT NOT NULL DEFAULT 0, + fitness INT NOT NULL DEFAULT 0, + willpower INT NOT NULL DEFAULT 0, + FOREIGN KEY(character_id) REFERENCES "character"(id) ON DELETE CASCADE +); + +INSERT INTO "stats" (character_id, level, exp, endurance, fitness, willpower) +SELECT character_id, level, exp, endurance, fitness, willpower FROM _stats_old; + +DROP TABLE _stats_old; + +PRAGMA foreign_keys=on; \ No newline at end of file diff --git a/server/src/migrations/2020-07-03-194516_skills/up.sql b/server/src/migrations/2020-07-03-194516_skills/up.sql new file mode 100644 index 0000000000..8d79198a62 --- /dev/null +++ b/server/src/migrations/2020-07-03-194516_skills/up.sql @@ -0,0 +1,5 @@ +ALTER TABLE "stats" ADD COLUMN skills TEXT; + +-- Update all existing stats records to "" which will cause characters to be populated +-- with the default skill groups/skills on next login. +UPDATE "stats" SET skills = '""'; \ No newline at end of file diff --git a/server/src/persistence/character.rs b/server/src/persistence/character.rs index f6ccb5e2a0..92d506b219 100644 --- a/server/src/persistence/character.rs +++ b/server/src/persistence/character.rs @@ -16,7 +16,7 @@ use super::{ }, schema, }; -use crate::comp; +use crate::{comp, persistence::models::SkillSetData}; use common::{ character::{Character as CharacterData, CharacterItem, MAX_CHARACTERS_PER_PLAYER}, LoadoutBuilder, @@ -411,6 +411,7 @@ fn create_character( endurance: default_stats.endurance as i32, fitness: default_stats.fitness as i32, willpower: default_stats.willpower as i32, + skills: SkillSetData(default_stats.skill_set), }; diesel::insert_into(stats::table) @@ -576,14 +577,16 @@ fn update( loadout: &LoadoutUpdate, connection: &SqliteConnection, ) { + // Update Stats if let Err(e) = diesel::update(schema::stats::table.filter(schema::stats::character_id.eq(character_id))) .set(stats) .execute(connection) { - warn!(?e, ?character_id, "Failed to update stats for character",) + error!(?e, ?character_id, "Failed to update stats for character",) } + // Update Inventory if let Err(e) = diesel::update( schema::inventory::table.filter(schema::inventory::character_id.eq(character_id)), ) @@ -597,6 +600,7 @@ fn update( ) } + // Update Loadout if let Err(e) = diesel::update( schema::loadout::table.filter(schema::loadout::character_id.eq(character_id)), ) diff --git a/server/src/persistence/models.rs b/server/src/persistence/models.rs index e3873777b7..d5267c24a2 100644 --- a/server/src/persistence/models.rs +++ b/server/src/persistence/models.rs @@ -92,6 +92,7 @@ pub struct Stats { pub endurance: i32, pub fitness: i32, pub willpower: i32, + pub skills: SkillSetData, } impl From> for comp::Stats { @@ -113,7 +114,7 @@ impl From> for comp::Stats { base_stats.endurance = data.stats.endurance as u32; base_stats.fitness = data.stats.fitness as u32; base_stats.willpower = data.stats.willpower as u32; - + base_stats.skill_set = data.stats.skills.0.clone(); base_stats } } @@ -127,6 +128,7 @@ pub struct StatsUpdate { pub endurance: i32, pub fitness: i32, pub willpower: i32, + pub skills: SkillSetData, } impl From<&comp::Stats> for StatsUpdate { @@ -137,10 +139,50 @@ impl From<&comp::Stats> for StatsUpdate { endurance: stats.endurance as i32, fitness: stats.fitness as i32, willpower: stats.willpower as i32, + skills: SkillSetData(stats.skill_set.clone()), } } } +/// A wrapper type for the SkillSet of a character used to serialise to and from +/// JSON If the column contains malformed JSON, a default skillset is returned +#[derive(AsExpression, Debug, Deserialize, Serialize, PartialEq, FromSqlRow)] +#[sql_type = "Text"] +pub struct SkillSetData(pub comp::SkillSet); + +impl diesel::deserialize::FromSql for SkillSetData +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 deserialize skill set data"); + Ok(Self(comp::SkillSet::default())) + }, + } + } +} + +impl diesel::serialize::ToSql for SkillSetData +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) + } +} + /// Inventory storage and conversion. Inventories have a one-to-one relationship /// with characters. /// @@ -354,6 +396,7 @@ mod tests { endurance: 2, fitness: 3, willpower: 4, + skills: SkillSetData(stats.skill_set) }) } @@ -380,6 +423,7 @@ mod tests { endurance: 0, fitness: 2, willpower: 3, + skills: SkillSetData(comp::SkillSet::new()), }, }; diff --git a/server/src/persistence/schema.rs b/server/src/persistence/schema.rs index e3e3e9cbc4..09ebfdc871 100644 --- a/server/src/persistence/schema.rs +++ b/server/src/persistence/schema.rs @@ -45,6 +45,7 @@ table! { endurance -> Integer, fitness -> Integer, willpower -> Integer, + skills -> Text, } } @@ -53,4 +54,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!(body, character, inventory, loadout, stats); diff --git a/server/src/sys/message.rs b/server/src/sys/message.rs index b13fe3753e..606e861349 100644 --- a/server/src/sys/message.rs +++ b/server/src/sys/message.rs @@ -43,7 +43,7 @@ impl Sys { uids: &ReadStorage<'_, Uid>, can_build: &ReadStorage<'_, CanBuild>, force_updates: &ReadStorage<'_, ForceUpdate>, - stats: &ReadStorage<'_, Stats>, + stats: &mut WriteStorage<'_, Stats>, chat_modes: &ReadStorage<'_, ChatMode>, accounts: &mut WriteExpect<'_, AuthProvider>, block_changes: &mut Write<'_, BlockChange>, @@ -367,6 +367,21 @@ impl Sys { ); } }, + ClientMsg::UnlockSkill(skill) => { + stats + .get_mut(entity) + .map(|s| s.skill_set.unlock_skill(skill)); + }, + ClientMsg::RefundSkill(skill) => { + stats + .get_mut(entity) + .map(|s| s.skill_set.refund_skill(skill)); + }, + ClientMsg::UnlockSkillGroup(skill_group_type) => { + stats + .get_mut(entity) + .map(|s| s.skill_set.unlock_skill_group(skill_group_type)); + }, } } } @@ -386,7 +401,7 @@ impl<'a> System<'a> for Sys { ReadStorage<'a, Uid>, ReadStorage<'a, CanBuild>, ReadStorage<'a, ForceUpdate>, - ReadStorage<'a, Stats>, + WriteStorage<'a, Stats>, ReadStorage<'a, ChatMode>, WriteExpect<'a, AuthProvider>, Write<'a, BlockChange>, @@ -416,7 +431,7 @@ impl<'a> System<'a> for Sys { uids, can_build, force_updates, - stats, + mut stats, chat_modes, mut accounts, mut block_changes, @@ -476,7 +491,7 @@ impl<'a> System<'a> for Sys { &uids, &can_build, &force_updates, - &stats, + &mut stats, &chat_modes, &mut accounts, &mut block_changes,