//! Loot-a-lot Database module //! //! # Description //! 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; use diesel::prelude::*; use diesel::query_dsl::RunQueryDsl; use diesel::r2d2::{self, ConnectionManager}; pub mod models; //mod updates; mod schema; /// 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> { models::item::Inventory(self.0).all() } /// 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> { models::item::LootManager(self.conn, self.id).all() } /// 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(|| { // Find item in inventory let item = models::item::Inventory(self.conn).find(*item_id)?; let new_item = models::item::LootManager(self.conn, self.id) .add_from(&item)?; let sell_price = match price_mod { Some(modifier) => item.base_price as f32 * modifier, None => item.base_price as f32 }; models::player::AsPlayer(self.conn, self.id) .update_wealth(-sell_price) .map(|diff| (new_item, diff.as_tuple())) }) { 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(|| { let deleted = models::item::LootManager(self.conn, self.id).remove(*loot_id)?; let mut sell_value = deleted.base_price as f32 / 2.0; if let Some(modifier) = price_mod { sell_value *= modifier; } models::player::AsPlayer(self.conn, self.id).update_wealth(sell_value) }); if let Ok(diff) = res { all_results.push(diff.as_tuple()) } 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)> { models::player::AsPlayer(self.conn, self.id) .update_wealth(value_in_gp) .map(|w| w.as_tuple()) } /// Put a claim on a specific item pub fn claim(self, item: i32) -> ActionResult<()> { models::claim::Claims(self.conn) .add(self.id, item) .map(|claim| { dbg!("created"); dbg!(claim); }) } /// Withdraw claim pub fn unclaim(self, item: i32) -> ActionResult<()> { models::claim::Claims(self.conn) .remove(self.id, item) .map(|c| { dbg!("deleted"); dbg!(c); }) } } /// 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 pub fn add_loot(self, items: Vec) -> ActionResult<()> { for item_desc in items.iter() { models::item::LootManager(self.0, 0).add_from(item_desc)?; } 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<()> { let data = models::claim::Claims(self.0).grouped_by_loot()?; 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(|| { loot.set_owner(claim.player_id) .execute(self.0)?; models::player::AsPlayer(self.0, player_id) .update_debt(loot.sell_value())?; models::claim::Claims(self.0).remove(player_id, claim.loot_id)?; Ok(()) })?; }, _ => (), } } Ok(()) } } /// Sets up a connection pool and returns it. /// Uses the DATABASE_URL environment variable (must be set) pub fn create_pool() -> Pool { let connspec = std::env::var("DATABASE_URL").expect("DATABASE_URL"); dbg!(&connspec); let manager = ConnectionManager::::new(connspec); r2d2::Pool::builder() .build(manager) .expect("Failed to create pool.") } #[cfg(test)] mod tests { use super::*; type TestConnection = DbConnection; /// Return a connection to a fresh database (stored in memory) fn test_connection() -> TestConnection { let test_conn = DbConnection::establish(":memory:").unwrap(); diesel_migrations::run_pending_migrations(&test_conn).unwrap(); test_conn } /// When migrations are run, a special player with id 0 and name "Groupe" /// must be created. #[test] fn global_group_is_autocreated() { let conn = test_connection(); let players = DbApi::with_conn(&conn).fetch_players().unwrap(); assert_eq!(players.len(), 1); let group = players.get(0).unwrap(); assert_eq!(group.id, 0); assert_eq!(group.name, "Groupe".to_string()); } /// When a player updates wealth, a difference is returned by API. /// Added to the previous amount of coins, it should equal the updated weath. #[test] fn as_player_updates_wealth() { let conn = test_connection(); DbApi::with_conn(&conn) .as_admin() .add_player("PlayerName", 403.21) .unwrap(); let diff = DbApi::with_conn(&conn) .as_player(1) .update_wealth(-401.21) .ok(); // Check the returned diff assert_eq!(diff, Some((-1, -2, -1, -4))); let diff = diff.unwrap(); let players = DbApi::with_conn(&conn).fetch_players().unwrap(); let player = players.get(1).unwrap(); // Check that we can add old value to return diff to get resulting value assert_eq!( (player.cp, player.sp, player.gp, player.pp), (1 + diff.0, 2 + diff.1, 3 + diff.2, 4 + diff.3) ); } #[test] fn as_admin_add_player() { let conn = test_connection(); let result = DbApi::with_conn(&conn) .as_admin() .add_player("PlayerName", 403.21); assert_eq!(result.is_ok(), true); let players = DbApi::with_conn(&conn).fetch_players().unwrap(); assert_eq!(players.len(), 2); let new_player = players.get(1).unwrap(); assert_eq!(new_player.name, "PlayerName"); assert_eq!( (new_player.cp, new_player.sp, new_player.gp, new_player.pp), (1, 2, 3, 4) ); } #[test] fn as_admin_resolve_claims() { let conn = test_connection(); let claims = DbApi::with_conn(&conn).fetch_claims().unwrap(); 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); // 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(); // 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(); let result = DbApi::with_conn(&conn).as_admin().resolve_claims(); assert_eq!(result.is_ok(), true); // 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); let player = players.get(i as usize).unwrap(); assert_eq!(player.debt, 20); } } #[test] fn as_player_claim_item() { let conn = test_connection(); DbApi::with_conn(&conn) .as_admin() .add_player("Player", 0.0) .unwrap(); DbApi::with_conn(&conn) .as_admin() .add_loot(vec![("Épée", 25)]) .unwrap(); // Claim an existing item let result = DbApi::with_conn(&conn).as_player(1).claim(1); assert_eq!(result.is_ok(), true); let claims = DbApi::with_conn(&conn).fetch_claims().unwrap(); assert_eq!(claims.len(), 1); let claim = claims.get(0).unwrap(); assert_eq!(claim.player_id, 1); assert_eq!(claim.loot_id, 1); // Claim an inexistant item let result = DbApi::with_conn(&conn).as_player(1).claim(2); assert_eq!(result.is_ok(), false); } #[test] fn as_player_unclaim_item() { let conn = test_connection(); DbApi::with_conn(&conn) .as_admin() .add_player("Player", 0.0) .unwrap(); DbApi::with_conn(&conn) .as_admin() .add_loot(vec![("Épée", 25)]) .unwrap(); // Claim an existing item let result = DbApi::with_conn(&conn).as_player(1).claim(1); assert_eq!(result.is_ok(), true); // Claiming twice is an error let result = DbApi::with_conn(&conn).as_player(1).claim(1); assert_eq!(result.is_ok(), false); // Unclaiming and item let result = DbApi::with_conn(&conn).as_player(1).unclaim(1); assert_eq!(result.is_ok(), true); // Check that not claimed items will not be unclaimed... let result = DbApi::with_conn(&conn).as_player(1).unclaim(1); assert_eq!(result.is_ok(), false); let claims = DbApi::with_conn(&conn).fetch_claims().unwrap(); assert_eq!(claims.len(), 0); } /// All-in-one checks one a simple buy/sell procedure /// /// Checks that player's chest and wealth are updated. /// Checks that items are sold at half their value. /// Checks that a player cannot sell item he does not own. #[test] fn as_player_simple_buy_sell() { let conn = test_connection(); // Adds a sword into inventory { use schema::items::dsl::*; diesel::insert_into(items) .values((name.eq("Sword"), base_price.eq(800))) .execute(&conn) .expect("Could not set up items table"); } DbApi::with_conn(&conn) .as_admin() .add_player("Player", 1000.0) .unwrap(); // Buy an item 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); let loot = chest.get(0).unwrap(); assert_eq!(loot.name, "Sword"); assert_eq!(loot.base_price, 800); let players = DbApi::with_conn(&conn).fetch_players().unwrap(); 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)]); assert_eq!(result.is_ok(), false); // Sell back 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); let players = DbApi::with_conn(&conn).fetch_players().unwrap(); let player = players.get(1).unwrap(); assert_eq!(player.pp, 6); } #[test] fn as_admin_add_loot() { let conn = test_connection(); assert_eq!( 0, DbApi::with_conn(&conn).as_player(0).loot().unwrap().len() ); let loot_to_add = vec![("Cape d'invisibilité", 8000), ("Arc long", 25)]; let result = DbApi::with_conn(&conn) .as_admin() .add_loot(loot_to_add.clone()); assert_eq!(result.is_ok(), true); let looted = DbApi::with_conn(&conn).as_player(0).loot().unwrap(); assert_eq!(looted.len(), 2); // NB: Not a problem now, but this adds constraints of items being // created in the same order. for (added, to_add) in looted.into_iter().zip(loot_to_add) { assert_eq!(added.name, to_add.0); assert_eq!(added.base_price, to_add.1); } } }