From 90c497f68a21c2034d5e15516fc49d11819a091d Mon Sep 17 00:00:00 2001 From: raymoo Date: Thu, 25 Feb 2016 14:38:19 -0800 Subject: [PATCH] Everything --- atm.lua | 182 +++++ description.txt | 1 + exchange.lua | 1080 +++++++++++++++++++++++++ exchange_machine.lua | 497 ++++++++++++ init.lua | 52 ++ mod.conf | 1 + test.db | Bin 0 -> 13312 bytes textures/global_exchange_atm_side.png | Bin 0 -> 255 bytes textures/global_exchange_atm_top.png | Bin 0 -> 176 bytes 9 files changed, 1813 insertions(+) create mode 100644 atm.lua create mode 100644 description.txt create mode 100644 exchange.lua create mode 100644 exchange_machine.lua create mode 100644 init.lua create mode 100644 mod.conf create mode 100644 test.db create mode 100644 textures/global_exchange_atm_side.png create mode 100644 textures/global_exchange_atm_top.png diff --git a/atm.lua b/atm.lua new file mode 100644 index 0000000..8e3b7c6 --- /dev/null +++ b/atm.lua @@ -0,0 +1,182 @@ + +-- A telling machine. Call this file with the exchange argument. +local exchange = ... + +local atm_form = "global_exchange:atm_form" + +local main_menu =[[ +size[6,1] +button[0,0;2,1;new_account;New Account] +button[2,0;2,1;info;Account Info] +button[4,0;2,1;wire;Wire Monies] +]] + + +local function logout(x,y) + return "button[" .. x .. "," .. y .. + ";2,1;logout;Log Out]" +end + + +local function label(x,y,text) + return "label[" .. x .. "," .. y .. ";" + .. minetest.formspec_escape(text) .. "]" +end + +local function field(x,y, w,h, name, label, default) + return "field[" .. x .. "," .. y .. ";" .. w .. "," .. h .. ";" + .. name .. ";" .. minetest.formspec_escape(label) .. ";" + .. minetest.formspec_escape(default) .. "]" +end + +local unique_num = 1 + +local function unique() + local ret = unique_num + unique_num = unique_num + 1 + + return ret +end + + +local function new_account_fs(p_name) + local fs = "size[4,3]" + + local act_suc, err = exchange:new_account(p_name) + + if not act_suc then + fs = fs .. label(0.5,0.5, "Error: " .. err) + else + fs = fs .. label(0.5,0.5, "Congratulations on \nyour new account.") + end + + fs = fs .. logout(0.5,2) + + return fs +end + + +local function info_fs(p_name) + local balance = exchange:get_balance(p_name) + + local fs = "size[4,3]" + + if not balance then + fs = fs .. label(0.5,0.5, "You don't have an account.") + else + fs = fs .. label(0.5,0.5, "Balance: " .. balance) + end + + fs = fs .. logout(0.5,2) + + return fs +end + + +local function wire_fs(p_name) + local balance = exchange:get_balance(p_name) + + local fs = "size[4,5]" + fs = fs .. logout(0,4) + + if not balance then + fs = fs .. label(0.5,0.5, "You don't have an account.") + return fs + end + + -- To prevent duplicates + fs = fs .. field(-100, -100, 0,0, "trans_id", "", unique()) + fs = fs .. label(0.5,0.5, "Balance: " .. balance) + fs = fs .. field(0.5,1.5, 2,1, "recipient", "Send to:", "") + fs = fs .. field(0.5,2.5, 2,1, "amount", "Amount", "") + fs = fs .. "button[2,4;2,1;send;Send]" + + return fs +end + + +local function send_fs(p_name, receiver, amt_str) + local fs = "size[7,3]" + + local amt = tonumber(amt_str) + + if not amt or amt <= 0 then + fs = fs .. label(0.5,0.5, "Invalid transfer amount.") + fs = fs .. "button[0.5,2;2,1;wire;Back]" + + return fs + end + + local succ, err = exchange:transfer_credits(p_name, receiver, amt) + + if not succ then + fs = fs .. label(0.5,0.5, "Error: " .. err) + fs = fs .. "button[0.5,2;2,1;wire;Back]" + else + fs = fs.. label(0.5,0.5, "Successfully sent " + .. amt .. " credits to " .. receiver) + fs = fs .. "button[0.5,2;2,1;wire;Back]" + end + + return fs +end + + +local trans_ids = {} + + +local function handle_fields(player, formname, fields) + if formname ~= atm_form then return end + if fields["quit"] then return true end + + local p_name = player:get_player_name() + + local this_id = fields.trans_id + + if this_id and this_id == trans_ids[p_name] then + return true + end + + trans_ids[p_name] = this_id + + if fields["logout"] then + minetest.show_formspec(p_name, atm_form, main_menu) + end + + if fields["new_account"] then + minetest.show_formspec(p_name, atm_form, new_account_fs(p_name)) + end + + if fields["info"] then + minetest.show_formspec(p_name, atm_form, info_fs(p_name)) + end + + if fields["wire"] then + minetest.show_formspec(p_name, atm_form, wire_fs(p_name)) + end + + if fields["send"] then + minetest.show_formspec(p_name, atm_form, + send_fs(p_name, fields.recipient, fields.amount)) + end + + return true +end + + +minetest.register_on_player_receive_fields(handle_fields) + + +minetest.register_node("global_exchange:atm", { + description = "ATM", + tiles = {"global_exchange_atm_top.png", + "global_exchange_atm_top.png", + "global_exchange_atm_side.png", + }, + groups = {cracky=2}, + on_rightclick = function(pos, node, clicker) + local p_name = clicker:get_player_name() + + minetest.show_formspec(p_name, atm_form, main_menu) + end, +}) diff --git a/description.txt b/description.txt new file mode 100644 index 0000000..dbd9693 --- /dev/null +++ b/description.txt @@ -0,0 +1 @@ +Adds a server-wide commodities (item) exchange. diff --git a/exchange.lua b/exchange.lua new file mode 100644 index 0000000..3b1a775 --- /dev/null +++ b/exchange.lua @@ -0,0 +1,1080 @@ + +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 diff --git a/exchange_machine.lua b/exchange_machine.lua new file mode 100644 index 0000000..7b3119c --- /dev/null +++ b/exchange_machine.lua @@ -0,0 +1,497 @@ + +local exchange = ... +local search_cooldown = 2 + +local main_state = {} +-- ^ A per-player state for the main form. It contains these values: +-- old_fields: Keeps track of the fields before this update, when changing +-- things slightly +-- search_results: The last search results the player obtained +-- last_search_time: The last time the player did a search. Used to implement +-- a cooldown on searches +-- sell: A boolean whether the player has sell selected + + +local function default_main_state() + return { old_fields = {}, + search_results = {}, + last_search_time = 0, + } +end + + +minetest.register_on_joinplayer(function(player) + main_state[player:get_player_name()] = default_main_state() +end) + +minetest.register_on_leaveplayer(function(player) + main_state[player:get_player_name()] = nil +end) + + +local main_form = "global_exchange:exchange_main" + + +local tablecolumns = + "tablecolumns[text;text;text;text;text;text]" + + +local function table_from_results(results, x, y, w, h, selected) + local fs_tab = {} + + local function insert(str) + table.insert(fs_tab, str) + end + + insert(tablecolumns) + insert("table[" .. x .. "," .. y .. ";" .. w .. "," .. h .. ";") + insert("result_table;") + insert("Poster,Type,Item,Description,Amount,Rate") + + local all_items = minetest.registered_items + + for i, row in ipairs(results) do + insert(",") + insert(tostring(row.Poster)) + insert(",") + insert(tostring(row.Type)) + insert(",") + insert(tostring(row.Item)) + insert(",") + if all_items[row.Item] then + insert(all_items[row.Item].description) + else + insert("Unknown Item") + end + insert(",") + insert(tostring(row.Amount)) + insert(",") + insert(tostring(row.Rate)) + end + + if selected and selected ~= "" then + insert(";") + insert(selected) + end + insert("]") + + return table.concat(fs_tab) +end + + +local function mk_main_fs(p_name, new_item, err_str, success) + local fs = "size[8,9]" + + local state = main_state[p_name] + if not state then return end -- Should have been initialized on player join + + local old_fields = state.old_fields + local results = state.search_results + local item_def = new_item or old_fields.item or "" + local amount_def = old_fields.amount or "" + local rate_def = old_fields.rate or "" + local sell_def = state.sell or false + local selected_def = old_fields.selected or "" + + local bal = exchange:get_balance(p_name) + + if bal then + fs = fs .. "label[0.2,0.5;Balance: " .. bal + else + fs = fs .. "label[0.2,0.5;Use an ATM to make your account.]" + end + + fs = fs .. "button[6,0,2,1;your_orders;Your Orders]" + fs = fs .. "field[0.2,1.5;3,1;item;Item: ;" .. item_def .. "]" + fs = fs .. "field[3.2,1.5;3,1;amount;Amount: ;" .. amount_def .. "]" + fs = fs .. "button[6,1;2,1.4;select_item;Select Item]" + fs = fs .. "checkbox[5,3;sell;Sell;" .. tostring(sell_def) .. "]" + fs = fs .. "field[0.2,2.5;2,1;rate;Rate: ;" .. rate_def .. "]" + fs = fs .. "button[2,2;2,1.4;search;Search]" + fs = fs .. "button[4,2;3,1.4;post_order;Post Order]" + + if err_str then + fs = fs .. "label[0,3;Error: " .. err_str .. "]" + end + + if success then + fs = fs .. "label[0,3;Success!]" + end + + fs = fs .. table_from_results(results, 0, 4, 8, 5, selected_def) + + return fs +end + + +local function show_main(p_name, new_item, err_str, success) + minetest.show_formspec(p_name, main_form, mk_main_fs(p_name, new_item, err_str, success)) +end + + +-- Something similar to creative inventory +local selectable_inventory_size = 0 + +-- Create detached inventory after loading all mods +minetest.after(0, function() + local inv = minetest.create_detached_inventory("global_exchange", { + allow_move = function(inv, from_list, from_index, to_list, to_index, count, player) + local p_name = player:get_player_name() + + if from_list == "main" and to_list == "p_" .. p_name then + return 1 + else + return 0 + end + end, + allow_put = function() + return 0 + end, + allow_take = function() + return 0 + end, + on_move = function(inv, from_list, from_index, to_list, to_index, count, player) + local p_name = player:get_player_name() + local p_list = "p_" .. p_name + + local item_name = inv:get_list(p_list)[1]:get_name() + inv:set_list(p_list, {}) + inv:add_item("main", item_name) + show_main(p_name, item_name) + end, + }) + + local selectable_list = {} + for name, def in pairs(minetest.registered_items) do + if (not def.groups.not_in_creative_inventory or def.groups.not_in_creative_inventory == 0) + and def.description and def.description ~= "" then + table.insert(selectable_list, name) + end + end + table.sort(selectable_list) + inv:set_size("main", #selectable_list) + for _,itemstring in ipairs(selectable_list) do + inv:add_item("main", ItemStack(itemstring)) + end + + selectable_inventory_size = #selectable_list +end) + + +minetest.register_on_joinplayer(function(player) + local big_inv = minetest.get_inventory({type="detached", name="global_exchange"}) + local p_list = "p_" .. player:get_player_name() + + big_inv:set_size(p_list, 1) +end) + + +local select_form = "global_exchange:select_form" + + +local function mk_select_formspec(p_name, start_i, pagenum) + pagenum = math.floor(pagenum) + local pagemax = math.floor((selectable_inventory_size - 1) / (8 * 4) + 1) + local p_list = "p_" .. p_name + + return "size[9.3,8]" .. + "list[detached:global_exchange;main;0.3,0.5;8,4;" .. tostring(start_i) .. "]" .. + "button[0.3,4.5;1.6,1;select_prev;<<]".. + "button[6.7,4.5;1.6,1;select_next;>>]".. + "label[2.0,5.55;"..tostring(pagenum).."/"..tostring(pagemax).."]".. + "list[detached:global_exchange;" .. p_list .. ";0.3,7;1,1;]" +end + + +local player_pages = {} + + +local function show_select(p_name) + local pagenum = player_pages[p_name] or 1 + local start_i = (pagenum - 1) * 8 * 4 + + local fs = mk_select_formspec(p_name, start_i, pagenum) + minetest.show_formspec(p_name, select_form, fs) +end + + +local own_form = "global_exchange:my_orders" + +local own_state = {} +-- ^ Per=player state for the own orders form. Contains these fields: +-- selected_index: The selected index +-- own_results: Results for own orders. + +local function mk_own_orders_fs(p_name, results, selected) + local fs = "size[8,8]" + + local state = main_state[p_name] + + fs = fs .. "label[0.5,0.2;Your Orders]" + fs = fs .. "button[6,0;2,1;refresh;Refresh]" + fs = fs .. table_from_results(results, 0, 2, 8, 4.5, selected or "") + fs = fs .. "button[0,7;2,1;cancel;Cancel]" + fs = fs .. "button[3,7;2,1;back;Back]" +end + + +local function show_own_orders(p_name, results, selected) + minetest.show_formspec(p_name, own_form, mk_own_orders_fs(p_name, results, selected)) +end + + +-- Returns success, and also returns an error message if failed. +local function post_order(player, ex_name, order_type, item_name, amount_str, rate_str) + local p_name = player:get_player_name() + + if item_name == "" then + return false, "You must input an item" + end + + if not minetest.registered_items[item_name] then + return false, "That item does not exist." + end + + local amount = tonumber(amount_str) + local rate = tonumber(rate_str) + + if not amount then + return false, "Invalid amount." + end + + if not rate then + return false, "Invalid rate." + end + + local p_inv = player:get_inventory() + local stack = ItemStack(item_name) + stack:set_count(amount) + + if order_type == "buy" then + if not p_inv:room_for_item("main", stack) then + return false, "Not enough space in inventory." + end + + local succ, res = exchange:buy(p_name, ex_name, item_name, amount, rate) + if not succ then + return false, res + end + + stack:set_count(res) + p_inv:add_item("main", stack) + else + if not p_inv:contains_item("main", stack) then + return false, "Items not in inventory." + end + + local succ, res = exchange:sell(p_name, ex_name, item_name, amount, rate) + if not succ then + return false, res + end + + p_inv:remove_item("main", stack) + end + + return true +end + + +local function handle_main(player, formname, fields) + if formname ~= main_form then return end + local p_name = player:get_player_name() + local state = main_state[p_name] + local old_fields = state.old_fields + + for k, v in pairs(fields) do + old_fields[k] = v + end + + if fields["select_item"] then + show_select(p_name) + end + + if fields["search"] then + local now = os.time() + local last_search = state.last_search_time + + if now - last_search < search_cooldown then + show_main(p_name, nil, "Please wait before searching again.") + return true + end + + -- If the player is selling, she wants "buy" type offers. + local order_type + if state.sell then + order_type = "buy" + else + order_type = "sell" + end + local item_name = fields["item"] + + local results = exchange:search_orders("", order_type, item_name) + state.search_results = results + state.last_search_time = now + + show_main(p_name) + end + + if fields["sell"] then + if fields["sell"] == "true" then + state.sell = true + else + state.sell = false + end + end + + + if fields["post_order"] then + local now = os.time() + local last_search = state.last_search_time + + if now - last_search < search_cooldown then + show_main(p_name, nil, "Please wait before posting.") + return true + end + + local order_type + if state.sell then + order_type = "sell" + else + order_type = "buy" + end + local item_name = fields["item"] + local amount_str = fields["amount"] + local rate_str = fields["rate"] + + local succ, err = + post_order(player, "", order_type, item_name, amount_str, rate_str) + + if succ then + state.search_results = {} + show_main(p_name, nil, nil, true) + else + show_main(p_name, nil, err) + end + end + + + if fields["result_table"] then + local results = state.search_results + local event = minetest.explode_table_event(fields["result_table"]) + + if event.type ~= "CHG" then + return true + end + + local index = event.row - 1 + result = results[index] + + if result then + old_fields.amount = tostring(result.Amount) + old_fields.rate = tostring(result.Rate) + end + + show_main(p_name) + end + + if fields["your_orders"] then + if not own_state[p_name] then + own_state[p_name] = {} + end + local o_state = own_state[p_name] + + o_state.own_results = exchange:search_player_orders(p_name) or {} + + show_own_orders(p_name, o_state.own_results) + end + + return true +end + + +local function handle_select(player, formname, fields) + if formname ~= select_form then return end + + local p_name = player:get_player_name() + + local pagemax = math.floor((selectable_inventory_size - 1) / (8 * 4) + 1) + local pagenum = player_pages[p_name] or 1 + + if fields["select_prev"] then + player_pages[p_name] = math.max(1, pagenum - 1) + show_select(p_name) + elseif fields["select_next"] then + player_pages[p_name] = math.min(pagemax, pagenum + 1) + show_select(p_name) + end + + return true +end + + +local function handle_own_orders(player, formname, fields) + if formname ~= own_form then return end + + local p_name = player:get_player_name() + + local state = own_state[p_name] or {} + local results = state.own_results or {} + local idx = state.selected_index + + if fields["refresh"] then + state.own_results = exchange:search_player_orders(p_name) or {} + show_own_orders(p_name, state.own_results) + end + + if fields["cancel"] and idx then + local row = results[idx] + + local succ, err = exchange:cancel_order(p_name, row.Id) + if succ then + table.remove(results, idx) + else + -- Refresh the results, since there might have been a problem. + state.own_results = exchange:search_player_orders(p_name) or {} + end + + show_own_orders(p_name, state.own_results) + end + + if fields["result_table"] then + local event = minetest.explode_table_event(fields["result_table"]) + if event.type == "CHG" then + state.selected_index = event.row - 1 + show_own_orders(p_name, results, state.selected_index) + end + end + + if fields["back"] then + show_main(p_name) + end + + return true +end + + +minetest.register_on_player_receive_fields(handle_main) +minetest.register_on_player_receive_fields(handle_select) +minetest.register_on_player_receive_fields(handle_own_orders) + + +minetest.register_node("global_exchange:exchange", { + description = "Exchange", + tiles = {"global_exchange_atm_top.png", + "global_exchange_atm_top.png", + "global_exchange_atm_side.png", + }, + groups = {cracky=2}, + on_rightclick = function(pos, node, clicker) + local p_name = clicker:get_player_name() + local state = main_state[p_name] + if state then + state.search_results = {} + end + + show_main(p_name) + end, +}) diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..d3c839b --- /dev/null +++ b/init.lua @@ -0,0 +1,52 @@ + +local modpath = minetest.get_modpath(minetest.get_current_modname()) .. "/" +local savepath = minetest.get_worldpath() .. "/global_exchange.db" + +local income_str = minetest.setting_get("citizens_income") +local income = income_str and tonumber(income_str) or 10 + +local income_interval = 1200 + +local income_msg = "You receive your citizen's income (+" .. income .. ")" + +local next_payout = os.time() + income_interval + +local ex = dofile(modpath .. "exchange.lua") +local exchange = ex.open_exchange(savepath) + + +minetest.register_on_shutdown(function() + exchange:close() +end) + + +-- Only check once in a while +local elapsed = 0 + +minetest.register_globalstep(function(dtime) + elapsed = elapsed + dtime + if elapsed <= 5 then return end + + elapsed = 0 + + local now = os.time() + if now < next_payout then return end + + next_payout = now + income_interval + + for i, player in ipairs(minetest.get_connected_players()) do + local p_name = player:get_player_name() + + local succ = + exchange:give_credits(p_name, income, + "Citizen's Income (+" .. income .. ")") + + if succ then + minetest.chat_send_player(p_name, income_msg) + end + end +end) + + +assert(loadfile(modpath .. "atm.lua"))(exchange) +assert(loadfile(modpath .. "exchange_machine.lua"))(exchange) diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..326fab3 --- /dev/null +++ b/mod.conf @@ -0,0 +1 @@ +name=global_exchange \ No newline at end of file diff --git a/test.db b/test.db new file mode 100644 index 0000000000000000000000000000000000000000..40f5d1d07fab9f17701d1e017467cb9272f61421 GIT binary patch literal 13312 zcmeHN&2Jk;6rb7I^?Elo7b~oMVn_w@Do$A^AS5aUoOD%IN$kXRsz#`a*6U4bVS5vQ z(Bzni6aN4=4jj00;=~bdaN*KRB@~bEd9iSwMW$-ruv;T&&jK~k%|ZG^NNQGTms=kt#3BsFBITfBqKfvL1J zao%y%yLLloGmZx{&Nib?H>}ZW@0dn=dtsZ!t{1Ed(d^X=R%*XKk*!OUSa(2GX7lh%T{=E(OJMu z(Y2wXf42mXr^v5}kO$2EKOjF}a=GQOL_i|&KS01#NCubMwXod*QxCS@YgMBo5PYUD zypF*USdaD%X8$pHh+vaNA|Mf%9t5r`21=)9RT~@NZgj#%gE?HI&Y3Bm|3`>CnjXca zCK3UOKn#HliwSJZV}jA=Uz7R27*4V(5twcS&gcKgpGdlEP(4L&4?sA% zme4KB!l&1}KBRJZ6fp=T1~f0+`SiM`TbT_0c#DG~!Ypx2td`wE#if1Ns6D3o{Tn!7^_+m(`6ggF~-v63?lp4w{DihHL*izV2%*Vm!jJqqsm zt^M%aK;`>dp+3@2;+8`G8lX**Qp)&E@DE^Zw>+8<KaG|E2wM>2tuk1;ya-c%FT{5;;_*mVvYkfPh|gonKlGGt>-Z3NA+ z!EDQKhB=sNpIAh<(rJ7;%Xvan3!6GJ7drmIWq{mZ{h-d0e&cvd0&gSibo}uPWZDhP zZI-W6-?=Ea%7Q_KG6LCpReHY=9y-wDHXF125P*U47-&SuomC`7!`~EHDv~o5>b_} zmD^C2<;6t6GZWTT3-5D#A&f?yrH#46m+BiVuXw|~Lt~?6sYp2m%?|CR9T>XxS_YrY z#&|_M9W8vKD;IDRy*cuIP`^^iu z!w6#yjzCOK!PNHj1d}%4i~??@cOzzqg{@4Eu#1l~j)I0}x#hMeI?Q^XiSP_LVE>6n z^6%U>(`NP`lOGXmvPc9Z0#k*+t9lyp{|0j_F3$fe;`x6H-~XpdajA?%Kq4?1f#Lsu Fe*wY-i}wHk literal 0 HcmV?d00001 diff --git a/textures/global_exchange_atm_side.png b/textures/global_exchange_atm_side.png new file mode 100644 index 0000000000000000000000000000000000000000..180dec221e27a65a683c0f0d0b1ff5c9b96b01a5 GIT binary patch literal 255 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|oCO|{#S9GG!XV7ZFl&wkP>{XE z)7O>#0h1^Pw~oY$h4nxo$r9Iy66gHf+|;}h2Ir#G#FEq$h4Rdj33NZ5RLQ6CR;b8DX?xZFfj1&@DN=2>u7hnj=;5NysJ4^edFawO-M*^;wUjNE-_G+ zC@`ColHhRZ!of2EAEz&Tq(9~0frf;`o+7*|l~ReEf($3wIwp5qWeDf`&JZT_PQ_z_ v;A?hfJJdynDYakXjxJ?{SoI)cH|)z4*}Q$iB}P)JaL literal 0 HcmV?d00001 diff --git a/textures/global_exchange_atm_top.png b/textures/global_exchange_atm_top.png new file mode 100644 index 0000000000000000000000000000000000000000..8b30dba36de5e9285d2e11ac9f15272c8632d9ca GIT binary patch literal 176 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|oCO|{#S9GG!XV7ZFl&wkP>{XE z)7O>#0h1^Pw`Rtn&<{W%$r9Iy66gHf+|;}h2Ir#G#FEq$h4Rdj3iD)^A0CyaK;FetE0#7+yz+#XpmdKI;Vst0C5;EC;$Ke literal 0 HcmV?d00001