Implement basic functionality for recognising achievement updates,

updating associated data and clarifying events
This commit is contained in:
Shane Handley 2020-07-04 04:09:39 +10:00
parent 3ff5e105dd
commit e7c47e1d38
13 changed files with 371 additions and 158 deletions

View File

@ -1000,6 +1000,9 @@ impl Client {
ServerMsg::AchievementDataError(error) => {
// TODO handle somehow
},
ServerMsg::AchievementCompletion => {
tracing::info!("Completed achievement");
},
}
}
}

View File

@ -1,82 +1,95 @@
use crate::{
comp::{self, inventory::item::Consumable, InventoryUpdateEvent},
event::ServerEvent,
};
use crate::comp::item::{Consumable, Item, ItemKind};
use specs::{Component, FlaggedStorage};
use specs_idvs::IDVStorage;
pub enum AchievementCategory {
CollectConsumable,
ReachLevel,
KillHumanoidSpecies,
KillBodyType,
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum AchievementEvent {
None,
CollectedItem(Item),
LevelUp(u32),
}
// Potential additions
// - ReachCoordinate
// - CollectCurrency(amount)
// - KillPlayers(amount)
// - OpenChests
/// The types of achievements available in game
///
/// Some potential additions in the future:
/// - ReachCoordinate
/// - CollectCurrency
/// - KillPlayers
/// - OpenChests
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AchievementType {
CollectConsumable(Consumable, i32),
ReachLevel(i32),
KillHumanoidSpecies(comp::body::humanoid::Species, i32),
KillBodyType(comp::Body, i32),
pub enum AchievementAction {
None,
CollectConsumable(Consumable),
ReachLevel,
}
impl From<AchievementType> for AchievementCategory {
fn from(achievement_type: AchievementType) -> AchievementCategory {
match achievement_type {
AchievementType::CollectConsumable(_, _) => AchievementCategory::CollectConsumable,
AchievementType::ReachLevel(_) => AchievementCategory::ReachLevel,
AchievementType::KillHumanoidSpecies(_, _) => AchievementCategory::KillHumanoidSpecies,
AchievementType::KillBodyType(_, _) => AchievementCategory::KillBodyType,
}
}
}
/// The representation of an achievement that is declared in .ron config.
/// Information about an achievement. This differs from a complete
/// [`Achievement`](struct.Achievement.html) in that it describes the
/// achievement without any information about progress
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AchievementItem {
pub title: String,
pub achievement_type: AchievementType,
}
/// TODO remove this, it's a confusing state
impl Default for AchievementItem {
fn default() -> Self {
Self {
title: String::new(),
achievement_type: AchievementType::ReachLevel(0),
}
}
pub action: AchievementAction,
pub target: usize,
}
impl AchievementItem {
pub fn matches_event(&self, event: InventoryUpdateEvent) -> bool {
pub fn matches_event(&self, event: AchievementEvent) -> bool {
match event {
InventoryUpdateEvent::Collected(_item) => true,
_ => false,
AchievementEvent::LevelUp(_) => self.action == AchievementAction::ReachLevel,
AchievementEvent::CollectedItem(item) => match self.action {
AchievementAction::CollectConsumable(consumable) => {
if let ItemKind::Consumable { kind, .. } = item.kind {
kind == consumable
} else {
false
}
},
_ => false,
},
_ => {
tracing::warn!(
?event,
"An AchievementEvent was processed but the event was not handled"
);
false
},
}
}
}
/// The complete representation of an achievement that has been
#[derive(Clone, Default, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Achievement {
pub id: i32,
pub item: AchievementItem,
pub completed: bool,
pub progress: i32,
pub progress: usize,
}
// impl Achievement {
// pub fn incr(&self, event: InventoryUpdateEvent) -> Option<bool> {
//
// }
// }
impl Achievement {
/// Increment the progress of this Achievement 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
/// handling of unique types of achievements which are not simple
/// counters for events
pub fn increment_progress(&mut self, event: AchievementEvent) -> bool {
match event {
AchievementEvent::LevelUp(level) => {
self.progress = level as usize;
},
_ => self.progress += 1,
};
self.completed = self.progress >= self.item.target;
self.completed
}
}
/// The achievement List assigned to all players. This holds a list of
/// achievements where the player has made some progress towards completion.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AchievementList(Vec<Achievement>);
@ -84,16 +97,215 @@ impl Default for AchievementList {
fn default() -> AchievementList { AchievementList(Vec::new()) }
}
impl AchievementList {
/// Process a single achievement item, inrementing or doing whataver it does
/// to indicate it's one step closer to cmpletion
pub fn process(&self, item: &AchievementItem) -> Option<bool> {
// if self.0.iter().
None
}
}
impl Component for AchievementList {
type Storage = FlaggedStorage<Self, IDVStorage<Self>>;
}
impl AchievementList {
pub fn item_by_id(&mut self, id: i32) -> Option<&mut Achievement> {
self.0.iter_mut().find(|a| a.id == id)
}
/// Process a single achievement item, inrementing the progress of the
/// achievement. This is called as part of server/sys/Achievements.
pub fn process_achievement(
&mut self,
achievement: Achievement,
event: AchievementEvent,
) -> bool {
let id = achievement.id;
if !self.0.contains(&achievement) {
self.0.push(achievement);
}
return if let Some(char_achievement) = self.item_by_id(id) {
if char_achievement.completed {
return false;
}
char_achievement.increment_progress(event)
} else {
tracing::warn!("Failed to find achievement after inserting");
false
};
}
}
/// Used as a container 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.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AchievementUpdate {
event: AchievementEvent,
}
impl AchievementUpdate {
pub fn new(event: AchievementEvent) -> Self { Self { event } }
pub fn event(&self) -> AchievementEvent { self.event.clone() }
}
impl Component for AchievementUpdate {
type Storage = FlaggedStorage<Self, IDVStorage<Self>>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{assets, comp::item::Consumable};
#[test]
fn inv_collect_event_matches_consumable_achievement_item() {
let item = AchievementItem {
title: String::from("Test"),
action: AchievementAction::CollectConsumable(Consumable::Apple),
target: 10,
};
let event =
AchievementEvent::CollectedItem(assets::load_expect_cloned("common.items.apple"));
assert!(item.matches_event(event));
}
#[test]
fn inv_collect_event_not_matches_consumable_achievement_item() {
let item = AchievementItem {
title: String::from("Test"),
action: AchievementAction::CollectConsumable(Consumable::Cheese),
target: 10,
};
let event =
AchievementEvent::CollectedItem(assets::load_expect_cloned("common.items.apple"));
assert_eq!(item.matches_event(event), false);
}
#[test]
fn levelup_event_matches_reach_level_achievement_item() {
let item = AchievementItem {
title: String::from("Test"),
action: AchievementAction::ReachLevel,
target: 100,
};
let event = AchievementEvent::LevelUp(3);
assert_eq!(item.matches_event(event), true);
}
#[test]
fn process_collect_achievement_increments_progress() {
let item = AchievementItem {
title: String::from("Collect 3 Mushrooms"),
action: AchievementAction::CollectConsumable(Consumable::Mushroom),
target: 3,
};
let achievement = Achievement {
id: 1,
item,
completed: false,
progress: 0,
};
let event =
AchievementEvent::CollectedItem(assets::load_expect_cloned("common.items.mushroom"));
let mut achievement_list = AchievementList::default();
// The first two increments should not indicate that it is complete
assert_eq!(
achievement_list.process_achievement(achievement.clone(), event.clone()),
false
);
assert_eq!(
achievement_list.process_achievement(achievement.clone(), event.clone()),
false
);
assert_eq!(achievement_list.0.get(0).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]
fn process_levelup_achievement_increments_progress() {
let item = AchievementItem {
title: String::from("Reach Level 10"),
action: AchievementAction::ReachLevel,
target: 10,
};
let achievement = Achievement {
id: 1,
item,
completed: false,
progress: 1,
};
let mut achievement_list = AchievementList::default();
assert_eq!(
achievement_list.process_achievement(achievement.clone(), AchievementEvent::LevelUp(6)),
false
);
// The achievement progress should be the new level value, and be incomplete
assert_eq!(achievement_list.0.get(0).unwrap().progress, 6);
assert_eq!(achievement_list.0.get(0).unwrap().completed, false);
assert_eq!(
achievement_list.process_achievement(achievement, AchievementEvent::LevelUp(10)),
true
);
// The achievement progress should be the new level value, and be completed
assert_eq!(achievement_list.0.get(0).unwrap().progress, 10);
assert_eq!(achievement_list.0.get(0).unwrap().completed, true);
}
#[test]
fn process_completed_achievement_doesnt_increment_progress() {
let item = AchievementItem {
title: String::from("Collect 3 Mushrooms"),
action: AchievementAction::CollectConsumable(Consumable::Mushroom),
target: 3,
};
let achievement = Achievement {
id: 1,
item,
completed: true,
progress: 3,
};
let mut achievement_list = AchievementList(vec![achievement.clone()]);
let event =
AchievementEvent::CollectedItem(assets::load_expect_cloned("common.items.mushroom"));
assert_eq!(
achievement_list.process_achievement(achievement, event),
false
);
// The achievement progress should not have incremented
assert_eq!(achievement_list.0.get(0).unwrap().progress, 3);
}
}

View File

@ -21,7 +21,10 @@ mod visual;
// Reexports
pub use ability::{CharacterAbility, CharacterAbilityType, ItemConfig, Loadout};
pub use achievement::{Achievement, AchievementItem, AchievementList, AchievementType};
pub use achievement::{
Achievement, AchievementAction, AchievementEvent, AchievementItem, AchievementList,
AchievementUpdate,
};
pub use admin::{Admin, AdminList};
pub use agent::{Agent, Alignment};
pub use body::{

View File

@ -83,10 +83,6 @@ pub enum ServerEvent {
Chat(comp::ChatMsg),
}
pub enum AchievementEvent {
CollectedItem { entity: EcsEntity, item: comp::Item },
}
pub struct EventBus<E> {
queue: Mutex<VecDeque<E>>,
}

View File

@ -64,6 +64,8 @@ pub enum ServerMsg {
AchievementDataUpdate(Vec<comp::Achievement>),
/// An error occurred while loading character achievements
AchievementDataError(String),
/// The client has completed an achievement
AchievementCompletion,
/// An error occurred while loading character data
CharacterDataLoadError(String),
/// A list of characters belonging to the a authenticated player was sent

View File

@ -150,6 +150,7 @@ impl State {
ecs.register::<comp::WaypointArea>();
ecs.register::<comp::ForceUpdate>();
ecs.register::<comp::InventoryUpdate>();
ecs.register::<comp::AchievementUpdate>();
ecs.register::<comp::Admin>();
ecs.register::<comp::Waypoint>();
ecs.register::<comp::Projectile>();

View File

@ -1,10 +1,9 @@
[
(
title: "Collect 10 Apples",
achievement_type: CollectConsumable(Apple, 10)
),
(
title: "Collect 50 Apples",
achievement_type: CollectConsumable(Apple, 50)
),
title: "Collect 5 Apples",
action: CollectItemKind(Consumable(
kind: Apple
)),
target: 5
)
]

View File

@ -1,7 +1,10 @@
use crate::{client::Client, Server, SpawnPoint, StateExt};
use common::{
assets,
comp::{self, item::lottery::Lottery, object, Body, HealthChange, HealthSource, Player, Stats},
comp::{
self, item::lottery::Lottery, object, AchievementEvent, Body, HealthChange, HealthSource,
Player, Stats,
},
msg::{PlayerListUpdate, ServerMsg},
state::BlockChange,
sync::{Uid, WorldSyncExt},
@ -314,6 +317,12 @@ pub fn handle_level_up(server: &mut Server, entity: EcsEntity, new_level: u32) {
.get(entity)
.expect("Failed to fetch uid component for entity.");
// Write an achievement update to trigger level achievements
let _ = server.state.ecs().write_storage().insert(
entity,
comp::AchievementUpdate::new(AchievementEvent::LevelUp(new_level)),
);
server
.state
.notify_registered_clients(ServerMsg::PlayerListUpdate(PlayerListUpdate::LevelChange(

View File

@ -3,9 +3,9 @@ use common::{
comp::{
self, item,
slot::{self, Slot},
Pos, MAX_PICKUP_RANGE_SQR,
AchievementEvent, Pos, MAX_PICKUP_RANGE_SQR,
},
event::{AchievementEvent, EventBus},
event::AchievementEvent,
sync::{Uid, WorldSyncExt},
terrain::block::Block,
vol::{ReadVol, Vox},
@ -86,13 +86,10 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv
let item = picked_up_item.unwrap();
state
.ecs()
.read_resource::<EventBus<AchievementEvent>>()
.emit_now(AchievementEvent::CollectedItem {
entity,
item: item.clone(),
});
state.write_component(
entity,
comp::AchievementUpdate::new(AchievementEvent::CollectedItem(item.clone())),
);
comp::InventoryUpdate::new(comp::InventoryUpdateEvent::Collected(item))
} else {
@ -122,13 +119,12 @@ pub fn handle_inventory(server: &mut Server, entity: EcsEntity, manip: comp::Inv
&& state.try_set_block(pos, Block::empty()).is_some()
{
comp::Item::try_reclaim_from_block(block).map(|item| {
state
.ecs()
.read_resource::<EventBus<AchievementEvent>>()
.emit_now(AchievementEvent::CollectedItem {
entity,
item: item.clone(),
});
state.write_component(
entity,
comp::AchievementUpdate::new(AchievementEvent::CollectedItem(
item.clone(),
)),
);
state.give_item(entity, item);
});

View File

@ -30,7 +30,7 @@ use crate::{
use common::{
cmd::ChatCommand,
comp::{self, ChatType},
event::{AchievementEvent, EventBus, ServerEvent},
event::{EventBus, ServerEvent},
msg::{ClientState, ServerInfo, ServerMsg},
state::{State, TimeOfDay},
sync::WorldSyncExt,
@ -101,9 +101,6 @@ impl Server {
// Event Emitters
state.ecs_mut().insert(EventBus::<ServerEvent>::default());
state
.ecs_mut()
.insert(EventBus::<AchievementEvent>::default());
state.ecs_mut().insert(AuthProvider::new(
settings.auth_server_address.clone(),
@ -262,10 +259,11 @@ impl Server {
// Sync and Load Achievement Data
debug!("Syncing Achievement data...");
// TODO I switched this to return comp::Achievement but that's not right...we
// want the id really,
match persistence::achievement::sync(&settings.persistence_db_dir) {
Ok(achievements) => {
info!("Achievement data loaded...");
info!(?achievements, "data:");
state.ecs_mut().insert(AvailableAchievements(achievements));
},
Err(e) => error!(?e, "Achievement data migration error"),
@ -431,20 +429,6 @@ impl Server {
}
}
let achievement_events = self
.state
.ecs()
.read_resource::<EventBus<AchievementEvent>>()
.recv_all();
for event in achievement_events {
match event {
AchievementEvent::CollectedItem { entity, item } => {
info!(?item, "Achievement event: item");
},
}
}
// 7 Persistence updates
let before_persistence_updates = Instant::now();

View File

@ -132,7 +132,7 @@ pub fn sync(db_dir: &str) -> Result<Vec<AchievementModel>, Error> {
info!(?checksum, "checksum: ");
migration_entry.checksum != hash(&achievements).to_string()
}
},
Err(diesel::result::Error::NotFound) => {
let migration = NewDataMigration {
title: "achievements",
@ -145,12 +145,12 @@ pub fn sync(db_dir: &str) -> Result<Vec<AchievementModel>, Error> {
.execute(&connection)?;
true
}
},
Err(_) => {
error!("Failed to run migrations"); // TODO better error messaging
false
}
},
};
if (should_run || persisted_achievements.is_empty()) && !achievements.is_empty() {
@ -189,7 +189,7 @@ pub fn sync(db_dir: &str) -> Result<Vec<AchievementModel>, Error> {
}
}
}
}
},
_ => return Err(Error::DatabaseError(error)),
}
}
@ -207,7 +207,10 @@ pub fn sync(db_dir: &str) -> Result<Vec<AchievementModel>, Error> {
info!("No achievement updates required");
}
Ok(schema::achievement::dsl::achievement.load::<AchievementModel>(&connection)?)
let data = schema::achievement::dsl::achievement.load::<AchievementModel>(&connection)?;
Ok(data)
// Ok(data.iter().map(comp::Achievement::from).collect::<_>())
}
fn load_data() -> Vec<AchievementItem> {
@ -223,7 +226,7 @@ fn load_data() -> Vec<AchievementItem> {
Err(error) => {
warn!(?error, "Unable to find achievement data file");
Vec::new()
}
},
}
}

View File

@ -397,7 +397,11 @@ where
Err(e) => {
warn!(?e, "Failed to deserialise achevement data");
Ok(Self(comp::AchievementItem::default()))
Ok(Self(comp::AchievementItem {
title: String::new(),
action: comp::AchievementAction::None,
target: 0,
}))
},
}
}
@ -456,13 +460,22 @@ pub struct CharacterAchievement {
}
impl From<&CharacterAchievement> for comp::Achievement {
fn from(_achievement: &CharacterAchievement) -> comp::Achievement {
comp::Achievement::default()
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 {
fn from(achievement: Achievement) -> comp::Achievement {
impl From<&Achievement> for comp::Achievement {
fn from(achievement: &Achievement) -> comp::Achievement {
comp::Achievement {
id: achievement.id,
item: comp::AchievementItem::from(&achievement.details),

View File

@ -1,56 +1,48 @@
use crate::persistence::achievement::AvailableAchievements;
use common::comp::{AchievementItem, AchievementList, InventoryUpdate, Player};
use specs::{Entities, Join, ReadExpect, ReadStorage, System};
use tracing::info;
use crate::client::Client;
use common::{
comp::{Achievement, AchievementItem, AchievementList, AchievementUpdate},
msg::ServerMsg,
};
use specs::{Join, ReadExpect, System, WriteStorage};
pub struct Sys;
impl<'a> System<'a> for Sys {
#[allow(clippy::type_complexity)] // TODO: Pending review in #587
type SystemData = (
Entities<'a>,
ReadStorage<'a, Player>,
ReadStorage<'a, AchievementList>,
ReadStorage<'a, InventoryUpdate>,
WriteStorage<'a, Client>,
WriteStorage<'a, AchievementList>,
WriteStorage<'a, AchievementUpdate>,
ReadExpect<'a, AvailableAchievements>,
);
fn run(
&mut self,
(entities, players, achievement_lists, inventory_updates, available_achievements): Self::SystemData,
(mut clients, mut achievement_lists, mut achievement_updates, available_achievements): Self::SystemData,
) {
for (_entity, _player, ach_list, inv_event) in
(&entities, &players, &achievement_lists, &inventory_updates).join()
// TODO filter out achievements which do not care about this event here, then
// iterate over them
for (client, achievement_list, ach_update) in
(&mut clients, &mut achievement_lists, &achievement_updates).join()
{
(available_achievements.0)
.iter()
.for_each(|achievement_item| {
let ach_item = AchievementItem::from(&achievement_item.details);
// pass the event to each achievement
// achievement checks if the event matches what it is looking for
if ach_item.matches_event(inv_event.event()) {
if let Some(event) = ach_list.process(&ach_item) {
info!(?event, "Achievement event");
}
(available_achievements.0).iter().for_each(|achievement| {
let achievement_item = AchievementItem::from(&achievement.details);
// if it's a match, pass it to the characters
// achievement list
// get a result from the call to the players achievement
// list
// - It checks for an entry No entry = append and
// increment Entry = Increment
// inrement(achievement_id, ?amount)
// if let Some(results) =
// _player_character_list.process(achievement_item) {
//
// - if its a completion result, dispatch an event which
// notifies the client
// server_events.dispatch(ServerEvent::
// AchievementUpdate(TheAchievementInfo))
// }
if achievement_item.matches_event(ach_update.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
if achievement_list
.process_achievement(Achievement::from(achievement), ach_update.event())
== true
{
client.notify(ServerMsg::AchievementCompletion);
}
});
}
});
}
achievement_updates.clear();
}
}