global_exchange/exchange.lua

1361 lines
29 KiB
Lua
Raw Permalink Normal View History

2016-02-25 23:38:19 +01:00
2016-11-18 05:19:33 +01:00
local insecure_env = ...
local sql = insecure_env.require("lsqlite3")
2016-02-25 23:38:19 +01:00
local exports = {}
2020-09-11 08:21:51 +02:00
local S = minetest.get_translator("global_exchange")
2016-02-25 23:38:19 +01:00
local order_book_cache = (function(cache)
return function(ex_name)
local maybe_cache = cache[ex_name]
if not maybe_cache then
maybe_cache = {}
cache[ex_name] = maybe_cache
end
return maybe_cache
end
end)({})
2016-02-25 23:38:19 +01:00
local init_query = [=[
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS Credit
2016-02-25 23:38:19 +01:00
(
Owner TEXT PRIMARY KEY NOT NULL,
Balance INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Log
2016-02-25 23:38:19 +01:00
(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Recipient TEXT NOT NULL,
Time INTEGER NOT NULL,
Message TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS Orders
2016-02-25 23:38:19 +01:00
(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Poster TEXT NOT NULL,
Exchange TEXT NOT NULL,
2016-02-25 23:46:44 +01:00
Type TEXT NOT NULL CHECK(Type IN ("buy", "sell")),
2016-02-25 23:38:19 +01:00
Time INTEGER NOT NULL,
Item TEXT NOT NULL,
Wear INTEGER NOT NULL CHECK(Wear >= 0 AND Wear <= 65535),
2016-02-25 23:38:19 +01:00
Amount INTEGER NOT NULL CHECK(Amount > 0),
Rate INTEGER NOT NULL CHECK(Rate > 0)
);
CREATE TABLE IF NOT EXISTS Inbox
2016-02-25 23:38:19 +01:00
(
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Recipient TEXT NOT NULL,
Item TEXT NOT NULL,
Wear INTEGER NOT NULL CHECK(Wear >= 0 AND Wear <= 65535),
2016-02-25 23:38:19 +01:00
Amount INTEGER NOT NULL CHECK(Amount > 0)
);
CREATE INDEX IF NOT EXISTS index_log
2016-02-25 23:38:19 +01:00
ON Log (Recipient, Time);
CREATE INDEX IF NOT EXISTS index_orders
ON Orders (Exchange, Type, Item, Rate, Wear, Time);
CREATE INDEX IF NOT EXISTS index_own_orders
ON Orders (Poster, Time);
2016-02-25 23:38:19 +01:00
CREATE INDEX IF NOT EXISTS index_inbox
ON Inbox (Recipient, Item, Wear);
2016-02-25 23:38:19 +01:00
CREATE VIEW IF NOT EXISTS distinct_items AS
SELECT DISTINCT Item, Wear FROM Orders;
2016-02-26 06:26:05 +01:00
CREATE VIEW IF NOT EXISTS market_summary AS
2016-02-26 07:07:57 +01:00
SELECT
distinct_items.Item AS Item,
distinct_items.Wear AS Wear,
2016-02-26 07:07:57 +01:00
(
SELECT SUM(Orders.Amount) FROM Orders
2016-02-26 07:07:57 +01:00
WHERE Orders.Item = distinct_items.Item
AND Orders.Wear >= distinct_items.Wear
2016-02-26 07:07:57 +01:00
AND Orders.Type = "buy"
) AS Buy_Volume,
2016-02-26 07:07:57 +01:00
(
SELECT MAX(Orders.Rate) FROM Orders
2016-02-26 07:07:57 +01:00
WHERE Orders.Item = distinct_items.Item
AND Orders.Wear >= distinct_items.Wear
2016-02-26 07:07:57 +01:00
AND Orders.Type = "buy"
) AS Buy_Max,
2016-02-26 07:07:57 +01:00
(
SELECT SUM(Orders.Amount) FROM Orders
2016-02-26 07:07:57 +01:00
WHERE Orders.Item = distinct_items.Item
AND Orders.Wear <= distinct_items.Wear
2016-02-26 07:07:57 +01:00
AND Orders.Type = "sell"
) AS Sell_Volume,
2016-02-26 07:07:57 +01:00
(
SELECT MIN(Orders.Rate) FROM Orders
2016-02-26 07:07:57 +01:00
WHERE Orders.Item = distinct_items.Item
AND Orders.Wear <= distinct_items.Wear
2016-02-26 07:07:57 +01:00
AND Orders.Type = "sell"
) AS Sell_Min
2016-02-26 07:07:57 +01:00
FROM distinct_items;
2016-02-26 07:07:57 +01:00
2016-02-25 23:38:19 +01:00
END TRANSACTION;
]=]
local new_act_query = [=[
INSERT INTO Credit (Owner, Balance)
VALUES (:owner, :start_balance);
2016-02-25 23:38:19 +01:00
]=]
local get_balance_query = [[
SELECT Balance FROM Credit
WHERE Owner = ?;
]]
local set_balance_query = [[
UPDATE Credit
SET Balance = :new_balance
WHERE Owner = :p_name;
2016-02-25 23:38:19 +01:00
]]
local log_query = [[
INSERT INTO Log (Recipient, Time, Message)
VALUES(:recipient, :time, :message);
2016-02-25 23:38:19 +01:00
]]
local add_order_query = [=[
INSERT INTO Orders (Poster, Exchange, Type, Time, Item, Wear, Amount, Rate)
VALUES (:p_name, :ex_name, :order_type, :time, :item_name, :wear, :amount, :rate);
2016-02-25 23:38:19 +01:00
]=]
local del_order_query = [=[
DELETE FROM Orders
WHERE Id = ?;
]=]
local reduce_order_query = [=[
UPDATE Orders
SET Amount = Amount - :delta
WHERE Id = :id;
2016-02-25 23:38:19 +01:00
]=]
local get_order_query = [=[
SELECT * FROM Orders
WHERE Id = ?
]=]
2016-02-25 23:38:19 +01:00
local cancel_order_query = [=[
DELETE FROM Orders
WHERE Id = :id
AND Poster = :p_name
]=]
local search_bids_query = [=[
SELECT * FROM Orders
WHERE Exchange = :ex_name
AND Type = "buy"
AND Item = :item_name
ORDER BY Rate ASC, Wear DESC;
2016-02-26 04:03:17 +01:00
]=]
local search_asks_query = [=[
2016-02-25 23:38:19 +01:00
SELECT * FROM Orders
WHERE Exchange = :ex_name
AND Type = "sell"
2016-02-25 23:38:19 +01:00
AND Item = :item_name
ORDER BY Rate DESC, Wear ASC;
2016-02-25 23:38:19 +01:00
]=]
local qual_bids_query = [=[
2016-02-25 23:38:19 +01:00
SELECT * FROM Orders
WHERE Exchange = :ex_name
AND Type = "buy"
2016-02-25 23:38:19 +01:00
AND Item = :item_name
AND Rate >= :rate_min
AND Wear >= :wear_min
ORDER BY Rate DESC, Time ASC;
2016-02-25 23:38:19 +01:00
]=]
local qual_asks_query = [=[
2016-02-25 23:38:19 +01:00
SELECT * FROM Orders
WHERE Exchange = :ex_name
AND Type = "sell"
2016-02-25 23:38:19 +01:00
AND Item = :item_name
AND Rate <= :rate_max
AND Wear <= :wear_max
ORDER BY Rate ASC, Time ASC;
2016-02-25 23:38:19 +01:00
]=]
local search_own_query = [=[
SELECT * FROM Orders
WHERE Poster = ?
ORDER BY Time ASC;
]=]
local order_book_asks_query = [=[
SELECT Type, Rate, Wear, SUM(Amount) AS Amount FROM Orders
GROUP BY Rate, Wear
HAVING Exchange = :ex_name
AND Type = "sell"
AND Item = :item_name
ORDER BY Rate ASC, Wear ASC
LIMIT 3;
2016-02-25 23:38:19 +01:00
]=]
local order_book_bids_query = [=[
SELECT Type, Rate, Wear, SUM(Amount) AS Amount FROM Orders
GROUP BY Rate, Wear
HAVING Exchange = :ex_name
AND Type = "buy"
AND Item = :item_name
ORDER BY Rate DESC, Wear DESC
LIMIT 3;
]=]
2016-02-25 23:38:19 +01:00
local insert_inbox_query = [=[
INSERT INTO Inbox(Recipient, Item, Wear, Amount)
VALUES(:p_name, :item_name, :wear, :amount);
2016-02-25 23:38:19 +01:00
]=]
local add_inbox_query = [=[
UPDATE Inbox
SET Amount = Amount + :change
WHERE Id = :id;
]=]
2016-02-25 23:38:19 +01:00
local view_inbox_query = [=[
SELECT * FROM Inbox
WHERE Recipient = ?
ORDER BY Item ASC, Wear ASC;
2016-02-25 23:38:19 +01:00
]=]
local search_inbox_query = [=[
SELECT * FROM Inbox
WHERE Recipient = :p_name
AND Item = :item_name
AND Wear = :wear;
]=]
2016-02-25 23:38:19 +01:00
local get_inbox_query = [=[
SELECT Amount, Wear FROM Inbox
2016-02-25 23:38:19 +01:00
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;
]=]
2016-02-26 07:07:57 +01:00
local summary_query = [=[
SELECT * FROM market_summary;
]=]
2016-02-26 19:36:21 +01:00
local transaction_log_query = [=[
SELECT Time, Message FROM Log
WHERE Recipient = ?
2016-02-26 19:36:21 +01:00
ORDER BY Time DESC;
]=]
2016-02-25 23:38:19 +01:00
local ex_methods = {}
local ex_meta = { __index = ex_methods }
local function sql_error(err)
error("SQL error: " .. err)
end
local function is_integer(x)
local num = tonumber(x)
return num and math.floor(num) == num
2016-02-25 23:38:19 +01:00
end
2016-02-26 04:03:17 +01:00
local function exec_stmt(db, stmt, names)
stmt:bind_names(names)
local res = stmt:step()
stmt:reset()
2016-02-26 04:03:17 +01:00
if res == sqlite3.BUSY then
2020-09-11 08:21:51 +02:00
return false, S("Database Busy.")
2016-02-26 04:03:17 +01:00
elseif res ~= sqlite3.DONE then
sql_error(db:errmsg())
else
return true
end
end
2016-02-25 23:38:19 +01:00
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_asks_stmt = assert(db:prepare(search_asks_query)),
search_bids_stmt = assert(db:prepare(search_bids_query)),
qual_asks_stmt = assert(db:prepare(qual_asks_query)),
qual_bids_stmt = assert(db:prepare(qual_bids_query)),
search_own_stmt = assert(db:prepare(search_own_query)),
add_order_stmt = assert(db:prepare(add_order_query)),
get_order_stmt = assert(db:prepare(get_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)),
add_inbox_stmt = assert(db:prepare(add_inbox_query)),
order_book_bids_stmt = assert(db:prepare(order_book_bids_query)),
order_book_asks_stmt = assert(db:prepare(order_book_asks_query)),
view_inbox_stmt = assert(db:prepare(view_inbox_query)),
search_inbox_stmt = assert(db:prepare(search_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)),
summary_stmt = assert(db:prepare(summary_query)),
2016-02-26 19:49:09 +01:00
transaction_log_stmt = assert(db:prepare(transaction_log_query)),
2016-02-25 23:38:19 +01:00
}
local ret = {
db = db,
stmts = stmts,
2016-02-25 23:38:19 +01:00
}
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_names({
recipient = recipient,
time = os.time(),
message = message,
})
2016-02-25 23:38:19 +01:00
local res = stmt:step()
stmt:reset()
if res == sqlite3.ERROR then
sql_error(db:errmsg())
elseif res == sqlite3.MISUSE then
2020-09-11 08:21:51 +02:00
error(S("Programmer error."))
2016-02-25 23:38:19 +01:00
elseif res == sqlite3.BUSY then
2020-09-11 08:21:51 +02:00
return false, S("Failed to log message.")
2016-02-25 23:38:19 +01:00
else
return true
end
end
2016-02-25 23:38:19 +01:00
-- 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
2020-09-11 08:21:51 +02:00
return false, S("Account already exists.")
2016-02-25 23:38:19 +01:00
end
db:exec("BEGIN TRANSACTION;")
2016-02-25 23:38:19 +01:00
local stmt = self.stmts.new_act_stmt
stmt:bind_names({
owner = p_name,
2016-02-25 23:38:19 +01:00
start_balance = amt,
time = os.time(),
2016-02-25 23:38:19 +01:00
})
local res = stmt:step()
if res == sqlite3.MISUSE then
2020-09-11 08:21:51 +02:00
error(S("Programmer error."))
2016-02-25 23:38:19 +01:00
elseif res == sqlite3.BUSY then
stmt:reset()
db:exec("ROLLBACK;")
2020-09-11 08:21:51 +02:00
return false, S("Database Busy.")
2016-02-25 23:38:19 +01:00
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;")
2016-02-25 23:38:19 +01:00
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
2016-02-25 23:38:19 +01:00
stmt:bind_values(p_name)
local res = stmt:step()
if res == sqlite3.ERROR then
sql_error(db:errmsg())
elseif res == sqlite3.MISUSE then
2020-09-11 08:21:51 +02:00
error(S("Programmer error."))
2016-02-25 23:38:19 +01:00
elseif res == sqlite3.ROW then
local balance = stmt:get_value(0)
stmt:reset()
return balance
end
stmt:reset()
return nil
2016-02-25 23:38:19 +01:00
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
2020-09-11 08:21:51 +02:00
return false, S("@1 does not have an account.", p_name)
2016-02-25 23:38:19 +01:00
end
set_stmt:bind_names({
p_name = p_name,
new_balance = new_bal,
})
2016-02-25 23:38:19 +01:00
local res = set_stmt:step()
if res == sqlite3.ERROR then
sql_error(db:errmsg())
elseif res == sqlite3.MISUSE then
2020-09-11 08:21:51 +02:00
error(S("Programmer error."))
2016-02-25 23:38:19 +01:00
elseif res == sqlite3.BUSY then
set_stmt:reset()
2020-09-11 08:21:51 +02:00
return false, S("Database busy.")
2016-02-25 23:38:19 +01:00
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
2016-02-25 23:38:19 +01:00
error("Non-integer credit delta")
end
local bal = self:get_balance(p_name)
if not bal then
2020-09-11 08:21:51 +02:00
return false, S("@1 does not have an account.", p_name)
2016-02-25 23:38:19 +01:00
end
if bal + delta < 0 then
2020-09-11 08:21:51 +02:00
return false, S("@1 does not have enough money.", p_name)
2016-02-25 23:38:19 +01:00
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
2020-09-11 08:21:51 +02:00
return false, S("Non-integer credit amount")
2016-02-25 23:38:19 +01:00
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")
2020-09-11 08:21:51 +02:00
return false, S("Failed to log sender message")
2016-02-25 23:38:19 +01:00
end
local succ_log2 = self:log("Received " .. amt .. " credits from " .. sender, receiver)
if not succ_log2 then
db:exec("ROLLBACK")
2020-09-11 08:21:51 +02:00
return false, S("Failed to log receiver message")
2016-02-25 23:38:19 +01:00
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
-- The best asks & bids for an item, grouped and sorted by rate and wear.
-- Result fields: Type, Rate, Wear, Amount.
function ex_methods.order_book(self, ex_name, item_name)
if not ex_name or not item_name then return {} end
local ex_cache = order_book_cache(ex_name)
if ex_cache[item_name] then
return ex_cache[item_name]
end
local res = {}
local stmt = self.stmts.order_book_asks_stmt
stmt:bind_names({
ex_name = ex_name,
item_name = item_name,
})
-- Insert asks in reverse order
for row in stmt:nrows() do
table.insert(res, 1, row)
end
stmt:reset()
local stmt = self.stmts.order_book_bids_stmt
stmt:bind_names({
ex_name = ex_name,
item_name = item_name,
})
-- Append bids in normal order
for row in stmt:nrows() do
table.insert(res, row)
end
stmt:reset()
ex_cache[item_name] = res
return res
end
2016-02-25 23:38:19 +01:00
-- Returns a list of orders, sorted by price.
function ex_methods.search_orders(self, ex_name, order_type, item_name)
local stmt
if order_type == "buy" then
stmt = self.stmts.search_bids_stmt
2016-02-25 23:38:19 +01:00
else
stmt = self.stmts.search_asks_stmt
2016-02-25 23:38:19 +01:00
end
stmt:bind_names({
ex_name = ex_name,
item_name = item_name,
})
2016-02-25 23:38:19 +01:00
local orders = {}
2016-02-25 23:38:19 +01:00
for tab in stmt:nrows() do
table.insert(orders, tab)
2016-02-25 23:38:19 +01:00
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 stmt = self.stmts.search_own_stmt
stmt:bind_values(p_name)
2016-02-25 23:38:19 +01:00
local orders = {}
2016-02-25 23:38:19 +01:00
for tab in stmt:nrows() do
table.insert(orders, tab)
2016-02-25 23:38:19 +01:00
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, wear, amount, rate)
if not is_integer(amount) then
2020-09-11 08:21:51 +02:00
return false, S("Noninteger quantity")
elseif amount <= 0 then
2020-09-11 08:21:51 +02:00
return false, S("Nonpositive quantity")
elseif not is_integer(rate) then
2020-09-11 08:21:51 +02:00
return false, S("Noninteger rate")
elseif rate <= 0 then
2020-09-11 08:21:51 +02:00
return false, S("Nonpositive rate")
elseif not is_integer(wear) then
2020-09-11 08:21:51 +02:00
return false, S("Noninteger wear")
elseif wear < 0 or wear > 65535 then
2020-09-11 08:21:51 +02:00
return false, S("Invalid wear")
2016-02-25 23:38:19 +01:00
end
order_book_cache(ex_name)[item_name] = nil
2016-02-25 23:38:19 +01:00
local db = self.db
local stmt = self.stmts.add_order_stmt
stmt:bind_names({
p_name = p_name,
ex_name = ex_name,
2016-02-25 23:38:19 +01:00
order_type = order_type,
time = os.time(),
item_name = item_name,
wear = wear,
amount = amount,
rate = rate,
2016-02-25 23:38:19 +01:00
})
local res = stmt:step()
if res == sqlite3.BUSY then
stmt:reset()
2020-09-11 08:21:51 +02:00
return false, S("Database Busy.")
2016-02-25 23:38:19 +01:00
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,
2016-02-25 23:38:19 +01:00
}
local db = self.db
2016-02-26 04:03:17 +01:00
db:exec("BEGIN TRANSACTION;")
2016-02-25 23:38:19 +01:00
local get_stmt = self.stmts.get_order_stmt
get_stmt:bind_values(id)
local res = get_stmt:step()
local order
2016-02-25 23:38:19 +01:00
if res == sqlite3.ERROR then
sql_error(db:errmsg())
elseif res == sqlite3.MISUSE then
2020-09-11 08:21:51 +02:00
error(S("Programmer error."))
elseif res == sqlite3.ROW then
order = get_stmt:get_named_values()
get_stmt:reset()
else
db:exec("ROLLBACK;")
2020-09-11 08:21:51 +02:00
return false, S("No such order.")
2016-02-25 23:38:19 +01:00
end
order_book_cache(order.Exchange)[order.Item] = nil
local cancel_stmt = self.stmts.cancel_order_stmt
2016-02-26 04:03:17 +01:00
local canc_succ, canc_err = exec_stmt(db, cancel_stmt, params)
if not canc_succ then
db:exec("ROLLBACK;")
2016-02-26 04:03:17 +01:00
return false, canc_err
end
2016-02-25 23:38:19 +01:00
2016-02-26 19:49:09 +01:00
local message = "Cancelled an order to " ..
order.Type .. " " .. order.Amount .. " " .. order.Item .. "."
2016-02-26 19:49:09 +01:00
local succ, err = self:log(message, p_name)
if not succ then
db:exec("ROLLBACK")
return false, err
end
2016-02-26 19:36:21 +01:00
db:exec("COMMIT;")
return true, order
2016-02-25 23:38:19 +01:00
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, wear, amount)
2016-02-25 23:38:19 +01:00
local db = self.db
local search_stmt = self.stmts.search_inbox_stmt
db:exec("BEGIN TRANSACTION;")
search_stmt:bind_names({
p_name = p_name,
item_name = item_name,
wear = wear,
})
local res = search_stmt:step()
local row = nil
if res == sqlite3.BUSY then
search_stmt:reset()
db:exec("ROLLBACK;")
2020-09-11 08:21:51 +02:00
return false, S("Database Busy.")
elseif res == sqlite3.ROW then
row = search_stmt:get_named_values()
elseif res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
2016-02-25 23:38:19 +01:00
search_stmt:reset()
local stmt
if row then
stmt = self.stmts.add_inbox_stmt
stmt:bind_names({
id = row.Id,
change = amount,
})
else
stmt = self.stmts.insert_inbox_stmt
stmt:bind_names({
p_name = p_name,
item_name = item_name,
wear = wear,
amount = amount,
})
end
2016-02-25 23:38:19 +01:00
local res = stmt:step()
2016-02-25 23:38:19 +01:00
if res == sqlite3.BUSY then
stmt:reset()
db:exec("ROLLBACK;")
2020-09-11 08:21:51 +02:00
return false, S("Database Busy.")
2016-02-25 23:38:19 +01:00
elseif res ~= sqlite3.DONE then
sql_error(db:errmsg())
end
stmt:reset()
db:exec("COMMIT;")
2016-02-25 23:38:19 +01:00
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, wear, amount, rate)
if not is_integer(amount) then
2020-09-11 08:21:51 +02:00
return false, S("Noninteger quantity")
2016-02-25 23:38:19 +01:00
elseif amount <= 0 then
2020-09-11 08:21:51 +02:00
return false, S("Nonpositive quantity")
elseif not is_integer(rate) then
2020-09-11 08:21:51 +02:00
return false, S("Noninteger rate")
2016-02-25 23:38:19 +01:00
elseif rate <= 0 then
2020-09-11 08:21:51 +02:00
return false, S("Nonpositive rate")
elseif not is_integer(wear) then
2020-09-11 08:21:51 +02:00
return false, S("Noninteger wear")
elseif wear < 0 or wear > 65535 then
2020-09-11 08:21:51 +02:00
return false, S("Invalid wear")
2016-02-25 23:38:19 +01:00
end
local db = self.db
db:exec("BEGIN TRANSACTION");
local balance = self:get_balance(p_name)
if not balance then
db:exec("ROLLBACK;")
2020-09-11 08:21:51 +02:00
return false, S("@1 does not have an account.", p_name)
2016-02-25 23:38:19 +01:00
end
local bought = {}
2016-02-25 23:38:19 +01:00
local remaining = amount
local out_of_funds = false
local last_row_rate = rate
2016-02-25 23:38:19 +01:00
local del_stmt = self.stmts.del_order_stmt
local red_stmt = self.stmts.reduce_order_stmt
local search_stmt = self.stmts.qual_asks_stmt
2016-02-25 23:38:19 +01:00
search_stmt:bind_names({
ex_name = ex_name,
2016-02-25 23:38:19 +01:00
item_name = item_name,
rate_max = rate,
wear_max = wear,
2016-02-25 23:38:19 +01:00
})
for row in search_stmt:nrows() do
local poster = row.Poster
local row_wear = row.Wear
2016-02-25 23:38:19 +01:00
local row_amount = row.Amount
local row_rate = row.Rate
local row_bought = math.min(row_amount, remaining)
if poster ~= p_name then
local can_afford = math.floor(balance / row_rate)
last_row_rate = row_rate
out_of_funds = can_afford < row_bought
row_bought = math.min(row_bought, can_afford)
-- asking prices can only increase from here
if row_bought == 0 then break end
end
2016-02-25 23:38:19 +01:00
local red_del_stmt
if row_bought < row_amount then
red_stmt:bind_names({
id = row.Id,
delta = row_bought,
})
red_del_stmt = red_stmt
else -- row_bought == row_amount
2016-02-25 23:38:19 +01:00
del_stmt:bind_values(row.Id)
red_del_stmt = del_stmt
end
2016-02-25 23:38:19 +01:00
local red_del_res = red_del_stmt:step()
if red_del_res == sqlite3.BUSY then
red_del_stmt:reset()
search_stmt:reset()
db:exec("ROLLBACK;")
2020-09-11 08:21:51 +02:00
return false, S("Database Busy.")
elseif red_del_res ~= sqlite3.DONE then
red_del_stmt:reset()
search_stmt:reset()
sql_error(db:errmsg())
end
red_del_stmt:reset()
if poster ~= p_name then
local cost = row_rate * row_bought
local ch_succ, ch_err = self:change_balance(poster, cost)
if not ch_succ then
2016-02-25 23:38:19 +01:00
search_stmt:reset()
db:exec("ROLLBACK;")
return false, ch_err
2016-02-25 23:38:19 +01:00
end
local ch_succ, ch_err = self:change_balance(p_name, -cost)
2016-02-25 23:38:19 +01:00
if not ch_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, ch_err
end
balance = balance - cost
local log_succ, log_err =
self:log(p_name .. " bought " .. row_bought .. " " ..
item_name .. " from you. (+" .. cost .. ")",
poster)
2016-02-25 23:38:19 +01:00
if not log_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
end
local log_succ, log_err =
self:log("Bought " .. row_bought .. " " .. item_name ..
" from " .. poster .. ". (-" .. cost .. ")",
p_name)
if not log_succ then
2016-02-25 23:38:19 +01:00
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
2016-02-25 23:38:19 +01:00
end
else
local log_succ, log_err =
self:log("Bought " .. row_bought .. " " ..
item_name .. " from yourself.",
p_name)
2016-02-25 23:38:19 +01:00
if not log_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
end
end
2016-02-25 23:38:19 +01:00
order_book_cache(ex_name)[item_name] = nil
2016-02-25 23:38:19 +01:00
table.insert(bought, { amount = row_bought, wear = row_wear })
remaining = remaining - row_bought
2016-02-25 23:38:19 +01:00
if remaining == 0 or out_of_funds then break end
2016-02-25 23:38:19 +01:00
end
search_stmt:reset()
if remaining > 0 then
if out_of_funds then
local log_succ, log_err =
self:log("Insufficient funds to buy " .. remaining .. " " ..
item_name .. " at " .. last_row_rate .. "/ea.",
p_name)
if not log_succ then
db:exec("ROLLBACK;")
return false, log_err
end
else
local add_succ, add_err =
self:add_order(p_name, ex_name, "buy", item_name, wear, remaining, rate)
2016-02-25 23:38:19 +01:00
if not add_succ then
db:exec("ROLLBACK;")
return false, add_err
end
2016-02-25 23:38:19 +01:00
local log_succ, log_err =
self:log("Posted buy offer for " .. remaining .. " " ..
item_name .. " at " .. rate .. "/ea.",
p_name)
if not log_succ then
db:exec("ROLLBACK;")
return false, log_err
end
2016-02-25 23:38:19 +01:00
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, wear, amount, rate)
if not is_integer(amount) then
2020-09-11 08:21:51 +02:00
return false, S("Noninteger quantity")
2016-02-25 23:38:19 +01:00
elseif amount <= 0 then
2020-09-11 08:21:51 +02:00
return false, S("Nonpositive quantity")
elseif not is_integer(rate) then
2020-09-11 08:21:51 +02:00
return false, S("Noninteger rate")
2016-02-25 23:38:19 +01:00
elseif rate <= 0 then
2020-09-11 08:21:51 +02:00
return false, S("Nonpositive rate")
elseif not is_integer(wear) then
2020-09-11 08:21:51 +02:00
return false, S("Noninteger wear")
elseif wear < 0 or wear > 65535 then
2020-09-11 08:21:51 +02:00
return false, S("Invalid wear")
2016-02-25 23:38:19 +01:00
end
local db = self.db
2016-02-25 23:38:19 +01:00
db:exec("BEGIN TRANSACTION");
2016-02-25 23:38:19 +01:00
local remaining = amount
local del_stmt = self.stmts.del_order_stmt
local red_stmt = self.stmts.reduce_order_stmt
local search_stmt = self.stmts.qual_bids_stmt
2016-02-25 23:38:19 +01:00
search_stmt:bind_names({
ex_name = ex_name,
2016-02-25 23:38:19 +01:00
item_name = item_name,
rate_min = rate,
wear_min = wear,
2016-02-25 23:38:19 +01:00
})
for row in search_stmt:nrows() do
local poster = row.Poster
2016-02-25 23:38:19 +01:00
local row_amount = row.Amount
local row_rate = row.Rate
local row_sold = math.min(row_amount, remaining)
2016-02-25 23:38:19 +01:00
local out_of_funds = false
if poster ~= p_name then
local bal = self:get_balance(poster) or 0
local can_afford = math.floor(bal / row_rate)
out_of_funds = can_afford < row_sold
row_sold = math.min(row_sold, can_afford)
end
2016-02-25 23:38:19 +01:00
local red_del_stmt
2016-02-25 23:38:19 +01:00
2022-07-10 19:40:52 +02:00
if row_sold < row_amount then
red_stmt:bind_names({
id = row.Id,
delta = row_sold,
})
red_del_stmt = red_stmt
2022-07-10 19:40:52 +02:00
else
del_stmt:bind_values(row.Id)
red_del_stmt = del_stmt
end
local red_del_res = red_del_stmt:step()
red_del_stmt:reset()
if red_del_res == sqlite3.BUSY then
search_stmt:reset()
db:exec("ROLLBACK;")
2020-09-11 08:21:51 +02:00
return false, S("Database Busy.")
elseif red_del_res ~= sqlite3.DONE then
search_stmt:reset()
sql_error(db:errmsg())
end
2022-07-10 19:40:52 +02:00
local in_succ, in_err =
self:put_in_inbox(poster, item_name, wear, row_sold)
if not in_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, in_err
end
2022-07-10 19:40:52 +02:00
if poster ~= p_name then
local revenue = row_sold * row_rate
local ch_succ, ch_err = self:change_balance(poster, -revenue)
if not ch_succ then
2016-02-25 23:38:19 +01:00
search_stmt:reset()
db:exec("ROLLBACK;")
return false, ch_err
2016-02-25 23:38:19 +01:00
end
local ch_succ, ch_err = self:change_balance(p_name, revenue)
if not ch_succ then
2016-02-25 23:38:19 +01:00
search_stmt:reset()
db:exec("ROLLBACK;")
return false, ch_err
2016-02-25 23:38:19 +01:00
end
local log_succ, log_err =
self:log(p_name .. " sold " .. row_sold .. " " ..
item_name .. " to you. (-" .. revenue .. ")",
poster)
if not log_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
end
if out_of_funds then
local log_succ, log_err =
self:log("Insufficient funds to buy " ..
math.min(row_amount - row_sold, remaining) ..
" " .. item_name .. " from " .. p_name ..
" at " .. row_rate .. "/ea.",
poster)
if not log_succ then
db:exec("ROLLBACK;")
return false, log_err
end
end
local log_succ, log_err =
self:log("Sold " .. row_sold .. " " .. item_name ..
" to " .. poster .. ". (+" .. revenue .. ")",
p_name)
if not log_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
end
else
local log_succ, log_err =
self:log("Sold " .. row_sold .. " " ..
item_name .. " to yourself.",
p_name)
if not log_succ then
search_stmt:reset()
db:exec("ROLLBACK;")
return false, log_err
2016-02-25 23:38:19 +01:00
end
end
2016-02-25 23:38:19 +01:00
order_book_cache(ex_name)[item_name] = nil
2016-02-25 23:38:19 +01:00
remaining = remaining - row_sold
if remaining == 0 then break end
2016-02-25 23:38:19 +01:00
end
search_stmt:reset()
if remaining > 0 then
local add_succ, add_err =
self:add_order(p_name, ex_name, "sell", item_name, wear, remaining, rate)
2016-02-25 23:38:19 +01:00
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.
2016-03-10 18:26:01 +01:00
-- TODO: On failure, return false and an error message.
2016-02-25 23:38:19 +01:00
function ex_methods.view_inbox(self, p_name)
2016-02-26 04:47:05 +01:00
local stmt = self.stmts.view_inbox_stmt
2016-02-25 23:38:19 +01:00
stmt:bind_values(p_name)
local res = {}
2016-02-25 23:38:19 +01:00
for row in stmt:nrows() do
table.insert(res, row)
2016-02-25 23:38:19 +01:00
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 })
2016-02-25 23:38:19 +01:00
local res = get_stmt:step()
if res == sqlite3.BUSY then
get_stmt:reset()
2020-09-11 08:21:51 +02:00
return false, S("Database Busy.")
2016-02-25 23:38:19 +01:00
elseif res == sqlite3.DONE then
get_stmt:reset()
2020-09-11 08:21:51 +02:00
return false, S("Order does not exist.")
2016-02-25 23:38:19 +01:00
elseif res ~= sqlite3.ROW then
sql_error(db:errmsg())
end
local available = get_stmt:get_value(0)
get_stmt:reset()
db:exec("BEGIN TRANSACTION;")
local red_del_stmt
2016-02-25 23:38:19 +01:00
if available > amount then
red_stmt:bind_names({
id = id,
2016-02-25 23:38:19 +01:00
change = amount
})
red_del_stmt = red_stmt
2016-02-25 23:38:19 +01:00
else
del_stmt:bind_values(id)
red_del_stmt = del_stmt
end
2016-02-25 23:38:19 +01:00
local red_del_res = red_del_stmt:step()
2016-02-25 23:38:19 +01:00
if red_del_res == sqlite3.BUSY then
red_del_stmt:reset()
db:exec("ROLLBACK;")
2020-09-11 08:21:51 +02:00
return false, S("Database Busy.")
elseif red_del_res ~= sqlite3.DONE then
sql_error(db:errmsg())
2016-02-25 23:38:19 +01:00
end
red_del_stmt:reset()
2016-02-25 23:38:19 +01:00
db:exec("COMMIT;")
return true, math.min(amount, available)
end
2016-02-26 07:07:57 +01:00
-- Returns a list of tables with fields:
-- item_name: Name of the item
-- buy_volume: Number of items sought
-- buy_max: Maximum buy rate
-- sell_volume: Number of items for sale
-- sell_min: Minimum sell rate
function ex_methods.market_summary(self)
local stmt = self.stmts.summary_stmt
local res = {}
for a in stmt:nrows() do
table.insert(res, a)
2016-02-26 07:07:57 +01:00
end
stmt:reset()
return res
end
2016-02-25 23:38:19 +01:00
2016-02-27 04:45:32 +01:00
-- Returns a list of log entries, sorted by time.
function ex_methods.player_log(self, p_name)
local stmt = self.stmts.transaction_log_stmt
stmt:bind_values(p_name)
local res = {}
2016-02-27 04:45:32 +01:00
for row in stmt:nrows() do
table.insert(res, row)
2016-02-27 04:45:32 +01:00
end
stmt:reset()
return res
end
2016-02-25 23:38:19 +01:00
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
2016-02-25 23:38:19 +01:00
-- 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()
2016-02-25 23:38:19 +01:00
-- 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", 32767, 10, 2)
2016-02-25 23:38:19 +01:00
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", 32767, 20, 1)
2016-02-25 23:38:19 +01:00
print("Success: ", succ, " ", err)
print_balances()
2016-02-25 23:38:19 +01:00
ex:close()
end
return exports
-- vim:set ts=4 sw=4 noet: