Rework some data structures and persistence. Abandoned the approach of

generating row ids for achievements via checksums in favour of static
uuids for each item.
This commit is contained in:
Shane Handley 2020-07-06 14:01:46 +10:00
parent efe7a62c92
commit c056b2b079
10 changed files with 235 additions and 293 deletions

View File

@ -1,4 +1,5 @@
use crate::comp::item::{Consumable, Item, ItemKind}; use crate::comp::item::{Consumable, Item, ItemKind};
use hashbrown::HashMap;
use specs::{Component, Entity, FlaggedStorage}; use specs::{Component, Entity, FlaggedStorage};
use specs_idvs::IDVStorage; use specs_idvs::IDVStorage;
@ -11,6 +12,12 @@ pub struct AchievementTrigger {
pub event: AchievementEvent, pub event: AchievementEvent,
} }
/// Used to indicate an in-game event that can contribute towards the completion
/// of an achievement.
///
/// These are paired with `AchievementAction` items - for
/// example an event of type `LevelUp` will trigger a check for `ReachLevel`
/// achievements.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum AchievementEvent { pub enum AchievementEvent {
None, None,
@ -30,17 +37,15 @@ pub enum AchievementAction {
KillNpcs, KillNpcs,
} }
/// Information about an achievement. This differs from a complete
/// [`Achievement`](struct.Achievement.html) in that it describes the
/// achievement without any information about progress or completion status
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AchievementItem { pub struct Achievement {
pub uuid: String,
pub title: String, pub title: String,
pub action: AchievementAction, pub action: AchievementAction,
pub target: usize, pub target: usize,
} }
impl AchievementItem { impl Achievement {
pub fn matches_event(&self, event: &AchievementEvent) -> bool { pub fn matches_event(&self, event: &AchievementEvent) -> bool {
match event { match event {
AchievementEvent::KilledNpc => self.action == AchievementAction::KillNpcs, AchievementEvent::KilledNpc => self.action == AchievementAction::KillNpcs,
@ -63,21 +68,24 @@ impl AchievementItem {
/// The complete representation of an achievement that has been /// The complete representation of an achievement that has been
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Achievement { pub struct CharacterAchievement {
pub id: i32, pub achievement: Achievement,
pub item: AchievementItem,
pub completed: bool, pub completed: bool,
pub progress: usize, pub progress: usize,
} }
impl Achievement { impl CharacterAchievement {
/// Increment the progress of this Achievement based on its type /// Increment the progress of this Achievement based on its type
/// ///
/// By default, when an achievement is incremented, its `progress` value is /// By default, when an achievement is incremented, its `progress` value is
/// incremented by 1. This covers many cases, but using this method allows /// incremented by 1. This covers many cases, but using this method allows
/// handling of unique types of achievements which are not simple /// handling of unique types of achievements which are not simple
/// counters for events /// counters for events
pub fn increment_progress(&mut self, event: &AchievementEvent) -> bool { pub fn increment_progress(&mut self, event: &AchievementEvent) -> Option<&mut Self> {
if self.completed {
return None;
}
match event { match event {
AchievementEvent::LevelUp(level) => { AchievementEvent::LevelUp(level) => {
self.progress = *level as usize; self.progress = *level as usize;
@ -85,61 +93,67 @@ impl Achievement {
_ => self.progress += 1, _ => self.progress += 1,
}; };
self.completed = self.progress >= self.item.target; self.completed = self.progress >= self.achievement.target;
self.completed
if self.completed == true {
Some(self)
} else {
None
}
}
}
/// For initialisation of a new CharacterAchievement item when a new achievement
/// is progressed
impl From<Achievement> for CharacterAchievement {
fn from(achievement: Achievement) -> Self {
Self {
achievement,
completed: false,
progress: 0,
}
} }
} }
/// Each character is assigned an achievement list, which holds information /// Each character is assigned an achievement list, which holds information
/// about which achievements that the player has made some progress on, or /// about which achievements that the player has made some progress on, or
/// completed. /// completed.
/// #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// This minimises storage of data per-character, and can be merged with a full pub struct AchievementList(HashMap<String, CharacterAchievement>);
/// achievement list
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AchievementList(Vec<Achievement>);
impl Default for AchievementList { impl Default for AchievementList {
fn default() -> AchievementList { AchievementList(Vec::new()) } fn default() -> AchievementList { AchievementList(HashMap::new()) }
} }
impl AchievementList { impl AchievementList {
pub fn from(data: Vec<Achievement>) -> Self { Self(data) } pub fn from(data: HashMap<String, CharacterAchievement>) -> Self { Self(data) }
} }
impl Component for AchievementList { impl Component for AchievementList {
type Storage = FlaggedStorage<Self, IDVStorage<Self>>; type Storage = FlaggedStorage<Self, IDVStorage<Self>>; // TODO check
} }
impl AchievementList { impl AchievementList {
pub fn item_by_id(&mut self, id: i32) -> Option<&mut Achievement> { /// Process a single CharacterAchievement item based on the occurance of an
self.0.iter_mut().find(|a| a.id == id) /// `AchievementEvent`.
} ///
/// When the character has existing progress on the achievement it is
/// Process a single achievement item, inrementing the progress of the /// updated, otherwise an insert-then-update is performed.
/// achievement. This is called as part of server/sys/Achievements. ///
/// Returns the `CharacterAchievement` item that was processed in the event
/// that prcessing resulted in its completion.
pub fn process_achievement( pub fn process_achievement(
&mut self, &mut self,
achievement: Achievement, achievement: &Achievement,
event: &AchievementEvent, event: &AchievementEvent,
) -> bool { ) -> Option<CharacterAchievement> {
let id = achievement.id; let uuid = achievement.uuid.clone();
if !self.0.contains(&achievement) { self.0
self.0.push(achievement); .entry(uuid)
} .or_insert(CharacterAchievement::from(achievement.clone()))
.increment_progress(event)
if let Some(char_achievement) = self.item_by_id(id) { .cloned()
if char_achievement.completed {
return false;
}
char_achievement.increment_progress(event)
} else {
tracing::warn!("Failed to find achievement after inserting");
false
}
} }
} }
@ -150,8 +164,9 @@ mod tests {
#[test] #[test]
fn inv_collect_event_matches_consumable_achievement_item() { fn inv_collect_event_matches_consumable_achievement_item() {
let item = AchievementItem { let item = Achievement {
title: String::from("Test"), uuid: String::new(),
title: String::new(),
action: AchievementAction::CollectConsumable(Consumable::Apple), action: AchievementAction::CollectConsumable(Consumable::Apple),
target: 10, target: 10,
}; };
@ -164,8 +179,9 @@ mod tests {
#[test] #[test]
fn inv_collect_event_not_matches_consumable_achievement_item() { fn inv_collect_event_not_matches_consumable_achievement_item() {
let item = AchievementItem { let item = Achievement {
title: String::from("Test"), uuid: String::new(),
title: String::new(),
action: AchievementAction::CollectConsumable(Consumable::Cheese), action: AchievementAction::CollectConsumable(Consumable::Cheese),
target: 10, target: 10,
}; };
@ -178,8 +194,9 @@ mod tests {
#[test] #[test]
fn levelup_event_matches_reach_level_achievement_item() { fn levelup_event_matches_reach_level_achievement_item() {
let item = AchievementItem { let item = Achievement {
title: String::from("Test"), uuid: String::new(),
title: String::new(),
action: AchievementAction::ReachLevel, action: AchievementAction::ReachLevel,
target: 100, target: 100,
}; };
@ -190,20 +207,16 @@ mod tests {
} }
#[test] #[test]
fn process_collect_achievement_increments_progress() { fn process_achievement_increments_progress() {
let item = AchievementItem { let uuid = String::from("2ef30659-5884-40aa-ba4d-8f5af32ff9ac");
let achievement = Achievement {
uuid: uuid.clone(),
title: String::from("Collect 3 Mushrooms"), title: String::from("Collect 3 Mushrooms"),
action: AchievementAction::CollectConsumable(Consumable::Mushroom), action: AchievementAction::CollectConsumable(Consumable::Mushroom),
target: 3, target: 3,
}; };
let achievement = Achievement {
id: 1,
item,
completed: false,
progress: 0,
};
let event = let event =
AchievementEvent::CollectedItem(assets::load_expect_cloned("common.items.mushroom")); AchievementEvent::CollectedItem(assets::load_expect_cloned("common.items.mushroom"));
@ -211,92 +224,75 @@ mod tests {
// The first two increments should not indicate that it is complete // The first two increments should not indicate that it is complete
assert_eq!( assert_eq!(
achievement_list.process_achievement(achievement.clone(), &event), achievement_list.process_achievement(&achievement.clone(), &event),
false None
); );
assert_eq!( assert_eq!(
achievement_list.process_achievement(achievement.clone(), &event), achievement_list.process_achievement(&achievement.clone(), &event),
false None
); );
assert_eq!(achievement_list.0.get(0).unwrap().progress, 2); assert_eq!(achievement_list.0.get(&uuid).unwrap().progress, 2);
// It should return true when completed
assert_eq!(
achievement_list.process_achievement(achievement, &event),
true
);
assert_eq!(achievement_list.0.get(0).unwrap().progress, 3);
// The achievement `completed` field should be true
assert_eq!(achievement_list.0.get(0).unwrap().completed, true);
} }
#[test] #[test]
fn process_levelup_achievement_increments_progress() { fn process_achievement_returns_achievement_when_complete() {
let item = AchievementItem { let uuid = String::from("Test");
let achievement = Achievement {
uuid: uuid.clone(),
title: String::from("Reach Level 10"), title: String::from("Reach Level 10"),
action: AchievementAction::ReachLevel, action: AchievementAction::ReachLevel,
target: 10, target: 10,
}; };
let achievement = Achievement {
id: 1,
item,
completed: false,
progress: 1,
};
let mut achievement_list = AchievementList::default(); let mut achievement_list = AchievementList::default();
assert_eq!( achievement_list.process_achievement(&achievement, &AchievementEvent::LevelUp(6));
achievement_list
.process_achievement(achievement.clone(), &AchievementEvent::LevelUp(6)),
false
);
// The achievement progress should be the new level value, and be incomplete // The achievement progress should be the new level value, but be incomplete
assert_eq!(achievement_list.0.get(0).unwrap().progress, 6); let incomplete_result = achievement_list.0.get(&uuid).unwrap();
assert_eq!(achievement_list.0.get(0).unwrap().completed, false);
assert_eq!( assert_eq!(incomplete_result.progress, 6);
achievement_list.process_achievement(achievement, &AchievementEvent::LevelUp(10)), assert_eq!(incomplete_result.completed, false);
true
); achievement_list.process_achievement(&achievement, &AchievementEvent::LevelUp(10));
// The achievement progress should be the new level value, and be completed // The achievement progress should be the new level value, and be completed
assert_eq!(achievement_list.0.get(0).unwrap().progress, 10); let complete_result = achievement_list.0.get(&uuid).unwrap();
assert_eq!(achievement_list.0.get(0).unwrap().completed, true);
assert_eq!(complete_result.progress, 10);
assert_eq!(complete_result.completed, true);
} }
#[test] #[test]
fn process_completed_achievement_doesnt_increment_progress() { fn process_completed_achievement_doesnt_increment_progress() {
let item = AchievementItem { let uuid = String::from("Test");
let achievement = Achievement {
uuid: uuid.clone(),
title: String::from("Collect 3 Mushrooms"), title: String::from("Collect 3 Mushrooms"),
action: AchievementAction::CollectConsumable(Consumable::Mushroom), action: AchievementAction::KillNpcs,
target: 3, target: 3,
}; };
let achievement = Achievement { // Initialise with the already completed event
id: 1, let mut achievement_list = AchievementList::default();
item,
completed: true,
progress: 3,
};
let mut achievement_list = AchievementList(vec![achievement.clone()]); achievement_list
.0
.insert(uuid.clone(), CharacterAchievement {
achievement: achievement.clone(),
completed: true,
progress: 3,
});
let event = achievement_list.process_achievement(&achievement, &AchievementEvent::KilledNpc);
AchievementEvent::CollectedItem(assets::load_expect_cloned("common.items.mushroom"));
assert_eq!( let result = achievement_list.0.get(&uuid).unwrap();
achievement_list.process_achievement(achievement, &event),
false
);
// The achievement progress should not have incremented // The achievement progress should not have incremented
assert_eq!(achievement_list.0.get(0).unwrap().progress, 3); assert_eq!(result.progress, 3);
} }
} }

View File

@ -22,8 +22,8 @@ mod visual;
// Reexports // Reexports
pub use ability::{CharacterAbility, CharacterAbilityType, ItemConfig, Loadout}; pub use ability::{CharacterAbility, CharacterAbilityType, ItemConfig, Loadout};
pub use achievement::{ pub use achievement::{
Achievement, AchievementAction, AchievementEvent, AchievementItem, AchievementList, Achievement, AchievementAction, AchievementEvent, AchievementList, AchievementTrigger,
AchievementTrigger, CharacterAchievement,
}; };
pub use admin::{Admin, AdminList}; pub use admin::{Admin, AdminList};
pub use agent::{Agent, Alignment}; pub use agent::{Agent, Alignment};

View File

@ -65,7 +65,7 @@ pub enum ServerMsg {
/// An error occurred while loading character achievements /// An error occurred while loading character achievements
CharacterAchievementDataError(String), CharacterAchievementDataError(String),
/// The client has completed an achievement /// The client has completed an achievement
AchievementCompletion(comp::AchievementItem), AchievementCompletion(comp::CharacterAchievement),
/// An error occurred while loading character data /// An error occurred while loading character data
CharacterDataLoadError(String), CharacterDataLoadError(String),
/// A list of characters belonging to the a authenticated player was sent /// A list of characters belonging to the a authenticated player was sent

View File

@ -1,50 +1,60 @@
[ [
( (
id: "9d48b606-f70a-4e7f-a3b7-6630e75ba8fe",
title: "Collect 10 apples", title: "Collect 10 apples",
action: CollectConsumable(Apple), action: CollectConsumable(Apple),
target: 10 target: 10
), ),
( (
id: "ffccb88c-2bb0-41bd-942b-3cbdf9882295",
title: "Collect 50 apples", title: "Collect 50 apples",
action: CollectConsumable(Apple), action: CollectConsumable(Apple),
target: 50 target: 50
), ),
( (
id: "290c0bc1-ab0a-450e-82d7-6917d9ea7497",
title: "Pick a mushroom", title: "Pick a mushroom",
action: CollectConsumable(Mushroom), action: CollectConsumable(Mushroom),
target: 1 target: 1
), ),
( (
id: "598aff28-01f6-46a9-a4c1-d75aead98794",
title: "Kill an NPC", title: "Kill an NPC",
action: KillNpcs, action: KillNpcs,
target: 1 target: 1
), ),
( (
id: "046e08c5-a512-4ee1-b6e4-76e4e11dd502",
title: "Kill 10 NPCs", title: "Kill 10 NPCs",
action: KillNpcs, action: KillNpcs,
target: 10 target: 10
), ),
( (
id: "9c2a326b-c4fa-45bf-9dd6-d4bf9dd85bb5",
title: "Kill 100 NPCs", title: "Kill 100 NPCs",
action: KillNpcs, action: KillNpcs,
target: 100 target: 100
), ),
( (
id: "de782069-4366-41dc-9645-1c02c64b8037",
title: "Kill another player", title: "Kill another player",
action: KillPlayers, action: KillPlayers,
target: 1 target: 1
), ),
( (
id: "df6bf984-efdf-40f7-8bac-e09e9927acba",
title: "Kill 10 players", title: "Kill 10 players",
action: KillPlayers, action: KillPlayers,
target: 10 target: 10
), ),
( (
id: "d733c6cc-57c9-4a22-aea6-13f4850d750f",
title: "Find a Velorite fragment", title: "Find a Velorite fragment",
action: CollectConsumable(VeloriteFrag), action: CollectConsumable(VeloriteFrag),
target: 1 target: 1
), ),
( (
id: "50140fe9-1811-40c4-bb3b-89503ac60ccf",
title: "Find Velorite", title: "Find Velorite",
action: CollectConsumable(Velorite), action: CollectConsumable(Velorite),
target: 1 target: 1

View File

@ -43,7 +43,7 @@ use futures_util::{select, FutureExt};
use metrics::{ServerMetrics, TickMetrics}; use metrics::{ServerMetrics, TickMetrics};
use network::{Address, Network, Pid}; use network::{Address, Network, Pid};
use persistence::{ use persistence::{
achievement::{Achievement, AchievementLoader, AchievementLoaderResponse}, achievement::{AchievementLoader, AchievementLoaderResponse},
character::{CharacterLoader, CharacterLoaderResponseType, CharacterUpdater}, character::{CharacterLoader, CharacterLoaderResponseType, CharacterUpdater},
}; };
use specs::{join::Join, Builder, Entity as EcsEntity, RunNow, SystemData, WorldExt}; use specs::{join::Join, Builder, Entity as EcsEntity, RunNow, SystemData, WorldExt};
@ -90,7 +90,8 @@ pub struct Server {
metrics: ServerMetrics, metrics: ServerMetrics,
tick_metrics: TickMetrics, tick_metrics: TickMetrics,
achievement_data: Vec<Achievement>, /// Holds a list of all available achievements
achievement_data: Vec<comp::Achievement>,
} }
impl Server { impl Server {
@ -447,9 +448,7 @@ impl Server {
for trigger in achievement_events { for trigger in achievement_events {
// Get the achievement that matches this event // Get the achievement that matches this event
for achievement in &self.achievement_data { for achievement in &self.achievement_data {
let achievement_item = comp::AchievementItem::from(&achievement.details); if achievement.matches_event(&trigger.event) {
if achievement_item.matches_event(&trigger.event) {
// Calls to `process_achievement` return true to indicate that the // Calls to `process_achievement` return true to indicate that the
// achievement is complete. In this case, we notify the client to notify them of // achievement is complete. In this case, we notify the client to notify them of
// completing the achievement // completing the achievement
@ -459,14 +458,12 @@ impl Server {
.write_storage::<comp::AchievementList>() .write_storage::<comp::AchievementList>()
.get_mut(trigger.entity) .get_mut(trigger.entity)
{ {
if achievement_list.process_achievement( if let Some(character_achievement) =
comp::Achievement::from(achievement), achievement_list.process_achievement(achievement, &trigger.event)
&trigger.event,
) == true
{ {
self.notify_client( self.notify_client(
trigger.entity, trigger.entity,
ServerMsg::AchievementCompletion(achievement_item), ServerMsg::AchievementCompletion(character_achievement),
) )
} }
} }

View File

@ -1,5 +1,7 @@
CREATE TABLE IF NOT EXISTS "achievement" ( CREATE TABLE IF NOT EXISTS "achievements" (
id INTEGER PRIMARY KEY NOT NULL, "uuid" TEXT PRIMARY KEY NOT NULL,
checksum VARCHAR(64) NOT NULL UNIQUE, "checksum" TEXT NOT NULL UNIQUE,
details TEXT NOT NULL "title" TEXT NOT NULL,
"action" INT NOT NULL,
"target" INT NOT NULL
); );

View File

@ -1,8 +1,8 @@
CREATE TABLE IF NOT EXISTS "character_achievement" ( CREATE TABLE IF NOT EXISTS "character_achievement" (
character_id INTEGER PRIMARY KEY NOT NULL, character_id INTEGER PRIMARY KEY NOT NULL,
achievement_id INTEGER NOT NULL, achievement_uuid TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0, completed INTEGER NOT NULL DEFAULT 0,
progress INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(character_id) REFERENCES "character"(id) ON DELETE CASCADE, FOREIGN KEY(character_id) REFERENCES "character"(id) ON DELETE CASCADE,
FOREIGN KEY(achievement_id) REFERENCES "achievement"(id) ON DELETE CASCADE FOREIGN KEY(achievement_uuid) REFERENCES "achievement"(uuid) ON DELETE CASCADE
); );

View File

@ -4,12 +4,12 @@ use super::{
error::Error, error::Error,
establish_connection, establish_connection,
models::{ models::{
Achievement as AchievementModel, CharacterAchievement, DataMigration, NewAchievement, Achievement as AchievementModel, CharacterAchievement, CharacterAchievementJoinData,
NewDataMigration, DataMigration, NewDataMigration,
}, },
schema, schema,
}; };
use common::comp::{self, AchievementItem}; use common::comp;
use crossbeam::{channel, channel::TryIter}; use crossbeam::{channel, channel::TryIter};
use diesel::{ use diesel::{
prelude::*, prelude::*,
@ -21,8 +21,6 @@ use std::{
}; };
use tracing::{error, info, warn}; use tracing::{error, info, warn};
pub type Achievement = AchievementModel;
/// Available database operations when modifying a player's characetr list /// Available database operations when modifying a player's characetr list
enum AchievementLoaderRequestKind { enum AchievementLoaderRequestKind {
LoadCharacterAchievementList { LoadCharacterAchievementList {
@ -105,25 +103,34 @@ fn load_character_achievement_list(
) -> Result<comp::AchievementList, Error> { ) -> Result<comp::AchievementList, Error> {
let character_achievements = schema::character_achievement::dsl::character_achievement let character_achievements = schema::character_achievement::dsl::character_achievement
.filter(schema::character_achievement::character_id.eq(character_id)) .filter(schema::character_achievement::character_id.eq(character_id))
.load::<CharacterAchievement>(&establish_connection(db_dir))?; .inner_join(schema::achievements::table)
.load::<(CharacterAchievement, AchievementModel)>(&establish_connection(db_dir))?;
Ok(comp::AchievementList::from( Ok(comp::AchievementList::from(
character_achievements character_achievements
.iter() .iter()
.map(comp::Achievement::from) .map(|(character_achievement, achievement)| {
.collect::<Vec<comp::Achievement>>(), (
achievement.uuid.clone(),
comp::CharacterAchievement::from(CharacterAchievementJoinData {
character_achievement,
achievement,
}),
)
})
.collect(),
)) ))
} }
pub fn sync(db_dir: &str) -> Result<Vec<AchievementModel>, Error> { pub fn sync(db_dir: &str) -> Result<Vec<comp::Achievement>, Error> {
let achievements = load_data(); let achievements = load_data();
let connection = establish_connection(db_dir); let connection = establish_connection(db_dir);
// Use the full dataset for checks // Use the full dataset for checksums
let persisted_achievements = let persisted_achievements =
schema::achievement::dsl::achievement.load::<AchievementModel>(&connection)?; schema::achievements::dsl::achievements.load::<AchievementModel>(&connection)?;
// Get a hash of the Vec<Achievement> to compare to the migration table // Get a hash of the Vec<Achievement> we have in config to compare
let result = schema::data_migration::dsl::data_migration let result = schema::data_migration::dsl::data_migration
.filter(schema::data_migration::title.eq(String::from("achievements"))) .filter(schema::data_migration::title.eq(String::from("achievements")))
.first::<DataMigration>(&connection); .first::<DataMigration>(&connection);
@ -132,12 +139,11 @@ pub fn sync(db_dir: &str) -> Result<Vec<AchievementModel>, Error> {
let should_run = match result { let should_run = match result {
Ok(migration_entry) => { Ok(migration_entry) => {
let checksum = &migration_entry.checksum; // If these don't match, we need to sync data
info!(?checksum, "checksum: ");
migration_entry.checksum != hash(&achievements).to_string() migration_entry.checksum != hash(&achievements).to_string()
}, },
Err(diesel::result::Error::NotFound) => { Err(diesel::result::Error::NotFound) => {
// If there was no migration entry (first run on this server) we need to run
let migration = NewDataMigration { let migration = NewDataMigration {
title: "achievements", title: "achievements",
checksum: &hash(&achievements).to_string(), checksum: &hash(&achievements).to_string(),
@ -158,39 +164,29 @@ pub fn sync(db_dir: &str) -> Result<Vec<AchievementModel>, Error> {
}; };
if (should_run || persisted_achievements.is_empty()) && !achievements.is_empty() { if (should_run || persisted_achievements.is_empty()) && !achievements.is_empty() {
let items = &achievements;
info!(?items, "Achievements need updating...");
// Make use of the unique constraint in the DB, attempt to insert, on unique // 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 // failure check if it needs updating and do so if necessary
for item in &achievements { for item in &achievements {
let new_item = NewAchievement::from(item); if let Err(error) = diesel::insert_into(schema::achievements::table)
.values(item)
if let Err(error) = diesel::insert_into(schema::achievement::table)
.values(&new_item)
.execute(&connection) .execute(&connection)
{ {
match error { match error {
DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _) => { DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _) => {
let entry = persisted_achievements // This uuid already exists, so overwrite the data
if let Some(existing_item) = persisted_achievements
.iter() .iter()
.find(|&a| &a.checksum == &new_item.checksum); .find(|&a| &a.uuid == &item.uuid)
{
if let Some(existing_item) = entry { match diesel::update(
if existing_item.details != new_item.details { schema::achievements::dsl::achievements
match diesel::update( .filter(schema::achievements::uuid.eq(&existing_item.uuid)),
schema::achievement::dsl::achievement.filter( )
schema::achievement::checksum .set(item)
.eq(String::from(&existing_item.checksum)), .execute(&connection)
), {
) Ok(_) => warn!(?existing_item.checksum, "Updated achievement"),
.set(schema::achievement::details.eq(new_item.details)) Err(err) => return Err(Error::DatabaseError(err)),
.execute(&connection)
{
Ok(_) => warn!(?existing_item.checksum, "Updated achievement"),
Err(err) => return Err(Error::DatabaseError(err)),
}
} }
} }
}, },
@ -211,17 +207,16 @@ pub fn sync(db_dir: &str) -> Result<Vec<AchievementModel>, Error> {
info!("No achievement updates required"); info!("No achievement updates required");
} }
let data = schema::achievement::dsl::achievement.load::<AchievementModel>(&connection)?; let data = schema::achievements::dsl::achievements.load::<AchievementModel>(&connection)?;
Ok(data) // Ok(data)
// Ok(data.iter().map(comp::Achievement::from).collect::<_>()) Ok(data.iter().map(comp::Achievement::from).collect::<_>())
} }
fn load_data() -> Vec<AchievementItem> { fn load_data() -> Vec<AchievementModel> {
// TODO this better
let manifest_dir = format!("{}/{}", env!("CARGO_MANIFEST_DIR"), "data/achievements.ron"); let manifest_dir = format!("{}/{}", env!("CARGO_MANIFEST_DIR"), "data/achievements.ron");
info!(?manifest_dir, "manifest_dir:");
match std::fs::canonicalize(manifest_dir) { match std::fs::canonicalize(manifest_dir) {
Ok(path) => match std::fs::File::open(path) { Ok(path) => match std::fs::File::open(path) {
Ok(file) => ron::de::from_reader(file).expect("Error parsing achievement data"), Ok(file) => ron::de::from_reader(file).expect("Error parsing achievement data"),
@ -234,7 +229,7 @@ fn load_data() -> Vec<AchievementItem> {
} }
} }
pub fn hash<T: Hash>(t: &T) -> u64 { fn hash<T: Hash>(t: &T) -> u64 {
let mut s = DefaultHasher::new(); let mut s = DefaultHasher::new();
t.hash(&mut s); t.hash(&mut s);
s.finish() s.finish()

View File

@ -1,11 +1,7 @@
extern crate serde_json; extern crate serde_json;
use super::{ use super::schema::{
achievement::hash, achievements, body, character, character_achievement, data_migration, inventory, loadout, stats,
schema::{
achievement, body, character, character_achievement, data_migration, inventory, loadout,
stats,
},
}; };
use crate::comp; use crate::comp;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
@ -373,114 +369,58 @@ pub struct NewDataMigration<'a> {
pub last_run: NaiveDateTime, pub last_run: NaiveDateTime,
} }
/// Achievements hold the data related to achievements available in-game. They /// The achievements table holds data related to all achievements available to
/// are the data referenced for characters /// players in-game. They serve as a strong reference for character achievements
/// A wrapper type for Loadout components used to serialise to and from JSON /// (see below) and are populated form configuration files holding the data
/// If the column contains malformed JSON, a default loadout is returned, with #[derive(Queryable, Debug, Identifiable, AsChangeset, Hash, Insertable, Deserialize)]
/// the starter sword set as the main weapon #[primary_key(uuid)]
#[derive(SqlType, AsExpression, Debug, Deserialize, Serialize, FromSqlRow, PartialEq)]
#[sql_type = "Text"]
pub struct AchievementData(comp::AchievementItem);
impl<DB> diesel::deserialize::FromSql<Text, DB> for AchievementData
where
DB: diesel::backend::Backend,
String: diesel::deserialize::FromSql<Text, DB>,
{
fn from_sql(
bytes: Option<&<DB as diesel::backend::Backend>::RawValue>,
) -> diesel::deserialize::Result<Self> {
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(comp::AchievementItem {
title: String::new(),
action: comp::AchievementAction::None,
target: 0,
}))
},
}
}
}
impl<DB> diesel::serialize::ToSql<Text, DB> for AchievementData
where
DB: diesel::backend::Backend,
{
fn to_sql<W: std::io::Write>(
&self,
out: &mut diesel::serialize::Output<W, DB>,
) -> diesel::serialize::Result {
let s = serde_json::to_string(&self.0)?;
<String as diesel::serialize::ToSql<Text, DB>>::to_sql(&s, out)
}
}
#[derive(Queryable, Debug, Identifiable)]
#[table_name = "achievement"]
pub struct Achievement { pub struct Achievement {
pub id: i32, pub uuid: String,
pub checksum: String, pub checksum: String,
pub details: AchievementData, pub title: String,
} pub action: i32,
pub target: i32,
impl From<&AchievementData> for comp::AchievementItem {
fn from(data: &AchievementData) -> comp::AchievementItem { data.0.clone() }
}
#[derive(Insertable, PartialEq, Debug)]
#[table_name = "achievement"]
pub struct NewAchievement {
pub checksum: String,
pub details: AchievementData,
}
impl From<&comp::AchievementItem> for NewAchievement {
fn from(item: &comp::AchievementItem) -> Self {
Self {
checksum: hash(item).to_string(),
details: AchievementData(item.clone()),
}
}
}
/// Character Achievements belong to characters
#[derive(Queryable, Debug, Identifiable)]
#[primary_key(character_id)]
#[table_name = "character_achievement"]
pub struct CharacterAchievement {
pub character_id: i32,
pub achievement_id: i32,
pub completed: i32,
pub progress: i32,
}
impl From<&CharacterAchievement> for comp::Achievement {
fn from(achievement: &CharacterAchievement) -> comp::Achievement {
comp::Achievement {
id: achievement.achievement_id,
item: comp::AchievementItem {
title: String::from("TODO"),
action: comp::AchievementAction::None,
target: 0,
},
completed: achievement.completed != 0,
progress: achievement.progress as usize,
}
}
} }
impl From<&Achievement> for comp::Achievement { impl From<&Achievement> for comp::Achievement {
fn from(achievement: &Achievement) -> comp::Achievement { fn from(achievement: &Achievement) -> comp::Achievement {
comp::Achievement { comp::Achievement {
id: achievement.id, uuid: achievement.uuid.clone(),
item: comp::AchievementItem::from(&achievement.details), title: achievement.title.clone(),
completed: false, action: comp::AchievementAction::None, // TODO find a way to store this data
progress: 0, target: achievement.target as usize,
}
}
}
/// Character Achievements belong to characters, and contain information about
/// achievement progress.
///
/// 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)]
#[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,
}
/// The required elements to build comp::CharacterAchievement from database data
pub struct CharacterAchievementJoinData<'a> {
pub character_achievement: &'a CharacterAchievement,
pub achievement: &'a Achievement,
}
impl From<CharacterAchievementJoinData<'_>> 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,
} }
} }
} }

View File

@ -1,8 +1,10 @@
table! { table! {
achievement (id) { achievements (uuid) {
id -> Integer, uuid -> Text,
checksum -> Text, checksum -> Text,
details -> Text, title -> Text,
action -> Integer,
target -> Integer,
} }
} }
@ -33,7 +35,7 @@ table! {
table! { table! {
character_achievement (character_id) { character_achievement (character_id) {
character_id -> Integer, character_id -> Integer,
achievement_id -> Integer, achievement_uuid -> Text,
completed -> Integer, completed -> Integer,
progress -> Integer, progress -> Integer,
} }
@ -76,14 +78,14 @@ table! {
} }
joinable!(body -> character (character_id)); joinable!(body -> character (character_id));
joinable!(character_achievement -> achievement (achievement_id));
joinable!(character_achievement -> character (character_id)); joinable!(character_achievement -> character (character_id));
joinable!(character_achievement -> achievements (achievement_uuid));
joinable!(inventory -> character (character_id)); joinable!(inventory -> character (character_id));
joinable!(loadout -> character (character_id)); joinable!(loadout -> character (character_id));
joinable!(stats -> character (character_id)); joinable!(stats -> character (character_id));
allow_tables_to_appear_in_same_query!( allow_tables_to_appear_in_same_query!(
achievement, achievements,
body, body,
character, character,
character_achievement, character_achievement,