Rework storage of achievement progress for characters, now just storing

a single row for each character with a JSON object with information on
achievement progress.

Work on the process of merging a character's achievement progress with
the master list of achievements when it is returned to the client.
This commit is contained in:
Shane Handley 2020-07-07 14:26:25 +10:00
parent a21e58d06c
commit fb12c25245
13 changed files with 210 additions and 103 deletions

View File

@ -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<Achievement> 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<String, CharacterAchievement>) -> Self { Self(data) }
pub fn new(items: HashMap<String, CharacterAchievement>) -> Self { Self(items) }
pub fn items(&self) -> HashMap<String, CharacterAchievement> { self.0.clone() }
}
impl Component for AchievementList {
type Storage = FlaggedStorage<Self, IdvStorage<Self>>; // TODO check
type Storage = IdvStorage<Self>;
}
impl AchievementList {
@ -150,11 +150,17 @@ impl AchievementList {
achievement: &Achievement,
event: &AchievementEvent,
) -> Option<CharacterAchievement> {
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()
}

View File

@ -158,6 +158,7 @@ impl State {
ecs.register::<comp::ChatMode>();
ecs.register::<comp::Group>();
ecs.register::<comp::Faction>();
ecs.register::<comp::AchievementList>();
// Register synced resources used by the ECS.
ecs.insert(TimeOfDay(0.0));

View File

@ -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::<Player>().get(entity),
state.read_storage::<comp::Stats>().get(entity),
state.read_storage::<comp::Inventory>().get(entity),
state.read_storage::<comp::Loadout>().get(entity),
state.read_storage::<comp::AchievementList>().get(entity),
state
.ecs()
.read_resource::<persistence::character::CharacterUpdater>(),
) {
if let Some(character_id) = player.character_id {
updater.update(character_id, stats, inventory, loadout);
updater.update(character_id, stats, inventory, loadout, achievement_list);
}
}

View File

@ -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<comp::Achievement>,
/// A list of all achievements available to characetrs on this server.
achievement_data: HashSet<comp::CharacterAchievement>,
}
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::<HashSet<comp::CharacterAchievement>>(),
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()),

View File

@ -1 +0,0 @@
DROP TABLE IF EXISTS "character_achievement";

View File

@ -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
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS "character_achievements";

View File

@ -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
);

View File

@ -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<comp::AchievementList, Error>);
type LoadCharacterAchievementsResult = (
specs::Entity,
Result<HashSet<comp::CharacterAchievement>, 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<comp::AchievementList, Error> {
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<HashSet<comp::CharacterAchievement>, Error> {
let character_achievements = schema::character_achievements::dsl::character_achievements
.filter(schema::character_achievements::character_id.eq(character_id))
.first::<CharacterAchievements>(&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<comp::CharacterAchievement> =
character_achievements.items.0.iter().cloned().collect();
Ok(result)
}
pub fn sync(db_dir: &str) -> Result<Vec<comp::Achievement>, Error> {

View File

@ -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<Item = (i32, &'a comp::Stats, &'a comp::Inventory, &'a comp::Loadout)>,
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<Item = (i32, CharacterUpdateData)>, 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 {

View File

@ -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<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,
/// 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::<Vec<_>>();
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<comp::CharacterAchievement>);
impl<DB> diesel::deserialize::FromSql<Text, DB> for CharacterAchievementData
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)?;
serde_json::from_str(&t).map_err(Box::from)
}
}
impl<DB> diesel::serialize::ToSql<Text, DB> for CharacterAchievementData
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)
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -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,

View File

@ -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<Self>>,
Write<'a, SysTimer<Self>>,
@ -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();
}