diff --git a/README.md b/README.md index 40d20c4..f306e0f 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ Un gestionnaire de trésors pour des joueurs de Donjon&Dragons(tm). ## Fonctionnalités prévues * Ajouter des objets - ☐ Acheter + ☑ Acheter ☐ Ajouter un trésor (objet par objet ou par liste) * Répartir les objets entre les joueurs et le groupe - ☐ Demander un objet + ☑ Demander un objet ☐ Résoudre un conflit ☐ Finaliser la répartition après un délai défini * Vendre les objets du groupe et répartir équitablement leur valeur entre les joueurs - ☐ Possibilité d'indiquer une variation du prix de vente globale et/ou pour chaque objet + ☑ Possibilité d'indiquer une variation du prix de vente globale et/ou pour chaque objet ☐ Possibilité d'indiquer des joueurs exclus de la répartition * Gérer les comptes du groupe et des joueurs ☑ Afficher le solde actuel et la dette envers le groupe diff --git a/lootalot_db/src/lib.rs b/lootalot_db/src/lib.rs index 3509296..513ec3a 100644 --- a/lootalot_db/src/lib.rs +++ b/lootalot_db/src/lib.rs @@ -4,8 +4,10 @@ //! This module wraps all needed database operations. //! It exports a public API for integration with various clients (REST Api, CLI, ...) extern crate dotenv; -#[macro_use] extern crate diesel; -#[macro_use] extern crate serde_derive; +#[macro_use] +extern crate diesel; +#[macro_use] +extern crate serde_derive; use diesel::prelude::*; use diesel::query_dsl::RunQueryDsl; @@ -14,326 +16,18 @@ use diesel::r2d2::{self, ConnectionManager}; pub mod models; mod schema; +pub use models::{ + claim::{Claim, Claims}, + item::{Item, LootManager, Inventory}, + player::{Player, Wealth, Players, AsPlayer}, +}; + /// The connection used pub type DbConnection = SqliteConnection; /// A pool of connections pub type Pool = r2d2::Pool>; /// The result of a query on DB pub type QueryResult = Result; -/// The result of an action provided by DbApi -pub type ActionResult = Result; - - -/// A wrapper providing an API over the database -/// It offers a convenient way to deal with connection. -/// -/// # Note -/// All methods consumes the DbApi, so that only one action -/// can be performed using a single instance. -/// -/// # Todo list -/// ```text -/// v .as_player() -/// // Needs an action's history (one entry only should be enough) -/// x .undo_last_action() -> Success status -/// v .as_admin() -/// // When adding loot, an identifier should be used to build some kind of history -/// vx .add_loot(identifier, [items_desc]) -> Success status -/// x .sell_loot([players], [excluded_item_ids]) -> Success status (bool, player_share) -/// // Claims should be resolved after a certain delay -/// x .set_claims_timeout() -/// v .resolve_claims() -/// v .add_player(player_data) -/// ``` -/// -pub struct DbApi<'q>(&'q DbConnection); - -impl<'q> DbApi<'q> { - /// Returns a DbApi using the user given connection - /// - /// # Usage - /// ``` - /// use lootalot_db::{DbConnection, DbApi}; - /// # use diesel::connection::Connection; - /// let conn = DbConnection::establish(":memory:").unwrap(); - /// let api = DbApi::with_conn(&conn); - /// ``` - pub fn with_conn(conn: &'q DbConnection) -> Self { - Self(conn) - } - /// Fetch the list of all players - pub fn fetch_players(self) -> QueryResult> { - Ok(schema::players::table.load::(self.0)?) - } - /// Fetch the inventory of items - /// - /// TODO: remove limit used for debug - pub fn fetch_inventory(self) -> QueryResult> { - Ok(schema::items::table.limit(100).load::(self.0)?) - } - /// Fetch all existing claims - pub fn fetch_claims(self) -> QueryResult> { - Ok(schema::claims::table.load::(self.0)?) - } - /// Wrapper for acting as a specific player - /// - /// # Usage - /// ``` - /// # use lootalot_db::{DbConnection, DbApi}; - /// # use diesel::connection::Connection; - /// # let conn = DbConnection::establish(":memory:").unwrap(); - /// # let api = DbApi::with_conn(&conn); - /// let player_id: i32 = 1; // Id that references player in DB - /// let player = api.as_player(player_id); - /// ``` - pub fn as_player(self, id: i32) -> AsPlayer<'q> { - AsPlayer { id, conn: self.0 } - } - - /// Wrapper for acting as the admin - pub fn as_admin(self) -> AsAdmin<'q> { - AsAdmin(self.0) - } -} - -/// A wrapper for interactions of players with the database. -/// Possible actions are exposed as methods -pub struct AsPlayer<'q> { - id: i32, - conn: &'q DbConnection, -} - -impl<'q> AsPlayer<'q> { - /// Fetch the content of a player's chest - /// - /// # Usage - /// ``` - /// # extern crate diesel_migrations; - /// # use lootalot_db::{DbConnection, DbApi}; - /// # use diesel::connection::Connection; - /// # let conn = DbConnection::establish(":memory:").unwrap(); - /// # diesel_migrations::run_pending_migrations(&conn).unwrap(); - /// # let api = DbApi::with_conn(&conn); - /// // Get loot of player with id of 1 - /// let loot = api.as_player(1).loot().unwrap(); - /// assert_eq!(format!("{:?}", loot), "[]".to_string()); - /// ``` - pub fn loot(self) -> QueryResult> { - Ok(models::Item::owned_by(self.id).load(self.conn)?) - } - /// Buy a batch of items and add them to this player chest - /// - /// Items can only be bought from inventory. Hence, the use - /// of the entity's id in 'items' table. - /// - /// # Params - /// List of (Item's id in inventory, Option) - /// - /// # Returns - /// Result containing the difference in coins after operation - pub fn buy<'a>(self, params: &Vec<(i32, Option)>) -> ActionResult<(Vec, (i32, i32, i32, i32))> { - let mut cumulated_diff: Vec<(i32, i32, i32, i32)> = Vec::with_capacity(params.len()); - let mut added_items: Vec = Vec::with_capacity(params.len()); - for (item_id, price_mod) in params.into_iter() { - if let Ok((item, diff)) = self.conn.transaction(|| { - use schema::looted::dsl::*; - let item = schema::items::table.find(item_id).first::(self.conn)?; - let new_item = models::item::NewLoot::to_player(self.id, &item); - diesel::insert_into(schema::looted::table) - .values(&new_item) - .execute(self.conn)?; - let added_item = models::Item::owned_by(self.id) - .order(id.desc()) - .first(self.conn)?; - let sell_price = match price_mod { - Some(modifier) => item.base_price as f32 * modifier, - None => item.base_price as f32 - }; - DbApi::with_conn(self.conn) - .as_player(self.id) - .update_wealth(-sell_price) - .map(|diff| (added_item, diff)) - }) { - cumulated_diff.push(diff); - added_items.push(item); - } - } - let all_diff = cumulated_diff.into_iter().fold((0,0,0,0), |sum, diff| { - (sum.0 + diff.0, sum.1 + diff.1, sum.2 + diff.2, sum.3 + diff.3) - }); - Ok((added_items, all_diff)) - } - /// Sell a set of items from this player chest - /// - /// # Returns - /// Result containing the difference in coins after operation - pub fn sell( - self, - params: &Vec<(i32, Option)>, - ) -> ActionResult<(i32, i32, i32, i32)> { - let mut all_results: Vec<(i32, i32, i32, i32)> = Vec::with_capacity(params.len()); - for (loot_id, price_mod) in params.into_iter() { - let res = self.conn.transaction(|| { - use schema::looted::dsl::*; - let loot = looted - .find(loot_id) - .first::(self.conn)?; - if loot.owner != self.id { - // If the item does not belong to player, - // it can't be what we're looking for - return Err(diesel::result::Error::NotFound); - } - let mut sell_value = loot.base_price as f32 / 2.0; - if let Some(modifier) = price_mod { - sell_value *= modifier; - } - let _deleted = diesel::delete(looted.find(loot_id)) - .execute(self.conn)?; - DbApi::with_conn(self.conn).as_player(self.id).update_wealth(sell_value) - }); - if let Ok(diff) = res { - all_results.push(diff) - } else { - // TODO: need to find a better way to deal with errors - return Err(diesel::result::Error::NotFound) - } - } - Ok(all_results.into_iter().fold((0,0,0,0), |sum, diff| { - (sum.0 + diff.0, sum.1 + diff.1, sum.2 + diff.2, sum.3 + diff.3) - })) - - } - - /// Adds the value in gold to the player's wealth. - /// - /// Value can be negative to substract wealth. - pub fn update_wealth(self, value_in_gp: f32) -> ActionResult<(i32, i32, i32, i32)> { - use schema::players::dsl::*; - let current_wealth = players - .find(self.id) - .select((cp, sp, gp, pp)) - .first::(self.conn)?; - // TODO: improve thisdiesel dependant transaction - // should be move inside a WealthUpdate method - let updated_wealth = models::Wealth::from_gp(current_wealth.to_gp() + value_in_gp); - // Difference in coins that is sent back - let (old, new) = (current_wealth.as_tuple(), updated_wealth.as_tuple()); - let diff = (new.0 - old.0, new.1 - old.1, new.2 - old.2, new.3 - old.3); - diesel::update(players) - .filter(id.eq(self.id)) - .set(&updated_wealth) - .execute(self.conn) - .map(|r| match r { - 1 => diff, - _ => panic!("RuntimeError: UpdateWealth did no changes at all!"), - }) - } - /// Put a claim on a specific item - pub fn claim(self, item: i32) -> ActionResult<()> { - let exists: bool = diesel::select(models::Loot::exists(item)).get_result(self.conn)?; - if !exists { - return Err(diesel::result::Error::NotFound); - }; - let claim = models::claim::NewClaim::new(self.id, item); - diesel::insert_into(schema::claims::table) - .values(&claim) - .execute(self.conn) - .map(|rows_updated| match rows_updated { - 1 => (), - _ => panic!("RuntimeError: Claim did no change at all!"), - }) - } - /// Withdraw claim - pub fn unclaim(self, item: i32) -> ActionResult<()> { - use schema::claims::dsl::*; - diesel::delete( - claims - .filter(loot_id.eq(item)) - .filter(player_id.eq(self.id)), - ) - .execute(self.conn) - .and_then(|rows_updated| match rows_updated { - 1 => Ok(()), - 0 => Err(diesel::result::Error::NotFound), - _ => panic!("RuntimeError: UnclaimItem did not make expected changes"), - }) - } -} - -/// Wrapper for interactions of admins with the DB. -pub struct AsAdmin<'q>(&'q DbConnection); - -impl<'q> AsAdmin<'q> { - /// Adds a player to the database - /// - /// Takes the player name and starting wealth (in gold value). - pub fn add_player(self, name: &str, start_wealth: f32) -> ActionResult<()> { - diesel::insert_into(schema::players::table) - .values(&models::player::NewPlayer::create(name, start_wealth)) - .execute(self.0) - .map(|rows_updated| match rows_updated { - 1 => (), - _ => panic!("RuntimeError: AddPlayer did not make expected changes"), - }) - } - - /// Adds a list of items to the group loot - /// - /// This offers complete control other created items, so that unique - /// items can be easily added. A user interface shall deal with - /// filling theses values for known items in inventory. - /// - /// # Params - /// List of (name, base_price) values for the new items - pub fn add_loot(self, items: Vec) -> ActionResult<()> { - for item_desc in items.iter() { - let new_item = models::item::NewLoot::to_group(item_desc); - diesel::insert_into(schema::looted::table) - .values(&new_item) - .execute(self.0)?; - } - Ok(()) - } - - /// Resolve all pending claims and dispatch claimed items. - /// - /// When a player gets an item, it's debt is increased by this item sell value - pub fn resolve_claims(self) -> ActionResult<()> { - // Fetch all claims, grouped by items. - let loot = models::Loot::owned_by(0).load(self.0)?; - let claims = schema::claims::table - .load::(self.0)? - .grouped_by(&loot); - // For each claimed item - let data = loot.into_iter().zip(claims).collect::>(); - dbg!(&data); - - for (loot, claims) in data { - match claims.len() { - 1 => { - let claim = claims.get(0).unwrap(); - let player_id = claim.player_id; - self.0.transaction(|| { - use schema::looted::dsl::*; - diesel::update(looted.find(claim.loot_id)) - .set(owner_id.eq(player_id)) - .execute(self.0)?; - diesel::delete(schema::claims::table.find(claim.id)) - .execute(self.0)?; - { - use schema::players::dsl::*; - diesel::update(players.find(player_id)) - .set(debt.eq(debt + (loot.base_price / 2))) - .execute(self.0) - } - })?; - }, - _ => (), - } - } - Ok(()) - } -} /// Sets up a connection pool and returns it. /// Uses the DATABASE_URL environment variable (must be set) @@ -346,8 +40,88 @@ pub fn create_pool() -> Pool { .expect("Failed to create pool.") } -#[cfg(test)] -mod tests { + +/// Sells a single item inside a transaction +/// +/// # Returns +/// The deleted entity and the updated Wealth (as a difference from previous value) +pub fn sell_item_transaction( + conn: &DbConnection, + id: i32, + loot_id: i32, + price_mod: Option, +) -> QueryResult<(Item, Wealth)> { + conn.transaction(|| { + let deleted = LootManager(conn, id) + .remove(loot_id)?; + let mut sell_value = + deleted.base_price as f64 / 2.0; + if let Some(modifier) = price_mod { + sell_value *= modifier; + } + let wealth = AsPlayer(conn, id) + .update_wealth(sell_value)?; + Ok((deleted, wealth)) + }) +} + +/// Buys a single item, copied from inventory. +/// Runs inside a transaction +/// +/// # Returns +/// The created entity and the updated Wealth (as a difference from previous value) +pub fn buy_item_from_inventory( + conn: &DbConnection, + id: i32, + item_id: i32, + price_mod: Option, +) -> QueryResult<(Item, Wealth)> { + conn.transaction(|| { + // Find item in inventory + let item = Inventory(conn).find(item_id)?; + let new_item = LootManager(conn, id).add_from(&item)?; + let sell_price = match price_mod { + Some(modifier) => item.base_price as f64 * modifier, + None => item.base_price as f64, + }; + AsPlayer(conn, id) + .update_wealth(-sell_price) + .map(|diff| (new_item, diff)) + }) +} + +/// Fetch all existing claims +pub fn fetch_claims(conn: &DbConnection) -> QueryResult> { + schema::claims::table.load::(conn) +} + +/// Resolve all pending claims and dispatch claimed items. +/// +/// When a player gets an item, it's debt is increased by this item sell value +pub fn resolve_claims(conn: &DbConnection) -> QueryResult<()> { + let data = models::claim::Claims(conn).grouped_by_item()?; + dbg!(&data); + + for (item, claims) in data { + match claims.len() { + 1 => { + let claim = claims.get(0).unwrap(); + let player_id = claim.player_id; + conn.transaction(|| { + claim.resolve_claim(conn)?; + //models::item::LootManager(self.0, 0).set_owner(claim.loot_id, claim.player_id)?; + models::player::AsPlayer(conn, player_id).update_debt(item.sell_value()) + })?; + } + _ => (), + } + } + Ok(()) +} + + +#[cfg(none)] +mod tests_old { use super::*; type TestConnection = DbConnection; @@ -419,13 +193,22 @@ mod tests { assert_eq!(claims.len(), 0); // Add items - assert_eq!(DbApi::with_conn(&conn).as_admin().add_loot(vec![ - ("Épée", 40), - ("Arc", 40), - ]).is_ok(), true); + assert_eq!( + DbApi::with_conn(&conn) + .as_admin() + .add_loot(vec![("Épée", 40), ("Arc", 40),]) + .is_ok(), + true + ); // Add players - DbApi::with_conn(&conn).as_admin().add_player("Player1", 0.0).unwrap(); - DbApi::with_conn(&conn).as_admin().add_player("Player2", 0.0).unwrap(); + DbApi::with_conn(&conn) + .as_admin() + .add_player("Player1", 0.0) + .unwrap(); + DbApi::with_conn(&conn) + .as_admin() + .add_player("Player2", 0.0) + .unwrap(); // Put claims on one different item each DbApi::with_conn(&conn).as_player(1).claim(1).unwrap(); DbApi::with_conn(&conn).as_player(2).claim(2).unwrap(); @@ -434,7 +217,10 @@ mod tests { // Check that both players received an item let players = DbApi::with_conn(&conn).fetch_players().unwrap(); for &i in [1, 2].into_iter() { - assert_eq!(DbApi::with_conn(&conn).as_player(i).loot().unwrap().len(), 1); + assert_eq!( + DbApi::with_conn(&conn).as_player(i).loot().unwrap().len(), + 1 + ); let player = players.get(i as usize).unwrap(); assert_eq!(player.debt, 20); } @@ -512,9 +298,7 @@ mod tests { .add_player("Player", 1000.0) .unwrap(); // Buy an item - let bought = DbApi::with_conn(&conn) - .as_player(1) - .buy(&vec![(1, None)]); + let bought = DbApi::with_conn(&conn).as_player(1).buy(&vec![(1, None)]); assert_eq!(bought.ok(), Some((0, 0, 0, -8))); // Returns diff of player wealth ? let chest = DbApi::with_conn(&conn).as_player(1).loot().unwrap(); assert_eq!(chest.len(), 1); @@ -525,10 +309,14 @@ mod tests { let player = players.get(1).unwrap(); assert_eq!(player.pp, 2); // A player cannot sell loot from an other's chest - let result = DbApi::with_conn(&conn).as_player(0).sell(&vec![(loot.id, None)]); + let result = DbApi::with_conn(&conn) + .as_player(0) + .sell(&vec![(loot.id, None)]); assert_eq!(result.is_ok(), false); // Sell back - let sold = DbApi::with_conn(&conn).as_player(1).sell(&vec![(loot.id, None)]); + let sold = DbApi::with_conn(&conn) + .as_player(1) + .sell(&vec![(loot.id, None)]); assert_eq!(sold.ok(), Some((0, 0, 0, 4))); let chest = DbApi::with_conn(&conn).as_player(1).loot().unwrap(); assert_eq!(chest.len(), 0); diff --git a/lootalot_db/src/models/claim.rs b/lootalot_db/src/models/claim.rs index ad785e5..107a1c7 100644 --- a/lootalot_db/src/models/claim.rs +++ b/lootalot_db/src/models/claim.rs @@ -1,8 +1,11 @@ -use crate::models::item::Loot; +use crate::{DbConnection, QueryResult}; +use diesel::prelude::*; + +use crate::models::{self, item::Loot}; use crate::schema::claims; /// A Claim is a request by a single player on an item from group chest. -#[derive(Identifiable, Queryable, Associations, Serialize, Debug)] +#[derive(Identifiable, Queryable, Associations, Serialize, Deserialize, Debug)] #[belongs_to(Loot)] pub struct Claim { /// DB Identifier @@ -15,15 +18,152 @@ pub struct Claim { pub resolve: i32, } +impl Claim { + pub fn resolve_claim(&self, conn: &DbConnection) -> QueryResult<()> { + let loot: Loot = Loot::find(self.loot_id).first(conn)?; + loot.set_owner(self.player_id, conn)?; + self.remove(conn)?; + Ok(()) + } + + fn remove(&self, conn: &DbConnection) -> QueryResult<()> { + diesel::delete(claims::table.find(self.id)).execute(conn)?; + Ok(()) + } +} + +pub struct Claims<'q>(pub &'q DbConnection); + +impl<'q> Claims<'q> { + pub fn all(&self) -> QueryResult> { + claims::table.load(self.0) + } + + /// Finds a single claim by association of player and loot ids. + pub fn find(&self, player_id: i32, loot_id: i32) -> QueryResult { + claims::table + .filter(claims::dsl::player_id.eq(player_id)) + .filter(claims::dsl::loot_id.eq(loot_id)) + .first(self.0) + } + + /// Adds a claim in database and returns it + pub fn add(self, player_id: i32, loot_id: i32) -> QueryResult { + // We need to validate that the claimed item exists + // AND is actually owned by group (id 0) + let _item = models::item::LootManager(self.0, 0).find(loot_id)?; + // We also check if claims does not already exists + if let Ok(_) = self.find(player_id, loot_id) { + return Err(diesel::result::Error::RollbackTransaction); + } + + let claim = NewClaim::new(player_id, loot_id); + diesel::insert_into(claims::table) + .values(&claim) + .execute(self.0)?; + // Return the created claim + claims::table + .order(claims::dsl::id.desc()) + .first::(self.0) + } + + /// Removes a claim from database, returning it + pub fn remove(self, player_id: i32, loot_id: i32) -> QueryResult { + let claim = self.find(player_id, loot_id)?; + claim.remove(self.0)?; + Ok(claim) + } + + pub fn filtered_by_loot(&self, loot_id: i32) -> QueryResult> { + claims::table + .filter(claims::dsl::loot_id.eq(loot_id)) + .load(self.0) + } + + pub(crate) fn grouped_by_item(&self) -> QueryResult)>> { + let group_loot: Vec = Loot::owned_by(0).load(self.0)?; + let claims = claims::table.load(self.0)?.grouped_by(&group_loot); + Ok(group_loot + .into_iter() + .map(|loot| loot.into_item()) + .zip(claims) + .collect::>()) + } +} + #[derive(Insertable, Debug)] #[table_name = "claims"] -pub(crate) struct NewClaim { +struct NewClaim { player_id: i32, loot_id: i32, } impl NewClaim { - pub(crate) fn new(player_id: i32, loot_id: i32) -> Self { + fn new(player_id: i32, loot_id: i32) -> Self { Self { player_id, loot_id } } } + +#[cfg(test)] +mod tests { + use super::*; + + type TestResult = Result<(), diesel::result::Error>; + + fn test_connection() -> Result { + let conn = + DbConnection::establish(":memory:").map_err(|_| diesel::result::Error::NotFound)?; + diesel_migrations::run_pending_migrations(&conn) + .map_err(|_| diesel::result::Error::NotFound)?; + let manager = models::player::Players(&conn); + manager.add("Player1", 0.0)?; + manager.add("Player2", 0.0)?; + crate::LootManager(&conn, 0).add_from(&crate::Item { + id: 0, + name: "Epee".to_string(), + base_price: 30, + })?; + crate::LootManager(&conn, 1).add_from(&crate::Item { + id: 0, + name: "Arc".to_string(), + base_price: 20, + })?; + Ok(conn) + } + + #[test] + fn add_claim() -> TestResult { + let conn = test_connection()?; + Claims(&conn).add(1, 1)?; + assert_eq!(Claims(&conn).all()?.len(), 1); + Ok(()) + } + + #[test] + fn cannot_duplicate_by_adding() -> TestResult { + let conn = test_connection()?; + Claims(&conn).add(1, 1)?; + let res = Claims(&conn).add(1, 1); + assert_eq!(res.is_err(), true); + assert_eq!(Claims(&conn).all()?.len(), 1); + Ok(()) + } + + #[test] + fn remove_claim() -> TestResult { + let conn = test_connection()?; + let claim = Claims(&conn).add(1, 1)?; + claim.remove(&conn); + assert_eq!(Claims(&conn).all()?.len(), 0); + Ok(()) + } + + #[test] + fn cannot_only_claim_from_group() -> TestResult { + let conn = test_connection()?; + let claim = Claims(&conn).add(1, 2); + assert_eq!(claim.is_err(), true); + assert_eq!(Claims(&conn).all()?.len(), 0); + Ok(()) + } +} diff --git a/lootalot_db/src/models/item.rs b/lootalot_db/src/models/item.rs index 5389d4e..3ac9fd4 100644 --- a/lootalot_db/src/models/item.rs +++ b/lootalot_db/src/models/item.rs @@ -1,17 +1,14 @@ -use crate::schema::looted; use diesel::dsl::{exists, Eq, Filter, Find, Select}; use diesel::expression::exists::Exists; use diesel::prelude::*; +use crate::schema::{items, looted}; +use crate::{DbConnection, QueryResult}; type ItemColumns = (looted::id, looted::name, looted::base_price); const ITEM_COLUMNS: ItemColumns = (looted::id, looted::name, looted::base_price); type OwnedBy = Select; -/// Represents a unique item in inventory -/// -/// It is also used as a public representation of Loot, since owner -/// information is implicit. -/// Or maybe this is a little too confusing ?? +/// Represents a basic item #[derive(Debug, Queryable, Serialize, Deserialize, Clone)] pub struct Item { pub id: i32, @@ -20,37 +17,123 @@ pub struct Item { } impl Item { - /// Public proxy for Loot::owned_by that selects only Item fields - pub fn owned_by(player: i32) -> OwnedBy { + pub fn value(&self) -> i32 { + self.base_price + } + + pub fn sell_value(&self) -> i32 { + self.base_price / 2 + } + + fn owned_by(player: i32) -> OwnedBy { Loot::owned_by(player).select(ITEM_COLUMNS) } } +pub struct Inventory<'q>(pub &'q DbConnection); + +impl<'q> Inventory<'q> { + pub fn all(&self) -> QueryResult> { + items::table.load::(self.0) + } + + pub fn find(&self, item_id: i32) -> QueryResult { + items::table.find(item_id).first::(self.0) + } +} + type WithOwner = Eq; type OwnedLoot = Filter; -/// Represents an item that has been looted -#[derive(Identifiable, Debug, Queryable, Serialize)] +/// Represents an item that has been looted, +/// hence has an owner. +#[derive(Identifiable, Debug, Queryable)] #[table_name = "looted"] -pub(crate) struct Loot { +pub(super) struct Loot { id: i32, name: String, - pub(crate) base_price: i32, - pub(crate) owner: i32, + base_price: i32, + owner: i32, } impl Loot { /// A filter on Loot that is owned by given player - pub(crate) fn owned_by(id: i32) -> OwnedLoot { + pub(super) fn owned_by(id: i32) -> OwnedLoot { looted::table.filter(looted::owner_id.eq(id)) } - pub(crate) fn owns(player: i32, item: i32) -> Exists> { - exists(Loot::owned_by(player).find(item)) + fn exists(id: i32) -> Exists> { + exists(looted::table.find(id)) } - pub(crate) fn exists(id: i32) -> Exists> { - exists(looted::table.find(id)) + pub(super) fn set_owner(&self, owner: i32, conn: &DbConnection) -> QueryResult<()> { + diesel::update(looted::table.find(self.id)) + .set(looted::dsl::owner_id.eq(owner)) + .execute(conn)?; + Ok(()) + } + + pub(super) fn into_item(self) -> Item { + Item { + id: self.id, + name: self.name, + base_price: self.base_price, + } + } + + pub(super) fn find(id: i32) -> Find { + looted::table.find(id) + } +} + +/// Manager for a player's loot +pub struct LootManager<'q>(pub &'q DbConnection, pub i32); + +impl<'q> LootManager<'q> { + /// All items from this player chest + pub fn all(&self) -> QueryResult> { + Ok(Item::owned_by(self.1).load(self.0)?) + } + + /// Finds an item by id + pub fn find(&self, loot_id: i32) -> QueryResult { + Ok(Loot::find(loot_id).first(self.0).and_then(|loot: Loot| { + if loot.owner != self.1 { + Err(diesel::result::Error::NotFound) + } else { + Ok(Item { + id: loot.id, + name: loot.name, + base_price: loot.base_price, + }) + } + })?) + } + + /// The last item added to the chest + pub fn last(&self) -> QueryResult { + Ok(Item::owned_by(self.1) + .order(looted::dsl::id.desc()) + .first(self.0)?) + } + + /// Adds a copy of the given item inside player chest + pub fn add_from(self, item: &Item) -> QueryResult { + let new_item = NewLoot { + name: &item.name, + base_price: item.base_price, + owner_id: self.1, + }; + diesel::insert_into(looted::table) + .values(&new_item) + .execute(self.0)?; + self.last() + } + + pub fn remove(self, item_id: i32) -> QueryResult { + let deleted = self.find(item_id)?; + diesel::delete(looted::table.find(deleted.id)).execute(self.0)?; + Ok(deleted) } } @@ -60,28 +143,8 @@ impl Loot { /// to the id of buying player otherwise. #[derive(Insertable)] #[table_name = "looted"] -pub(crate) struct NewLoot<'a> { +struct NewLoot<'a> { name: &'a str, base_price: i32, owner_id: i32, } - -impl<'a> NewLoot<'a> { - /// A new loot going to the group (loot procedure) - pub(crate) fn to_group(desc: &'a Item) -> Self { - Self { - name: &desc.name, - base_price: desc.base_price, - owner_id: 0, - } - } - - /// A new loot going to a specific player (buy procedure) - pub(crate) fn to_player(player: i32, desc: &'a Item) -> Self { - Self { - name: &desc.name, - base_price: desc.base_price, - owner_id: player, - } - } -} diff --git a/lootalot_db/src/models/mod.rs b/lootalot_db/src/models/mod.rs index 33147a4..b69d581 100644 --- a/lootalot_db/src/models/mod.rs +++ b/lootalot_db/src/models/mod.rs @@ -1,8 +1,7 @@ -pub(super) mod claim; -pub(super) mod item; -pub(super) mod player; +pub mod claim; +pub mod item; +pub mod player; pub use claim::Claim; -pub use item::{Item}; -pub(crate) use item::Loot; +pub use item::Item; pub use player::{Player, Wealth}; diff --git a/lootalot_db/src/models/player.rs b/lootalot_db/src/models/player.rs index a6ec804..38f06ad 100644 --- a/lootalot_db/src/models/player.rs +++ b/lootalot_db/src/models/player.rs @@ -1,7 +1,9 @@ use crate::schema::players; +use crate::{DbConnection, QueryResult}; +use diesel::prelude::*; /// Representation of a player in database -#[derive(Debug, Queryable, Serialize)] +#[derive(Identifiable, Queryable, Serialize, Deserialize, Debug)] pub struct Player { /// DB Identitier pub id: i32, @@ -20,7 +22,52 @@ pub struct Player { pub pp: i32, } -/// Unpack a floating value in gold pieces to integer +pub struct Players<'q>(pub &'q DbConnection); + +impl<'q> Players<'q> { + pub fn all(&self) -> QueryResult> { + players::table.load(self.0) + } + + pub fn find(&self, id: i32) -> QueryResult { + players::table.find(id).first(self.0) + } + + pub fn add(&self, name: &str, wealth: f64) -> QueryResult { + diesel::insert_into(players::table) + .values(&NewPlayer::create(name, wealth)) + .execute(self.0)?; + players::table.order(players::dsl::id.desc()).first(self.0) + } +} + +pub struct AsPlayer<'q>(pub &'q DbConnection, pub i32); + +impl<'q> AsPlayer<'q> { + pub fn update_wealth(&self, value_in_gp: f64) -> QueryResult { + use crate::schema::players::dsl::*; + let current_wealth = players + .find(self.1) + .select((cp, sp, gp, pp)) + .first::(self.0)?; + let updated_wealth = Wealth::from_gp(current_wealth.to_gp() + value_in_gp); + diesel::update(players) + .filter(id.eq(self.1)) + .set(&updated_wealth) + .execute(self.0)?; + // Difference in coins that is sent back + Ok(updated_wealth - current_wealth) + } + + pub fn update_debt(&self, value_in_gp: i32) -> QueryResult<()> { + diesel::update(players::table.find(self.1)) + .set(players::dsl::debt.eq(players::dsl::debt + value_in_gp)) + .execute(self.0)?; + Ok(()) + } +} + +/// Unpack a floating value of gold pieces to integer /// values of copper, silver, gold and platinum pieces /// /// # Note @@ -28,7 +75,7 @@ pub struct Player { /// The conversion is slightly different than standard rules : /// ``` 1pp = 100gp = 1000sp = 10000 cp ``` /// -fn unpack_gold_value(gold: f32) -> (i32, i32, i32, i32) { +fn unpack_gold_value(gold: f64) -> (i32, i32, i32, i32) { let rest = (gold.fract() * 100.0).round() as i32; let gold = gold.trunc() as i32; let pp = gold / 100; @@ -42,7 +89,7 @@ fn unpack_gold_value(gold: f32) -> (i32, i32, i32, i32) { /// /// Values are held as individual pieces counts. /// Allows conversion from and to a floating amount of gold pieces. -#[derive(Queryable, AsChangeset, Debug)] +#[derive(Queryable, AsChangeset, Serialize, Deserialize, Debug)] #[table_name = "players"] pub struct Wealth { pub cp: i32, @@ -60,7 +107,7 @@ impl Wealth { /// let wealth = Wealth::from_gp(403.21); /// assert_eq!(wealth.as_tuple(), (1, 2, 3, 4)); /// ``` - pub fn from_gp(gp: f32) -> Self { + pub fn from_gp(gp: f64) -> Self { let (cp, sp, gp, pp) = unpack_gold_value(gp); Self { cp, sp, gp, pp } } @@ -69,13 +116,13 @@ impl Wealth { /// # Examples /// ``` /// # use lootalot_db::models::Wealth; - /// let wealth = Wealth{ pp: 4, gp: 3, sp: 2, cp: 1}; - /// assert_eq!(wealth.to_gp(), 403.21); + /// let wealth = Wealth{ pp: 4, gp: 5, sp: 8, cp: 4}; + /// assert_eq!(wealth.to_gp(), 405.84); /// ``` - pub fn to_gp(&self) -> f32 { + pub fn to_gp(&self) -> f64 { let i = self.pp * 100 + self.gp; - let f = (self.sp * 10 + self.cp) as f32 / 100.0; - i as f32 + f + let f = (self.sp * 10 + self.cp) as f64 / 100.0; + i as f64 + f } /// Pack the counts inside a tuple, from lower to higher coin value. pub fn as_tuple(&self) -> (i32, i32, i32, i32) { @@ -83,6 +130,37 @@ impl Wealth { } } +use std::ops::Sub; + +impl Sub for Wealth { + type Output = Self; + /// What needs to be added to 'other' so that + /// the result equals 'self' + fn sub(self, other: Self) -> Self { + Wealth { + cp: self.cp - other.cp, + sp: self.sp - other.sp, + gp: self.gp - other.gp, + pp: self.pp - other.pp, + } + } +} + +use std::ops::Add; + +impl Add for Wealth { + type Output = Self; + + fn add(self, other: Self) -> Self { + Wealth { + cp: self.cp + other.cp, + sp: self.sp + other.sp, + gp: self.gp + other.gp, + pp: self.pp + other.pp + } + } +} + /// Representation of a new player record #[derive(Insertable)] #[table_name = "players"] @@ -95,7 +173,7 @@ pub(crate) struct NewPlayer<'a> { } impl<'a> NewPlayer<'a> { - pub(crate) fn create(name: &'a str, wealth_in_gp: f32) -> Self { + pub(crate) fn create(name: &'a str, wealth_in_gp: f64) -> Self { let (cp, sp, gp, pp) = Wealth::from_gp(wealth_in_gp).as_tuple(); Self { name, @@ -114,12 +192,16 @@ mod tests { fn test_unpack_gold_values() { use super::unpack_gold_value; let test_values = [ + (0.01, (1, 0, 0, 0)), + (0.1, (0, 1, 0, 0)), (1.0, (0, 0, 1, 0)), (1.23, (3, 2, 1, 0)), (1.03, (3, 0, 1, 0)), (100.23, (3, 2, 0, 1)), (-100.23, (-3, -2, -0, -1)), (10189.23, (3, 2, 89, 101)), + (141805.9, (0, 9, 5, 1418)), + (123141805.9, (0, 9, 5, 1231418)), (-8090.20, (0, -2, -90, -80)), ]; diff --git a/lootalot_db/src/schema.rs b/lootalot_db/src/schema.rs index 5e7b23e..3989f64 100644 --- a/lootalot_db/src/schema.rs +++ b/lootalot_db/src/schema.rs @@ -40,9 +40,4 @@ joinable!(claims -> looted (loot_id)); joinable!(claims -> players (player_id)); joinable!(looted -> players (owner_id)); -allow_tables_to_appear_in_same_query!( - claims, - items, - looted, - players, -); +allow_tables_to_appear_in_same_query!(claims, items, looted, players,); diff --git a/lootalot_front/src/App.vue b/lootalot_front/src/App.vue index 06aa34e..35dfeb6 100644 --- a/lootalot_front/src/App.vue +++ b/lootalot_front/src/App.vue @@ -1,11 +1,11 @@ - {{ p.name }} @@ -57,18 +57,22 @@
- { + this.playerList = players.value; + this.groupLoot = loot.value; + this.itemsInventory = items.value; + }) + .catch(r => alert("Error ! \n" + r)) + .then(() => this.initiated = true); }, methods: { setActivePlayer (idx) { if (idx == 0) this.switchView('group'); - AppStorage.setActivePlayer(idx); + this.player_id = Number(idx) + document.cookie = `player_id=${idx};`; }, switchView (viewId) { if (!['group', 'player', 'adding'].includes(viewId)) { @@ -147,21 +160,19 @@ export default { } this.activeView = viewId; }, - switchPlayerChestVisibility () { - AppStorage.switchPlayerChestVisibility(); - }, addNewLoot () { - Api.newLoot(this.pending_loot) - .then(_ => { + api.fetch("admin/add-loot", "POST", this.pending_loot) + .then(() => { this.pending_loot = [] this.switchView('group'); - }); + }) + .catch(r => alert("Error: " + r)); } }, computed: { showPlayerChest () { return this.activeView == 'player' }, isAdding () { return this.activeView == 'adding' }, - playerIsGroup () { return this.state.player_id == 0 }, + playerIsGroup () { return this.player_id == 0 }, } } diff --git a/lootalot_front/src/AppStorage.js b/lootalot_front/src/AppStorage.js deleted file mode 100644 index 160140c..0000000 --- a/lootalot_front/src/AppStorage.js +++ /dev/null @@ -1,175 +0,0 @@ -import Vue from 'vue' - -const API_BASEURL = "http://localhost:8088/api/" -const API_ENDPOINT = function (tailString) { - return API_BASEURL + tailString; -} - -export const Api = { - __doFetch (endpoint, method, payload) { - return fetch(API_ENDPOINT(endpoint), - { - method, - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload) - }) - .then(r => r.json()) - }, - fetchPlayerList () { - return fetch(API_ENDPOINT("players/all")) - .then(r => r.json()) - }, - fetchInventory () { - return fetch(API_ENDPOINT("items")) - .then(r => r.json()) - }, - fetchClaims () { - return fetch(API_ENDPOINT("claims")) - .then(r => r.json()) - }, - fetchLoot (playerId) { - return fetch(API_ENDPOINT("players/loot/" + playerId)) - .then(r => r.json()) - }, - putClaim (player_id, item_id) { - const payload = { player_id, item_id }; - return this.__doFetch("claims", 'PUT', payload); - }, - unClaim (player_id, item_id) { - const payload = { player_id, item_id }; - return this.__doFetch("claims", 'DELETE', payload); - }, - updateWealth (player_id, value_in_gp) { - const payload = { player_id, value_in_gp: Number(value_in_gp) }; - return this.__doFetch("players/update-wealth", 'PUT', payload); - }, - buyItems (player_id, items) { - const payload = { player_id, items }; - return this.__doFetch("players/buy", 'POST', payload); - }, - sellItems (player_id, items) { - const payload = { player_id, items }; - return this.__doFetch("players/sell", 'POST', payload); - }, - newLoot (items) { - return this.__doFetch("admin/add-loot", 'POST', items); - }, -}; - - -export const AppStorage = { - debug: true, - state: { - player_id: 0, - player_list: {}, - group_loot: [], - player_claims: {}, - inventory: [], - initiated: false, - show_player_chest: false, - }, - // Initiate the state - initStorage (playerId) { - if (this.debug) console.log('Initiates with player : ', playerId) - this.state.player_id = playerId; - // Fetch initial data - return Promise - .all([ - Api.fetchPlayerList(), - Api.fetchClaims(), - Api.fetchInventory(), - Api.fetchLoot(0) - ]) - .then(data => { - const [players, claims, inventory, group_loot] = data; - this.__initPlayerList(players); - this.__initClaimsStore(claims); - Vue.set(this.state, 'group_loot', group_loot); - Vue.set(this.state, 'inventory', inventory); - }) - .then(_ => this.state.initiated = true) - .catch(e => { alert(e); this.state.initiated = false }); - }, - __initClaimsStore(data) { - for (var idx in data) { - var claimDesc = data[idx]; - this.state.player_claims[claimDesc.player_id].push(claimDesc.loot_id); - } - }, - __initPlayerList(data) { - for (var idx in data) { - var playerDesc = data[idx]; - const playerId = Number(playerDesc.id); - if (this.debug) console.log("Creates", playerId, playerDesc.name) - // Initiate data for a single Player. - Vue.set(this.state.player_list, playerId, playerDesc); - Vue.set(this.state.player_claims, playerId, []); - } - }, - // User actions - // Sets a new active player by id - setActivePlayer (newPlayerId) { - if (this.debug) console.log('setActivePlayer to ', newPlayerId) - this.state.player_id = Number(newPlayerId) - document.cookie = `player_id=${newPlayerId};`; - }, - // Show/Hide player's chest - switchPlayerChestVisibility () { - if (this.debug) console.log('switchPlayerChestVisibility', !this.state.show_player_chest) - this.state.show_player_chest = !this.state.show_player_chest - }, - updatePlayerWealth (goldValue) { - return Api.updateWealth(this.state.player_id, goldValue) - .then(diff => this.__updatePlayerWealth(diff)); - }, - // TODO: Weird private name denotes a conflict - __updatePlayerWealth (diff) { - if (this.debug) console.log('updatePlayerWealth', diff) - this.state.player_list[this.state.player_id].cp += diff[0]; - this.state.player_list[this.state.player_id].sp += diff[1]; - this.state.player_list[this.state.player_id].gp += diff[2]; - this.state.player_list[this.state.player_id].pp += diff[3]; - }, - // Put a claim on an item from group chest. - putRequest (itemId) { - const playerId = this.state.player_id - return Api.putClaim(playerId, itemId) - .then(done => { - // Update cliend-side state - this.state.player_claims[playerId].push(itemId); - }); - }, - buyItems (items) { - return Api.buyItems(this.state.player_id, items) - .then(([items, diff]) => { - this.__updatePlayerWealth(diff) - // Add items to the player loot - // TODO: needs refactoring because player mutation happens in - // 2 different places - return items; - }); - }, - sellItems (items) { - return Api.sellItems(this.state.player_id, items) - .then(diff => this.__updatePlayerWealth(diff)) - }, - // Withdraws a claim. - cancelRequest(itemId) { - const playerId = this.state.player_id - return Api.unClaim(playerId, itemId) - .then(_ => { - var idx = this.state.player_claims[playerId].indexOf(itemId); - if (idx > -1) { - this.state.player_claims[playerId].splice(idx, 1); - } else { - if (this.debug) console.log("cancel a non-existent request") - } - }); - }, - addNewLoot (items) { - return Api.newLoot(items); - }, -} - diff --git a/lootalot_front/src/components/Chest.vue b/lootalot_front/src/components/Chest.vue index c8b646e..e0b97f1 100644 --- a/lootalot_front/src/components/Chest.vue +++ b/lootalot_front/src/components/Chest.vue @@ -47,6 +47,8 @@ ...

+ + + + diff --git a/lootalot_front/src/components/PlayerView.js b/lootalot_front/src/components/PlayerView.js index cd76e0f..2c32ee3 100644 --- a/lootalot_front/src/components/PlayerView.js +++ b/lootalot_front/src/components/PlayerView.js @@ -1,70 +1,94 @@ -import { Api, AppStorage } from '../AppStorage' +import { api } from '../lootalot.js' export default { props: ["id"], data () { return { + player: { + name: "Loading", + id: 0, + cp: '-', sp: '-', gp: '-', pp: '-', + debt: 0, + }, notifications: [], loot: [], + claims: {}, }}, + created () { + api.fetch("claims", "GET", null) + .then(r => { + for (var idx in r.value) { + var claim = r.value[idx]; + if (!(claim.player_id in this.claims)) { + this.$set(this.claims, claim.player_id, []); + } + this.claims[claim.player_id].push(claim.loot_id); + } + }); + }, methods: { - updateWealth (value) { - AppStorage.updatePlayerWealth(value) - .then(_ => {if (AppStorage.debug) this.notifications.push("Wealth updated")}) - .catch(e => {if (AppStorage.debug) console.error("wealthUpdate Error", e)}) + parseUpdate (update) { + if (update.Wealth) { + var w = update.Wealth; + this.player.cp += w.cp; + this.player.sp += w.sp; + this.player.gp += w.gp; + this.player.pp += w.pp; + } + else if (update.ItemAdded) { + var i = update.ItemAdded; + this.loot.push(i); + } + else if (update.ItemRemoved) { + var i = update.ItemRemoved; + this.loot.splice(this.loot.indexOf(i), 1); + } + else if (update.ClaimAdded) { + var c = update.ClaimAdded; + this.claims[c.player_id].push(c.loot_id); + } + else if (update.ClaimRemoved) { + var c = update.ClaimRemoved; + this.claims[c.player_id].splice( + this.claims[c.player_id].indexOf(c.loot_id), + 1 + ); + } }, - putClaim (itemId) { - AppStorage.putRequest(itemId) - .then(_ => { if (AppStorage.debug) this.notifications.push("Claim put")}) - }, - withdrawClaim (itemId) { - AppStorage.cancelRequest(itemId) - .then(_ => { if (AppStorage.debug) this.notifications.push("Claim withdrawn")}) - - }, - buyItems(items) { - AppStorage.buyItems(items) - .then((items) => { - this.notifications.push(`Bought ${items.length} items`) - this.loot = this.loot.concat(items); + call (resource, method, payload) { + return api.fetch(`players/${this.id}/${resource}`, method, payload) + .then(response => { + if (response.notification) { + this.notifications.push(response.notification) + } + if (response.errors) { + this.notifications.push(response.errors) + } + if (response.updates) { + for (var idx in response.updates) { + this.parseUpdate(response.updates[idx]); + } + } + return response.value; }) }, - sellItems (items) { - AppStorage.sellItems(items) - .then(_ => { - this.notifications.push(`Sold ${items.length} items`) - for (var idx in items) { - var to_remove = items[idx][0]; - this.loot = this.loot.filter((item) => item.id != to_remove); - } - }) - }, - parseLoot (items) { - this.loot = []; - items.map(item => { - this.loot.push(item); - }); - } + updateWealth (value) { this.call("wealth", "PUT", Number(value)) }, + putClaim (itemId) { this.call("claims", "PUT", itemId) }, + withdrawClaim (itemId) { this.call("claims", "DELETE", itemId) }, + buyItems(items) { this.call("loot", "PUT", items) }, + sellItems (items) { this.call("loot", "DELETE", items) }, }, watch: { id: { immediate: true, handler: function(newId) { - Api.fetchLoot(newId).then(this.parseLoot); - } - }, - }, - computed: { - player () { - if (!AppStorage.state.initiated) { - return { name: "Loading", - id: 0, - cp: '-', sp: '-', gp: '-', pp: '-', - debt: 0 }; - } else { - return AppStorage.state.player_list[this.id]; + this.call("", "GET", null) + .then(p => this.player = p) + this.call("loot", "GET", null) + .then(l => this.loot = l) } }, }, + computed: {}, render () { return this.$scopedSlots.default({ player: this.player, @@ -76,7 +100,8 @@ export default { withdrawClaim: this.withdrawClaim, buyItems: this.buyItems, sellItems: this.sellItems, - } + }, + claims: this.claims, }) } } diff --git a/lootalot_front/src/components/Request.vue b/lootalot_front/src/components/Request.vue index f0ab45e..0238e96 100644 --- a/lootalot_front/src/components/Request.vue +++ b/lootalot_front/src/components/Request.vue @@ -14,7 +14,7 @@ -