498 lines
18 KiB
Rust
498 lines
18 KiB
Rust
//! 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<ConnectionManager<DbConnection>>;
|
|
/// The result of a query on DB
|
|
pub type QueryResult<T> = Result<T, diesel::result::Error>;
|
|
/// The result of an action provided by DbApi
|
|
pub type ActionResult<R> = Result<R, diesel::result::Error>;
|
|
|
|
|
|
/// 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<Vec<models::Player>> {
|
|
Ok(schema::players::table.load::<models::Player>(self.0)?)
|
|
}
|
|
/// Fetch the inventory of items
|
|
///
|
|
/// TODO: remove limit used for debug
|
|
pub fn fetch_inventory(self) -> QueryResult<Vec<models::Item>> {
|
|
models::item::Inventory(self.0).all()
|
|
}
|
|
/// Fetch all existing claims
|
|
pub fn fetch_claims(self) -> QueryResult<Vec<models::Claim>> {
|
|
Ok(schema::claims::table.load::<models::Claim>(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<Vec<models::Item>> {
|
|
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<Price modifier>)
|
|
///
|
|
/// # Returns
|
|
/// Result containing the difference in coins after operation
|
|
pub fn buy<'a>(self, params: &Vec<(i32, Option<f32>)>) -> ActionResult<(Vec<models::Item>, (i32, i32, i32, i32))> {
|
|
let mut cumulated_diff: Vec<(i32, i32, i32, i32)> = Vec::with_capacity(params.len());
|
|
let mut added_items: Vec<models::Item> = 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<f32>)>,
|
|
) -> 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<models::item::Item>) -> 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::<DbConnection>::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);
|
|
}
|
|
}
|
|
}
|