global_exchange/exchange.lua

1081 lines
22 KiB
Lua

local sql = require("lsqlite3")
local exports = {}
local init_query = [=[
BEGIN TRANSACTION;
CREATE TABLE if not exists Credit
(
Owner TEXT PRIMARY KEY NOT NULL,
Balance INTEGER NOT NULL
);
CREATE TABLE if not exists Log
(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Recipient TEXT NOT NULL,
Time INTEGER NOT NULL,
Message TEXT NOT NULL
);
CREATE TABLE if not exists Orders
(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Poster TEXT NOT NULL,
Exchange TEXT NOT NULL,
v Type TEXT NOT NULL CHECK(Type IN ("buy", "sell")),
Time INTEGER NOT NULL,
Item TEXT NOT NULL,
Amount INTEGER NOT NULL CHECK(Amount > 0),
Rate INTEGER NOT NULL CHECK(Rate > 0)
);
CREATE TABLE if not exists Inbox
(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Recipient TEXT NOT NULL,
Item TEXT NOT NULL,
Amount INTEGER NOT NULL CHECK(Amount > 0)
);
CREATE INDEX if not exists credit_owner
ON Credit (Owner);
CREATE INDEX if not exists index_log
ON Log (Recipient, Time);
CREATE INDEX if not exists index_orders
ON Orders (Poster, Type, Time, Item, Rate);
CREATE INDEX if not exists index_inbox
ON Inbox (Recipient);
END TRANSACTION;
]=]
local new_act_query = [=[
INSERT INTO Credit (Owner, Balance)
VALUES (:owner,:start_balance);
]=]
local get_balance_query = [[
SELECT Balance FROM Credit
WHERE Owner = ?;
]]
local set_balance_query = [[
UPDATE Credit
SET Balance = ?
WHERE Owner = ?;
]]
local log_query = [[
INSERT INTO Log (Recipient, Time, Message)
VALUES(?, ?, ?);
]]
local search_desc_query = [=[
SELECT * FROM Orders
WHERE Exchange = :ex_name
AND Type = :order_type
AND Item = :item_name
ORDER BY Rate DESC;
]=]
local add_order_query = [=[
INSERT INTO Orders (Poster, Exchange, Type, Time, Item, Amount, Rate)
VALUES (:p_name, :ex_name, :order_type, :time, :item_name, :amount, :rate);
]=]
local del_order_query = [=[
DELETE FROM Orders
WHERE Id = ?;
]=]
local reduce_order_query = [=[
UPDATE Orders
SET Amount = Amount - ?
WHERE Id = ?;
]=]
-- Delete an order while also checking the player.
local cancel_order_query = [=[
DELETE FROM Orders
WHERE Id = :id
AND Poster = :p_name
]=]
local search_asc_query = [=[
SELECT * FROM Orders
WHERE Exchange = :ex_name
AND Type = :order_type
AND Item = :item_name
ORDER BY Rate ASC;
]=]
local search_min_query = [=[
SELECT * FROM Orders
WHERE Exchange = :ex_name
AND Type = :order_type
AND Item = :item_name
AND Rate >= :rate_min
ORDER BY Rate DESC;
]=]
local search_max_query = [=[
SELECT * FROM Orders
WHERE Exchange = :ex_name
AND Type = :order_type
AND Item = :item_name
AND Rate <= :rate_max
ORDER BY Rate ASC;
]=]
local search_own_query = [=[
SELECT * FROM Orders
WHERE Poster = :p_name;
]=]
local insert_inbox_query = [=[
INSERT INTO Inbox(Recipient, Item, Amount)
VALUES(?, ?, ?);
]=]
local view_inbox_query = [=[
SELECT * FROM Inbox
WHERE Recipient = ?;
]=]
local get_inbox_query = [=[
SELECT Amount FROM Inbox
WHERE Id = :id;
]=]
local red_inbox_query = [=[
UPDATE Inbox
SET Amount = Amount - :change
WHERE Id = :id;
]=]
local del_inbox_query = [=[
DELETE FROM Inbox
WHERE Id = :id;
]=]
local ex_methods = {}
local ex_meta = { __index = ex_methods }
local function sql_error(err)
error("SQL error: " .. err)
end
local function is_integer(num)
return math.floor(num) == num
end
function exports.open_exchange(path)
local db = assert(sqlite3.open(path))
local res = db:exec(init_query)
if res ~= sqlite3.OK then
sql_error(db:errmsg())
end
local stmts = {
new_act_stmt = assert(db:prepare(new_act_query)),
get_balance_stmt = assert(db:prepare(get_balance_query)),
set_balance_stmt = assert(db:prepare(set_balance_query)),
log_stmt = assert(db:prepare(log_query)),
search_desc_stmt = assert(db:prepare(search_desc_query)),
search_asc_stmt = assert(db:prepare(search_asc_query)),
search_min_stmt = assert(db:prepare(search_min_query)),
search_max_stmt = assert(db:prepare(search_max_query)),
search_own_stmt = assert(db:prepare(search_own_query)),
add_order_stmt = assert(db:prepare(add_order_query)),
del_order_stmt = assert(db:prepare(del_order_query)),
reduce_order_stmt = assert(db:prepare(reduce_order_query)),
cancel_order_stmt = assert(db:prepare(cancel_order_query)),
insert_inbox_stmt = assert(db:prepare(insert_inbox_query)),
view_inbox_stmt = assert(db:prepare(view_inbox_query)),
get_inbox_stmt = assert(db:prepare(get_inbox_query)),
red_inbox_stmt = assert(db:prepare(red_inbox_query)),
del_inbox_stmt = assert(db:prepare(del_inbox_query)),
}
local ret = { db = db,
stmts = stmts,
}
setmetatable(ret, ex_meta)
return ret
end
function ex_methods.close(self)
for k, v in pairs(self.stmts) do
v:finalize()
end
self.db:close()
end
-- Returns success boolean
function ex_methods.log(self, message, recipient)
recipient = recipient or ""
local db = self.db
local stmt = self.stmts.log_stmt
stmt:bind_values(recipient, os.time(), message)
local res = stmt:step()
stmt:reset()
if res == sqlite3.ERROR then
sql_error(db:errmsg())
elseif res == sqlite3.MISUSE then
error("Programmer error.")
elseif res == sqlite3.BUSY then
return false, "Failed to log message."
else
return true
end
end
-- Returns success boolean and error.
function ex_methods.new_account(self, p_name, amt)
local db = self.db
amt = amt or 0
local exists = self:get_balance(p_name)
if exists then
return false, "Account already exists."
end
db:exec("BEGIN TRANSACTION;")
local stmt = self.stmts.new_act_stmt
stmt:bind_names({
owner = p_name,
start_balance = amt,
time = os.time(),
})
local res = stmt:step()
if res == sqlite3.MISUSE then
error("Programmer error.")
elseif res == sqlite3.BUSY then
stmt:reset()
db:exec("ROLLBACK;")
return false, "Database Busy."
elseif res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
stmt:reset()
local log_succ1, log_err1 =
self:log("Account opened with balance " .. amt, p_name)
local log_succ2, log_err2 =
self:log(p_name .. " opened an account with balance " .. amt)
if not log_succ1 then
db:exec("ROLLBACK;")
return false, log_err1
end
if not log_succ2 then
db:exec("ROLLBACK;")
return false, log_err2
end
db:exec("COMMIT;")
return true
end
-- Returns nil if no balance.
function ex_methods.get_balance(self, p_name)
local db = self.db
local stmt = self.stmts.get_balance_stmt
stmt:bind_values(p_name)
local res = stmt:step()
if res == sqlite3.ERROR then
sql_error(db:errmsg())
elseif res == sqlite3.MISUSE then
error("Programmer error.")
elseif res == sqlite3.ROW then
local balance = stmt:get_value(0)
stmt:reset()
return balance
end
stmt:reset()
end
-- Returns success boolean, and error message if false.
function ex_methods.set_balance(self, p_name, new_bal)
local db = self.db
local set_stmt = self.stmts.set_balance_stmt
local bal = self:get_balance(p_name)
if not bal then
return false, p_name .. " does not have an account."
end
set_stmt:bind_values(new_bal, p_name)
local res = set_stmt:step()
if res == sqlite3.ERROR then
sql_error(db:errmsg())
elseif res == sqlite3.MISUSE then
error("Programmer error.")
elseif res == sqlite3.BUSY then
set_stmt:reset()
return false, "Database busy"
else
set_stmt:reset()
return true
end
end
-- Change balance by the given amount. Returns a success boolean, and error
-- message on fail.
function ex_methods.change_balance(self, p_name, delta)
if not is_integer(delta) then
error("Non-integer credit delta")
end
local bal = self:get_balance(p_name)
if not bal then
return false, p_name .. " does not have an account."
end
if bal + delta < 0 then
return false, p_name .. " does not have enough money."
end
return self:set_balance(p_name, bal + delta)
end
-- Sends credits from one user to another. Returns a success boolean, and error
-- message on fail.
function ex_methods.transfer_credits(self, sender, receiver, amt)
local db = self.db
if not is_integer(amt) then
return false, "Non-integer credit amount"
end
db:exec("BEGIN TRANSACTION;")
local succ_minus, err = self:change_balance(sender, -amt)
if not succ_minus then
db:exec("ROLLBACK")
return false, err
end
local succ_plus, err = self:change_balance(receiver, amt)
if not succ_plus then
db:exec("ROLLBACK")
return false, err
end
local succ_log1 = self:log("Sent " .. amt .. " credits to " .. receiver, sender)
if not succ_log1 then
db:exec("ROLLBACK")
return false, "Failed to log sender message"
end
local succ_log2 = self:log("Received " .. amt .. " credits from " .. sender, receiver)
if not succ_log2 then
db:exec("ROLLBACK")
return false, "Failed to log receiver message"
end
db:exec("COMMIT;")
return true
end
function ex_methods.give_credits(self, p_name, amt, msg)
local db = self.db
db:exec("BEGIN TRANSACTION;")
local succ_change, err = self:change_balance(p_name, amt)
if not succ_change then
db:exec("ROLLBACK;")
return false, err
end
local succ_log, err = self:log(msg, p_name)
if not succ_log then
db:exec("ROLLBACK;")
return false, er
end
db:exec("COMMIT;")
return true
end
-- Returns a list of orders, sorted by price.
function ex_methods.search_orders(self, ex_name, order_type, item_name)
local params = { ex_name = ex_name,
order_type = order_type,
item_name = item_name,
}
local stmt
if order_type == "buy" then
stmt = self.stmts.search_asc_stmt
else
stmt = self.stmts.search_desc_stmt
end
stmt:bind_names(params)
local orders = {}
for tab in stmt:nrows() do
table.insert(orders, tab)
end
stmt:reset()
return orders
end
-- Same as above, except not sorted in any particular order.
function ex_methods.search_player_orders(self, p_name)
local params = { p_name = p_name,
}
local stmt = self.stmts.search_own_stmt
stmt:bind_names(params)
local orders = {}
for tab in stmt:nrows() do
table.insert(orders, tab)
end
stmt:reset()
return orders
end
-- Adds a new order. Returns success, and an error string if failed.
function ex_methods.add_order(self, p_name, ex_name, order_type, item_name, amount, rate)
if math.floor(amount) ~= amount then
return false, "Noninteger quantity"
end
if amount <= 0 then
return false, "Nonpositive quantity"
end
if math.floor(rate) ~= rate then
return false, "Noninteger rate"
end
if rate <= 0 then
return false, "Nonpositive rate"
end
local db = self.db
local stmt = self.stmts.add_order_stmt
stmt:bind_names({
p_name = p_name,
ex_name = ex_name,
order_type = order_type,
time = os.time(),
item_name = item_name,
amount = amount,
rate = rate,
})
local res = stmt:step()
if res == sqlite3.BUSY then
stmt:reset()
return false, "Database Busy"
elseif res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
stmt:reset()
return true
end
-- Returns true, or false and an error message.
function ex_methods.cancel_order(self, p_name, id)
local params = { p_name = p_name,
id = id,
}
local db = self.db
local stmt = self.stmts.cancel_order_stmt
stmt:bind_values(params)
local res = stmt:step()
if res == sqlite3.BUSY then
stmt:reset()
return false, "Database busy."
elseif res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
stmt:reset()
return true
end
-- Puts things in a player's item inbox. Returns success, and also returns an
-- error message if failed.
function ex_methods.put_in_inbox(self, p_name, item_name, amount)
local db = self.db
local stmt = self.stmts.insert_inbox_stmt
stmt:bind_values(p_name, item_name, amount)
local res = stmt:step()
if res == sqlite3.BUSY then
return false, "Database Busy."
elseif res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
stmt:reset()
return true
end
-- Tries to buy from orders at the provided rate, and posts an offer with any
-- remaining desired amount. Returns success. If succeeded, also returns amount
-- bought. If failed, returns an error message
function ex_methods.buy(self, p_name, ex_name, item_name, amount, rate)
if math.floor(amount) ~= amount then
return false, "Noninteger quantity"
elseif amount <= 0 then
return false, "Nonpositive quantity"
elseif math.floor(rate) ~= rate then
return false, "Noninteger rate"
elseif rate <= 0 then
return false, "Nonpositive rate"
end
local db = self.db
local bal = self:get_balance(p_name)
if bal < amount * rate then
return false, "Not enough money."
end
db:exec("BEGIN TRANSACTION");
local remaining = amount
local del_stmt = self.stmts.del_order_stmt
local red_stmt = self.stmts.reduce_order_stmt
local search_stmt = self.stmts.search_max_stmt
search_stmt:bind_names({
ex_name = ex_name,
order_type = "sell",
item_name = item_name,
rate_max = rate,
})
for row in search_stmt:nrows() do
local poster = row.Poster
local row_amount = row.Amount
if row_amount <= remaining then
del_stmt:bind_values(row.Id)
local del_res = del_stmt:step()
if del_res == sqlite3.BUSY then
del_stmt:reset()
search_stmt:reset()
db:exec("ROLLBACK;")
return false, "Database Busy."
elseif del_res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
del_stmt:reset()
local ch_succ, ch_err =
self:change_balance(poster, rate * row_amount)
if not ch_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, ch_err
end
local log_succ, log_err =
self:log(p_name .. " bought " .. row_amount .. " "
.. item_name .. " from you. (+"
.. rate * row_amount .. ")", poster)
if not log_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
end
local log2_succ, log2_err =
self:log("Bought " .. row_amount .. " " .. item_name
.. " from " .. poster
.. "(-" .. rate * row_amount .. ")",p_name)
if not log2_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log2_err
end
remaining = remaining - row_amount
else -- row_amount > remaining
red_stmt:bind_values(remaining, row.Id)
local red_res = red_stmt:step()
if red_res == sqlite3.BUSY then
red_stmt:reset()
search_stmt:reset()
db:exec("ROLLBACK;")
return false, "Database Busy."
elseif red_res ~= sqlite3.DONE then
red_stmt:reset()
search_stmt:reset()
sql_error(db:errmsg())
end
red_stmt:reset()
local ch_succ, ch_err =
self:change_balance(poster, rate * remaining)
if not ch_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, ch_err
end
local log_succ, log_err =
self:log(p_name .. " bought " .. remaining .. " "
.. item_name .. " from you. (+"
.. rate * remaining .. ")", poster)
if not log_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
end
local log2_succ, log2_err =
self:log("Bought " .. remaining .. " " .. item_name
.. " from " .. poster .. " (-"
.. rate * remaining .. ")", p_name)
if not log2_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log2_err
end
remaining = 0
end
if remaining == 0 then break end
end
search_stmt:reset()
local bought = amount - remaining
local cost = amount * rate
local ch_succ, ch_err = self:change_balance(p_name, -cost)
if not ch_succ then
db:exec("ROLLBACK;")
return false, ch_err
end
if remaining > 0 then
local add_succ, add_err =
self:add_order(p_name, ex_name, "buy", item_name, remaining, rate)
if not add_succ then
db:exec("ROLLBACK;")
return false, add_err
end
local log_succ, log_err =
self:log("Posted buy offer for "
.. remaining .. " " .. item_name .. " at "
.. rate .. "/item (-"
.. remaining * rate .. ")", p_name)
if not log_succ then
db:exec("ROLLBACK;")
return false, log_err
end
end
db:exec("COMMIT;")
return true, bought
end
-- Tries to sell to orders at the provided rate, and posts an offer with any
-- remaining desired amount. Returns success. If failed, returns an error message.
function ex_methods.sell(self, p_name, ex_name, item_name, amount, rate)
if math.floor(amount) ~= amount then
return false, "Noninteger quantity"
elseif amount <= 0 then
return false, "Nonpositive quantity"
elseif math.floor(rate) ~= rate then
return false, "Noninteger rate"
elseif rate <= 0 then
return false, "Nonpositive rate"
end
local db = self.db
db:exec("BEGIN TRANSACTION");
local remaining = amount
local del_stmt = self.stmts.del_order_stmt
local red_stmt = self.stmts.reduce_order_stmt
local search_stmt = self.stmts.search_min_stmt
search_stmt:bind_names({
ex_name = ex_name,
order_type = "buy",
item_name = item_name,
rate_min = rate,
})
for row in search_stmt:nrows() do
local poster = row.Poster
local row_amount = row.Amount
if row_amount <= remaining then
del_stmt:bind_values(row.Id)
local del_res = del_stmt:step()
if del_res == sqlite3.BUSY then
del_stmt:reset()
search_stmt:reset()
db:exec("ROLLBACK;")
return false, "Database Busy."
elseif del_res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
del_stmt:reset()
local in_succ, in_err =
self:put_in_inbox(poster, item_name, row_amount)
if not in_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, in_err
end
local log_succ, log_err =
self:log(p_name .. " sold " .. row_amount .. " "
.. item_name .. " to you." , poster)
if not log_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
end
local log2_succ, log2_err =
self:log("Sold " .. row_amount .. " " .. item_name
.. " to " .. poster
.. "(+" .. rate * row_amount .. ")",p_name)
if not log2_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log2_err
end
remaining = remaining - row_amount
else -- row_amount > remaining
red_stmt:bind_values(remaining, row.Id)
local red_res = red_stmt:step()
if red_res == sqlite3.BUSY then
red_stmt:reset()
search_stmt:reset()
db:exec("ROLLBACK;")
return false, "Database Busy."
elseif red_res ~= sqlite3.DONE then
red_stmt:reset()
search_stmt:reset()
sql_error(db:errmsg())
end
red_stmt:reset()
local in_succ, in_err =
self:put_in_inbox(poster, item_name, remaining)
if not in_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, in_err
end
local log_succ, log_err =
self:log(p_name .. " sold " .. remaining .. " "
.. item_name .. " to you.", poster)
if not log_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
end
local log2_succ, log2_err =
self:log("Sold " .. row_amount .. " " .. item_name
.. " to " .. poster .. " (+"
.. rate * remaining .. ")", p_name)
if not log2_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log2_err
end
remaining = 0
end
if remaining == 0 then break end
end
search_stmt:reset()
local sold = amount - remaining
local revenue = sold * rate
local ch_succ, ch_err = self:change_balance(p_name, revenue)
if not ch_succ then
db:exec("ROLLBACK;")
return false, ch_err
end
if remaining > 0 then
local add_succ, add_err =
self:add_order(p_name, ex_name, "sell", item_name, remaining, rate)
if not add_succ then
db:exec("ROLLBACK;")
return false, add_err
end
end
db:exec("COMMIT;")
return true
end
-- On success, returns true and a list of inbox entries.
-- On failure, returns false and an error message.
function ex_methods.view_inbox(self, p_name)
local db = self.db
local stmt = self.stmt.view_inbox_stmt
stmt:bind_values(p_name)
local res = {}
for row in stmt:nrows() do
table.insert(res, row)
end
stmt:reset()
return true, res
end
-- Returns success boolean. On success, also returns the number actually
-- taken. On failure, also returns an error message
function ex_methods.take_inbox(self, id, amount)
local db = self.db
local get_stmt = self.stmts.get_inbox_stmt
local red_stmt = self.stmts.red_inbox_stmt
local del_stmt = self.stmts.del_inbox_stmt
get_stmt:bind_names({
id = id,
change = amount
})
local res = get_stmt:step()
if res == sqlite3.BUSY then
get_stmt:reset()
return false, "Database Busy."
elseif res == sqlite3.DONE then
get_stmt:reset()
return false, "Order does not exist."
elseif res ~= sqlite3.ROW then
sql_error(db:errmsg())
end
local available = get_stmt:get_value(0)
get_stmt:reset()
db:exec("BEGIN TRANSACTION;")
if available > amount then
red_stmt:bind_names({
id = id,
change = amount
})
local red_res = red_stmt:step()
if red_res == sqlite3.BUSY then
red_stmt:reset()
db:exec("ROLLBACK;")
return false, "Database Busy."
elseif red_res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
red_stmt:reset()
else
del_stmt:bind_names({
id = id,
})
local del_res = del_stmt:step()
if del_res == sqlite3.BUSY then
del_stmt:reset()
db:exec("ROLLBACK;")
return false, "Database Busy."
elseif del_res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
del_stmt:reset()
end
db:exec("COMMIT;")
return true, math.min(amount, available)
end
function exports.test()
local ex = exports.open_exchange("test.db")
local alice_bal = ex:get_balance("Alice")
local bob_bal = ex:get_balance("Bob")
local function print_balances()
print("Alice: ", ex:get_balance("Alice"))
print("Bob: ", ex:get_balance("Bob"))
end
-- Initialize balances
if alice_bal then
ex:set_balance("Alice", 420)
else
ex:new_account("Alice", 420)
end
if bob_bal then
ex:set_balance("Bob", 2015)
else
ex:new_account("Bob", 2015)
end
print_balances()
-- Transfer a valid amount
print("Transfering 1000 credits from Bob to Alice")
local succ, err = ex:transfer_credits("Bob", "Alice", 1000)
print("Success: ", succ, " ", err)
print_balances()
-- Transfer an invalid amount
print("Transfering 3000 credits from Alice to Bob")
local succ, err = ex:transfer_credits("Alice", "Bob", 3000)
print("Success: ", succ, " ", err)
print_balances()
-- Simulate a transaction
print("Alice posting an offer to buy 10 cobble at 2 credits each")
local succ, err = ex:buy("Alice", "", "default:cobble", 10, 2)
print("Success: ", succ, " ", err)
print_balances()
print("Bob posting an offer to sell 20 cobble at 1 credits each")
local succ, err = ex:sell("Bob", "", "default:cobble", 20, 1)
print("Success: ", succ, " ", err)
print_balances()
ex:close()
end
return exports