408 lines
14 KiB
Rust
408 lines
14 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 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<ConnectionManager<DbConnection>>;
|
|
/// The result of a query on DB
|
|
pub type QueryResult<T> = Result<T, diesel::result::Error>;
|
|
|
|
/// 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.")
|
|
}
|
|
|
|
|
|
/// Every possible update which can happen during a query
|
|
#[derive(Serialize, Debug)]
|
|
pub enum Update {
|
|
Wealth(Wealth),
|
|
ItemAdded(Item),
|
|
ItemRemoved(Item),
|
|
ClaimAdded(Claim),
|
|
ClaimRemoved(Claim),
|
|
}
|
|
|
|
/// Every value which can be queried
|
|
#[derive(Debug)]
|
|
pub enum Value {
|
|
Player(Player),
|
|
Item(Item),
|
|
Claim(Claim),
|
|
ItemList(Vec<Item>),
|
|
ClaimList(Vec<Claim>),
|
|
PlayerList(Vec<Player>),
|
|
Notifications(Vec<String>),
|
|
}
|
|
|
|
impl serde::Serialize for Value {
|
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
match self {
|
|
Value::Player(v) => v.serialize(serializer),
|
|
Value::Item(v) => v.serialize(serializer),
|
|
Value::Claim(v) => v.serialize(serializer),
|
|
Value::ItemList(v) => v.serialize(serializer),
|
|
Value::ClaimList(v) => v.serialize(serializer),
|
|
Value::PlayerList(v) => v.serialize(serializer),
|
|
Value::Notifications(v) => v.serialize(serializer),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<f64>,
|
|
) -> QueryResult<(Item, Wealth)> {
|
|
conn.transaction(|| {
|
|
let deleted = LootManager(conn, id)
|
|
.remove(loot_id)?;
|
|
let mut sell_value = deleted.sell_value() as f64;
|
|
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<f64>,
|
|
) -> 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.value() as f64 * modifier,
|
|
None => item.value() 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<Vec<models::Claim>> {
|
|
schema::claims::table.load::<models::Claim>(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);
|
|
conn.transaction(move || {
|
|
for (item, mut claims) in data {
|
|
if claims.len() > 1 {
|
|
// TODO: better sorting mechanism :)
|
|
claims.sort_by(|a,b| a.resolve.cmp(&b.resolve));
|
|
}
|
|
let winner = claims.get(0).expect("Claims should not be empty !");
|
|
let player_id = winner.player_id;
|
|
winner.resolve_claim(conn)?;
|
|
models::player::AsPlayer(conn, player_id)
|
|
.update_debt(item.sell_value())?;
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// Split up and share group money among selected players
|
|
pub fn split_and_share(conn: &DbConnection, amount: i32, players: Vec<i32>) -> QueryResult<Wealth> {
|
|
let share = (
|
|
amount / (players.len() + 1) as i32 // +1 share for the group
|
|
) as f64;
|
|
conn.transaction(|| {
|
|
for p in players.into_iter() {
|
|
let player = Players(conn).find(p)?;
|
|
// Take debt into account
|
|
match share - player.debt as f64 {
|
|
rest if rest > 0.0 => {
|
|
AsPlayer(conn, p).update_debt(-player.debt)?;
|
|
AsPlayer(conn, p).update_wealth(rest)?;
|
|
AsPlayer(conn, 0).update_wealth(-rest)?;
|
|
},
|
|
_ => {
|
|
AsPlayer(conn, p).update_debt(-share as i32)?;
|
|
}
|
|
}
|
|
}
|
|
Ok(Wealth::from_gp(share))
|
|
})
|
|
}
|
|
|
|
|
|
|
|
#[cfg(none)]
|
|
mod tests_old {
|
|
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);
|
|
}
|
|
}
|
|
}
|