diff --git a/atm.lua b/atm.lua index f6342a8..79fd8e7 100644 --- a/atm.lua +++ b/atm.lua @@ -1,119 +1,115 @@ -- A telling machine. Call this file with the exchange argument. -local exchange = ... +local exchange, formlib = ... local atm_form = "global_exchange:atm_form" -local main_menu =[[ -size[6,2] -button[2,0;2,1;info;Account Info] -button[4,0;2,1;wire;Wire Monies] -button[1,1;4,1;transaction_log;Transaction Log] -]] + +local unique = (function(unique_num) + return function() + unique_num = unique_num + 1 + return unique_num + end +end)(0) -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 info_fs(p_name) +local function info_fs(fs, p_name) local balance = exchange:get_balance(p_name) - local fs - if not balance then - fs = label(0.5,0.5, "You don't have an account.") + fs:size(4,3) + + if balance then + fs:label(0.5,0.5, "Balance: " .. balance) else - fs = label(0.5,0.5, "Balance: " .. balance) + fs:label(0.5,0.5, "You don't have an account.") end - return "size[4,3]" .. fs .. logout(0.5,2) + fs:button(1,2, 2,1, "logout", "Log Out") end -local function wire_fs(p_name) +local function wire_fs(fs, p_name) local balance = exchange:get_balance(p_name) - local fs = "size[4,5]" .. logout(0,4) + fs:size(4,5) - if not balance then - return fs .. label(0.5,0.5, "You don't have an account.") + if balance then + -- To prevent duplicates + fs:field(-100, -100, 0,0, "trans_id", "", unique()) + + fs:label(0.50,0.325, "Balance: " .. balance) + fs:field(0.75,1.750, 3,1, "recipient", "Send to:", "") + fs:field(0.75,3.000, 3,1, "amount", "Amount", "") + + fs:button(0,4.25, 2,1, "logout", "Log Out") + fs:button(2,4.25, 2,1, "send", "Send") + else + fs:button(0,4, 2,1, "logout", "Back") + fs:label(0.5,0.5, "You don't have an account.") end - - -- To prevent duplicates - return fs .. field(-100, -100, 0,0, "trans_id", "", unique()) .. - label(0.5,0.5, "Balance: " .. balance) .. - field(0.5,1.5, 2,1, "recipient", "Send to:", "") .. - field(0.5,2.5, 2,1, "amount", "Amount", "") .. - "button[2,4;2,1;send;Send]" end -local function send_fs(p_name, receiver, amt_str) - local fs = "size[7,3]" +local function send_fs(fs, p_name, receiver, amt_str) + fs:size(10,3) + + fs:button(4,2, 2,1, "wire", "Back") local amt = tonumber(amt_str) + local msg = nil if not amt or amt <= 0 then - return fs .. label(0.5,0.5, "Invalid transfer amount.") .. - "button[0.5,2;2,1;wire;Back]" + msg = "Invalid transfer amount." + else + local succ, err = exchange:transfer_credits(p_name, receiver, amt) + + if not succ then + msg = "Error: " .. err + else + msg = "Successfully sent " .. amt .. + " credits to " .. receiver .. "." + end end - local succ, err = exchange:transfer_credits(p_name, receiver, amt) - - if not succ then - return fs .. label(0.5,0.5, "Error: " .. err) .. - "button[0.5,2;2,1;wire;Back]" - end - return fs.. label(0.5,0.5, "Successfully sent " .. - amt .. " credits to " .. receiver) .. - "button[0.5,2;2,1;wire;Back]" + fs:label(0.5,0.5, msg) end -local function log_fs(p_name) - local res = { - "size[8,8]label[0,0;Transaction Log]button[0,7;2,1;logout;Log Out]", - "tablecolumns[text;text]", - "table[0,1;8,6;log_table;Time,Message", - } +local function log_fs(fs, p_name) + fs:size(14,8) - for i, entry in ipairs(exchange:player_log(p_name)) do - i = i*4 - res[i] = "," - res[i+1] = tostring(entry.Time) - res[i+2] = "," - res[i+3] = entry.Message + fs:label(0,0, "Transaction Log") + + fs("tablecolumns[text;text]") + fs("table[0,0.75;13.75,6.75;log_table;Time,Message") + + for _, entry in ipairs(exchange:player_log(p_name)) do + fs(",", formlib.escape(entry.Time), ",", formlib.escape(entry.Message)) end - res[#res+1] ="]" - return table.concat(res) + fs("]") + + fs:button(6,7.5, 2,1, "logout", "Log Out") +end + + +local function main_menu_fs(fs, p_name) + fs:size(6,2) + fs:button(0.50,0.125, 2.5,1, "info", "Account Info") + fs:button(3.00,0.125, 2.5,1, "wire", "Wire Monies") + fs:button(0.50,1.125, 5.0,1, "transaction_log", "Transaction Log") end local trans_ids = {} +local function show_atm_form(fs_fn, p_name, ...) + local fs = formlib.Builder() + fs_fn(fs, p_name, ...) + minetest.show_formspec(p_name, atm_form, tostring(fs)) +end + + minetest.register_on_player_receive_fields(function(player, formname, fields) if formname ~= atm_form then return end if fields.quit then return true end @@ -129,24 +125,15 @@ minetest.register_on_player_receive_fields(function(player, formname, fields) trans_ids[p_name] = this_id if fields.logout then - minetest.show_formspec(p_name, atm_form, main_menu) - 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 - - if fields.transaction_log then - minetest.show_formspec(p_name, atm_form, log_fs(p_name)) + show_atm_form(main_menu_fs, p_name) + elseif fields.info then + show_atm_form(info_fs, p_name) + elseif fields.wire then + show_atm_form(wire_fs, p_name) + elseif fields.send then + show_atm_form(send_fs, p_name, fields.recipient, fields.amount) + elseif fields.transaction_log then + show_atm_form(log_fs, p_name) end return true @@ -180,16 +167,16 @@ minetest.register_node("global_exchange:atm_bottom", { selection_box = { type = "fixed", fixed = { - {-0.5, -0.5, -0.5, 0.5, 0.5, 0.5}, - {-0.5, 0.5, -0.5, -0.375, 1.125, -0.25}, - {0.375, 0.5, -0.5, 0.5, 1.125, -0.25}, - {-0.5, 0.5, -0.25, 0.5, 1.5, 0.5}, - {-0.5, 1.125, -0.4375, -0.375, 1.25, -0.25}, - {0.375, 1.125, -0.4375, 0.5, 1.25, -0.25}, - {-0.5, 1.25, -0.375, -0.375, 1.375, -0.25}, - {0.375, 1.25, -0.375, 0.5, 1.375, -0.25}, - {-0.5, 1.375, -0.3125, -0.375, 1.5, -0.25}, - {0.375, 1.375, -0.3125, 0.5, 1.5, -0.25}, + {-0.500, -0.500, -0.5000, 0.500, 0.500, 0.50}, + {-0.500, 0.500, -0.5000, -0.375, 1.125, -0.25}, + { 0.375, 0.500, -0.5000, 0.500, 1.125, -0.25}, + {-0.500, 0.500, -0.2500, 0.500, 1.500, 0.50}, + {-0.500, 1.125, -0.4375, -0.375, 1.250, -0.25}, + { 0.375, 1.125, -0.4375, 0.500, 1.250, -0.25}, + {-0.500, 1.250, -0.3750, -0.375, 1.375, -0.25}, + { 0.375, 1.250, -0.3750, 0.500, 1.375, -0.25}, + {-0.500, 1.375, -0.3125, -0.375, 1.500, -0.25}, + { 0.375, 1.375, -0.3125, 0.500, 1.500, -0.25}, }, }, on_place = function(itemstack, placer, pointed_thing) @@ -233,7 +220,7 @@ minetest.register_node("global_exchange:atm_bottom", { groups = {cracky=2, atm = 1}, on_rightclick = function(pos, _, clicker) minetest.sound_play("atm_beep", {pos = pos, gain = 0.3, max_hear_distance = 5}) - minetest.show_formspec(clicker:get_player_name(), atm_form, main_menu) + show_atm_form(main_menu_fs, clicker:get_player_name()) end, }) @@ -254,15 +241,15 @@ minetest.register_node("global_exchange:atm_top", { node_box = { type = "fixed", fixed = { - {-0.5, -0.5, -0.5, -0.375, 0.125, -0.25}, - {0.375, -0.5, -0.5, 0.5, 0.125, -0.25}, - {-0.5, -0.5, -0.25, 0.5, 0.5, 0.5}, - {-0.5, 0.125, -0.4375, -0.375, 0.25, -0.25}, - {0.375, 0.125, -0.4375, 0.5, 0.25, -0.25}, - {-0.5, 0.25, -0.375, -0.375, 0.375, -0.25}, - {0.375, 0.25, -0.375, 0.5, 0.375, -0.25}, - {-0.5, 0.375, -0.3125, -0.375, 0.5, -0.25}, - {0.375, 0.375, -0.3125, 0.5, 0.5, -0.25}, + {-0.500, -0.500, -0.5000, -0.375, 0.125, -0.25}, + { 0.375, -0.500, -0.5000, 0.500, 0.125, -0.25}, + {-0.500, -0.500, -0.2500, 0.500, 0.500, 0.50}, + {-0.500, 0.125, -0.4375, -0.375, 0.250, -0.25}, + { 0.375, 0.125, -0.4375, 0.500, 0.250, -0.25}, + {-0.500, 0.250, -0.3750, -0.375, 0.375, -0.25}, + { 0.375, 0.250, -0.3750, 0.500, 0.375, -0.25}, + {-0.500, 0.375, -0.3125, -0.375, 0.500, -0.25}, + { 0.375, 0.375, -0.3125, 0.500, 0.500, -0.25}, } }, selection_box = { @@ -278,10 +265,11 @@ minetest.register_node("global_exchange:atm_top", { minetest.register_craft( { output = "global_exchange:atm", recipe = { - { "default:stone", "default:stone", "default:stone" }, + { "default:stone", "default:stone", "default:stone" }, { "default:stone", "default:gold_ingot", "default:stone" }, - { "default:stone", "default:stone", "default:stone" }, + { "default:stone", "default:stone", "default:stone" }, } }) minetest.register_alias("global_exchange:atm", "global_exchange:atm_bottom") +-- vim:set ts=4 sw=4 noet: diff --git a/digital_mailbox.lua b/digital_mailbox.lua index 5610be7..f333245 100644 --- a/digital_mailbox.lua +++ b/digital_mailbox.lua @@ -1,60 +1,65 @@ - -local exchange = ... +local exchange, formlib = ... local mailbox_form = "global_exchange:digital_mailbox" local mailbox_contents = {} local selected_index = {} --- Map from player names to their most recent search result +-- Map from player names to their most recent search result local function get_mail(p_name) local mail_maybe = mailbox_contents[p_name] - if mail_maybe then - return mail_maybe - else - mailbox_contents[p_name] = {} - return mailbox_contents[p_name] + + if not mail_maybe then + local _,res = exchange:view_inbox(p_name) + mail_maybe = res or {} + mailbox_contents[p_name] = mail_maybe + selected_index[p_name] = math.min(selected_index[p_name] or 0, #mail_maybe) end + + return mail_maybe end -local function mk_inbox_list(results, x, y, w, h) - local res = { - "textlist[", - tostring(x), - ",", - tostring(y), - ";", - tostring(w), - ",", - tostring(h), - ";result_list;" - } +local function wear_string(wear) + return "-" .. math.ceil(100 * wear / 65535) .. "%" +end + +local function mk_inbox_list(fs, results, x, y, w, h) + fs("textlist[", x, ",", y, ";", w, ",", h, ";result_list;") + + local sep = nil for i, row in ipairs(results) do - res[i*2+8] = row.Amount .. " " .. row.Item - res[i*2+9] = "," + fs(sep) + fs:escape(row.Amount, " ", row.Item) + if row.Wear > 0 then + fs:escape(" (", wear_string(row.Wear), ")") + end + sep = "," end - res[#res+1] = "]" - return table.concat(res) + fs("]") end -local function mk_mail_fs(p_name, results, err_str) - fs = "size[6,8]" .. - "label[0,0;Inbox]" +local function mk_mail_fs(fs, p_name, results, err_str) + fs:size(8,8) + fs:label(0,0, "Inbox") + if err_str then - fs = fs .. "label[3,0;Error: " .. err_str .. "]" + fs:label(3,0, "Error: " .. err_str) end - return fs .. mk_inbox_list(results, 0, 1, 6, 6) .. - "button[0,7;2,1;claim;Claim]" + mk_inbox_list(fs, results, 0, 1, 7.75, 6.25) + + fs:button(3,7.35, 2,1, "claim", "Claim") end -local function show_mail(p_name, results, err_str) - minetest.show_formspec(p_name, mailbox_form, mk_mail_fs(p_name, results, err_str)) +local function show_mail(p_name, err_str) + local fs = formlib.Builder() + mk_mail_fs(fs, p_name, get_mail(p_name), err_str) + minetest.show_formspec(p_name, mailbox_form, tostring(fs)) end @@ -63,35 +68,6 @@ minetest.register_on_player_receive_fields(function(player, formname, fields) if fields.quit then return true end local p_name = player:get_player_name() - local idx = selected_index[p_name] - - if fields.claim - and idx then - local row = get_mail(p_name)[idx] - - if row then - local stack = ItemStack(row.Item) - stack:set_count(row.Amount) - - local p_inv = player:get_inventory() - if not p_inv:room_for_item("main", stack) then - show_mail(p_name, get_mail(p_name), "Not enough room.") - return true - end - - local succ, res = exchange:take_inbox(row.Id, row.Amount) - if not succ then - show_mail(p_name, get_mail(p_name), res) - end - - stack:set_count(res) - - p_inv:add_item("main", stack) - - table.remove(get_mail(p_name), idx) - show_mail(p_name, get_mail(p_name)) - end - end if fields.result_list then local event = minetest.explode_textlist_event(fields.result_list) @@ -101,22 +77,48 @@ minetest.register_on_player_receive_fields(function(player, formname, fields) end end + if fields.claim then + local idx = selected_index[p_name] + local row = get_mail(p_name)[idx] + + if row then + local stack = ItemStack(row.Item) + stack:set_count(row.Amount) + stack:set_wear(row.Wear) + + local p_inv = player:get_inventory() + local leftover = p_inv:add_item("main", stack) + local took_amount = row.Amount - leftover:get_count() + + mailbox_contents[p_name] = nil + + local succ, res = exchange:take_inbox(row.Id, took_amount) + if succ then + show_mail(p_name) + else + show_mail(p_name, res) + end + end + end + return true end) minetest.register_node("global_exchange:mailbox", { description = "Digital Mailbox", - tiles = {"global_exchange_box.png", + tiles = { + "global_exchange_box.png", "global_exchange_box.png", "global_exchange_box.png^global_exchange_mailbox_side.png", }, + is_ground_content = false, + stack_max = 1, groups = {cracky=2}, on_rightclick = function(pos, node, clicker) local p_name = clicker:get_player_name() - local _,res = exchange:view_inbox(p_name) - mailbox_contents[p_name] = res - minetest.show_formspec(p_name, mailbox_form, mk_mail_fs(p_name, res)) + mailbox_contents[p_name] = nil + show_mail(p_name) end, }) @@ -125,7 +127,8 @@ minetest.register_craft( { output = "global_exchange:mailbox", recipe = { { "default:stone", "default:gold_ingot", "default:stone" }, - { "default:stone", "default:chest", "default:stone" }, - { "default:stone", "default:stone", "default:stone" }, + { "default:stone", "default:chest", "default:stone" }, + { "default:stone", "default:stone", "default:stone" }, } }) +-- vim:set ts=4 sw=4 noet: diff --git a/exchange.lua b/exchange.lua index 316b10a..e01249d 100644 --- a/exchange.lua +++ b/exchange.lua @@ -3,6 +3,17 @@ local insecure_env = ... local sql = insecure_env.require("lsqlite3") local exports = {} +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)({}) + local init_query = [=[ BEGIN TRANSACTION; CREATE TABLE if not exists Credit @@ -27,6 +38,7 @@ CREATE TABLE if not exists Orders Type TEXT NOT NULL CHECK(Type IN ("buy", "sell")), Time INTEGER NOT NULL, Item TEXT NOT NULL, + Wear INTEGER NOT NULL CHECK(Wear >= 0 AND Wear <= 65535), Amount INTEGER NOT NULL CHECK(Amount > 0), Rate INTEGER NOT NULL CHECK(Rate > 0) ); @@ -36,20 +48,21 @@ CREATE TABLE if not exists Inbox Id INTEGER PRIMARY KEY AUTOINCREMENT, Recipient TEXT NOT NULL, Item TEXT NOT NULL, + Wear INTEGER NOT NULL CHECK(Wear >= 0 AND Wear <= 65535), 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); +ON Orders (Exchange, Type, Item, Rate, Wear, Time); + +CREATE INDEX if not exists index_own_orders +ON Orders (Poster, Time); CREATE INDEX if not exists index_inbox -ON Inbox (Recipient); +ON Inbox (Recipient, Item, Wear); CREATE VIEW if not exists distinct_items AS SELECT DISTINCT Item FROM Orders; @@ -83,128 +96,142 @@ FROM distinct_items; END TRANSACTION; ]=] - local new_act_query = [=[ INSERT INTO Credit (Owner, Balance) -VALUES (:owner,:start_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 = ?; +SET Balance = :new_balance +WHERE Owner = :p_name; ]] - local log_query = [[ INSERT INTO Log (Recipient, Time, Message) -VALUES(?, ?, ?); +VALUES(:recipient, :time, :message); ]] - -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); +INSERT INTO Orders (Poster, Exchange, Type, Time, Item, Wear, Amount, Rate) +VALUES (:p_name, :ex_name, :order_type, :time, :item_name, :wear, :amount, :rate); ]=] - local del_order_query = [=[ DELETE FROM Orders WHERE Id = ?; ]=] - local reduce_order_query = [=[ UPDATE Orders -SET Amount = Amount - ? -WHERE Id = ?; +SET Amount = Amount - :delta +WHERE Id = :id; ]=] +local get_order_query = [=[ +SELECT * FROM Orders +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 refund_order_query = [=[ -UPDATE Credit -SET Balance = Balance + coalesce(( - SELECT sum(Rate * Amount) FROM Orders - WHERE Poster = :p_name - AND Type = "buy" - AND Id = :id - ), 0) -WHERE Owner = :p_name; -]=] - - -local search_asc_query = [=[ +local search_bids_query = [=[ SELECT * FROM Orders WHERE Exchange = :ex_name -AND Type = :order_type +AND Type = "buy" AND Item = :item_name -ORDER BY Rate ASC; +ORDER BY Rate ASC, Wear DESC; ]=] -local search_min_query = [=[ +local search_asks_query = [=[ SELECT * FROM Orders WHERE Exchange = :ex_name -AND Type = :order_type +AND Type = "sell" +AND Item = :item_name +ORDER BY Rate DESC, Wear ASC; +]=] + +local qual_bids_query = [=[ +SELECT * FROM Orders +WHERE Exchange = :ex_name +AND Type = "buy" AND Item = :item_name AND Rate >= :rate_min -ORDER BY Rate DESC; +AND Wear >= :wear_min +ORDER BY Rate DESC, Time ASC; ]=] - -local search_max_query = [=[ +local qual_asks_query = [=[ SELECT * FROM Orders WHERE Exchange = :ex_name -AND Type = :order_type +AND Type = "sell" AND Item = :item_name AND Rate <= :rate_max -ORDER BY Rate ASC; +AND Wear <= :wear_max +ORDER BY Rate ASC, Time ASC; ]=] - local search_own_query = [=[ SELECT * FROM Orders -WHERE Poster = :p_name; +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; +]=] + +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; +]=] local insert_inbox_query = [=[ -INSERT INTO Inbox(Recipient, Item, Amount) -VALUES(?, ?, ?); +INSERT INTO Inbox(Recipient, Item, Wear, Amount) +VALUES(:p_name, :item_name, :wear, :amount); ]=] +local add_inbox_query = [=[ +UPDATE Inbox +SET Amount = Amount + :change +WHERE Id = :id; +]=] local view_inbox_query = [=[ SELECT * FROM Inbox -WHERE Recipient = ?; +WHERE Recipient = ? +ORDER BY Item ASC, Wear ASC; ]=] +local search_inbox_query = [=[ +SELECT * FROM Inbox +WHERE Recipient = :p_name +AND Item = :item_name +AND Wear = :wear; +]=] local get_inbox_query = [=[ -SELECT Amount FROM Inbox +SELECT Amount, Wear FROM Inbox WHERE Id = :id; ]=] @@ -225,7 +252,7 @@ SELECT * FROM market_summary; local transaction_log_query = [=[ SELECT Time, Message FROM Log -WHERE Recipient = :p_name +WHERE Recipient = ? ORDER BY Time DESC; ]=] @@ -239,8 +266,9 @@ local function sql_error(err) end -local function is_integer(num) - return num%1 == 0 +local function is_integer(x) + local num = tonumber(x) + return num and math.floor(num) == num end @@ -270,32 +298,37 @@ function exports.open_exchange(path) 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)), - refund_order_stmt = assert(db:prepare(refund_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)), - summary_stmt = assert(db:prepare(summary_query)), + 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)), transaction_log_stmt = assert(db:prepare(transaction_log_query)), } - local ret = { db = db, - stmts = stmts, + local ret = { + db = db, + stmts = stmts, } setmetatable(ret, ex_meta) @@ -318,7 +351,11 @@ function ex_methods.log(self, message, recipient) local db = self.db local stmt = self.stmts.log_stmt - stmt:bind_values(recipient, os.time(), message) + stmt:bind_names({ + recipient = recipient, + time = os.time(), + message = message, + }) local res = stmt:step() stmt:reset() @@ -351,9 +388,9 @@ function ex_methods.new_account(self, p_name, amt) local stmt = self.stmts.new_act_stmt stmt:bind_names({ - owner = p_name, + owner = p_name, start_balance = amt, - time = os.time(), + time = os.time(), }) local res = stmt:step() @@ -411,6 +448,7 @@ function ex_methods.get_balance(self, p_name) end stmt:reset() + return nil end @@ -425,7 +463,11 @@ function ex_methods.set_balance(self, p_name, new_bal) return false, p_name .. " does not have an account." end - set_stmt:bind_values(new_bal, p_name) + set_stmt:bind_names({ + p_name = p_name, + new_balance = new_bal, + }) + local res = set_stmt:step() if res == sqlite3.ERROR then @@ -445,7 +487,7 @@ 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 + if not is_integer(delta) then error("Non-integer credit delta") end @@ -533,26 +575,68 @@ function ex_methods.give_credits(self, p_name, amt, msg) 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 + + -- 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_asc_stmt + stmt = self.stmts.search_bids_stmt else - stmt = self.stmts.search_desc_stmt + stmt = self.stmts.search_asks_stmt end stmt:bind_names({ - ex_name = ex_name, - order_type = order_type, + ex_name = ex_name, item_name = item_name, }) - local orders,n = {},1 + local orders = {} for tab in stmt:nrows() do - orders[n] = tab - n = n+1 + table.insert(orders, tab) end stmt:reset() @@ -564,13 +648,12 @@ end function ex_methods.search_player_orders(self, p_name) local stmt = self.stmts.search_own_stmt - stmt:bind_names({p_name = p_name}) + stmt:bind_values(p_name) - local orders,n = {},1 + local orders = {} for tab in stmt:nrows() do - orders[n] = tab - n = n+1 + table.insert(orders, tab) end stmt:reset() @@ -579,34 +662,35 @@ 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 +function ex_methods.add_order(self, p_name, ex_name, order_type, item_name, wear, amount, rate) + if not is_integer(amount) then return false, "Noninteger quantity" - end - - if amount <= 0 then + elseif amount <= 0 then return false, "Nonpositive quantity" - end - - if math.floor(rate) ~= rate then + elseif not is_integer(rate) then return false, "Noninteger rate" + elseif rate <= 0 then + return false, "Nonpositive rate" + elseif not is_integer(wear) then + return false, "Noninteger wear" + elseif wear < 0 or wear > 65535 then + return false, "Invalid wear" end - if rate <= 0 then - return false, "Nonpositive rate" - end + order_book_cache(ex_name)[item_name] = nil local db = self.db local stmt = self.stmts.add_order_stmt stmt:bind_names({ - p_name = p_name, - ex_name = ex_name, + p_name = p_name, + ex_name = ex_name, order_type = order_type, - time = os.time(), - item_name = item_name, - amount = amount, - rate = rate, + time = os.time(), + item_name = item_name, + wear = wear, + amount = amount, + rate = rate, }) local res = stmt:step() @@ -624,35 +708,43 @@ end -- Returns true, or false and an error message. -function ex_methods.cancel_order(self, p_name, id, order_type, item_name, amount, rate) - local params = { p_name = p_name, - id = id, +function ex_methods.cancel_order(self, p_name, id) + local params = { + p_name = p_name, + id = id, } local db = self.db db:exec("BEGIN TRANSACTION;") - local refund_stmt = self.stmts.refund_order_stmt - local cancel_stmt = self.stmts.cancel_order_stmt + local get_stmt = self.stmts.get_order_stmt + get_stmt:bind_values(id) + local res = get_stmt:step() + local order - local ref_succ, ref_err = exec_stmt(db, refund_stmt, params) - if not ref_succ then - db:exec("ROLLBACK") - return false, ref_err + if res == sqlite3.ERROR then + sql_error(db:errmsg()) + elseif res == sqlite3.MISUSE then + error("Programmer error.") + elseif res == sqlite3.ROW then + order = get_stmt:get_named_values() + get_stmt:reset() + else + db:exec("ROLLBACK;") + return false, "No such order." end + order_book_cache(order.Exchange)[order.Item] = nil + + local cancel_stmt = self.stmts.cancel_order_stmt local canc_succ, canc_err = exec_stmt(db, cancel_stmt, params) if not canc_succ then - db:exec("ROLLBACK") + db:exec("ROLLBACK;") return false, canc_err end local message = "Cancelled an order to " .. - order_type .. " " .. amount .. " " .. item_name .. "." - - if order_type == "buy" then - message = message .. " (+" .. amount * rate .. ")" - end + order.Type .. " " .. order.Amount .. " " .. order.Item .. "." local succ, err = self:log(message, p_name) if not succ then @@ -662,20 +754,64 @@ function ex_methods.cancel_order(self, p_name, id, order_type, item_name, amount db:exec("COMMIT;") - return true + return true, order 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) +function ex_methods.put_in_inbox(self, p_name, item_name, wear, amount) local db = self.db - local stmt = self.stmts.insert_inbox_stmt + local search_stmt = self.stmts.search_inbox_stmt - stmt:bind_values(p_name, item_name, amount) + 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;") + return false, "Database Busy." + elseif res == sqlite3.ROW then + row = search_stmt:get_named_values() + elseif res ~= sqlite3.DONE then + sql_error(db:errmsg()) + end + + 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 local res = stmt:step() + if res == sqlite3.BUSY then + stmt:reset() + db:exec("ROLLBACK;") return false, "Database Busy." elseif res ~= sqlite3.DONE then sql_error(db:errmsg()) @@ -683,6 +819,8 @@ function ex_methods.put_in_inbox(self, p_name, item_name, amount) stmt:reset() + db:exec("COMMIT;") + return true end @@ -690,7 +828,7 @@ 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) +function ex_methods.buy(self, p_name, ex_name, item_name, wear, amount, rate) if not is_integer(amount) then return false, "Noninteger quantity" elseif amount <= 0 then @@ -699,146 +837,141 @@ function ex_methods.buy(self, p_name, ex_name, item_name, amount, rate) return false, "Noninteger rate" elseif rate <= 0 then return false, "Nonpositive rate" + elseif not is_integer(wear) then + return false, "Noninteger wear" + elseif wear < 0 or wear > 65535 then + return false, "Invalid wear" end local db = self.db - local bal = self:get_balance(p_name) - - if not bal then - return false, "Nonexistent account." - end - - if bal < amount * rate then - return false, "Not enough money." - end - - db:exec("BEGIN TRANSACTION"); + local balance = self:get_balance(p_name) + + if not balance then + db:exec("ROLLBACK;") + return false, p_name .. " does not have an account." + end + + local bought = {} 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 + local search_stmt = self.stmts.qual_asks_stmt search_stmt:bind_names({ - ex_name = ex_name, - order_type = "sell", + ex_name = ex_name, item_name = item_name, - rate_max = rate, + rate_max = rate, + wear_max = wear, }) for row in search_stmt:nrows() do - local poster = row.Poster + local poster = row.Poster + local row_wear = row.Wear local row_amount = row.Amount + local row_rate = row.Rate + local row_bought = math.min(row_amount, remaining) - 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 + if poster ~= p_name then + local can_afford = math.floor(balance / row_rate) + row_bought = math.min(row_bought, can_afford) + -- asking prices can only increase from here + if row_bought == 0 then break end end + 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 + del_stmt:bind_values(row.Id) + red_del_stmt = del_stmt + end + + 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;") + return false, "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 + search_stmt:reset() + db:exec("ROLLBACK;") + return false, ch_err + end + + local ch_succ, ch_err = self:change_balance(p_name, -cost) + 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_amount .. " " .. + item_name .. " from you. (+" .. cost .. ")", + poster) + 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_amount .. " " .. item_name .. + " from " .. poster .. ". (-" .. cost .. ")", + 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("Bought " .. row_amount .. " " .. + item_name .. " from yourself.", + p_name) + if not log_succ then + search_stmt:reset() + db:exec("ROLLBACK;") + return false, log_err + end + end + + order_book_cache(ex_name)[item_name] = nil + + table.insert(bought, { amount = row_bought, wear = row_wear }) + + remaining = remaining - row_bought + 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) + self:add_order(p_name, ex_name, "buy", item_name, wear, remaining, rate) if not add_succ then db:exec("ROLLBACK;") @@ -846,11 +979,9 @@ function ex_methods.buy(self, p_name, ex_name, item_name, amount, rate) end local log_succ, log_err = - self:log("Posted buy offer for " - .. remaining .. " " .. item_name .. " at " - .. rate .. "/item (-" - .. remaining * rate .. ")", p_name) - + 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 @@ -865,7 +996,7 @@ 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) +function ex_methods.sell(self, p_name, ex_name, item_name, wear, amount, rate) if not is_integer(amount) then return false, "Noninteger quantity" elseif amount <= 0 then @@ -874,6 +1005,10 @@ function ex_methods.sell(self, p_name, ex_name, item_name, amount, rate) return false, "Noninteger rate" elseif rate <= 0 then return false, "Nonpositive rate" + elseif not is_integer(wear) then + return false, "Noninteger wear" + elseif wear < 0 or wear > 65535 then + return false, "Invalid wear" end local db = self.db @@ -881,133 +1016,136 @@ function ex_methods.sell(self, p_name, ex_name, item_name, amount, rate) db:exec("BEGIN TRANSACTION"); local remaining = amount - local revenue = 0 local del_stmt = self.stmts.del_order_stmt local red_stmt = self.stmts.reduce_order_stmt - local search_stmt = self.stmts.search_min_stmt + local search_stmt = self.stmts.qual_bids_stmt search_stmt:bind_names({ - ex_name = ex_name, - order_type = "buy", + ex_name = ex_name, item_name = item_name, - rate_min = rate, + rate_min = rate, + wear_min = wear, }) for row in search_stmt:nrows() do - local poster = row.Poster + local poster = row.Poster local row_amount = row.Amount + local row_rate = row.Rate + local row_sold = math.min(row_amount, remaining) - if row_amount <= remaining then - del_stmt:bind_values(row.Id) + if poster ~= p_name then + local bal = self:get_balance(poster) - local del_res = del_stmt:step() - if del_res == sqlite3.BUSY then - del_stmt:reset() + if not bal then 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 + return false, poster .. " does not have an account." 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 - revenue = revenue + row_amount * row.Rate - 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 - - revenue = revenue + remaining * row.Rate - remaining = 0 + local can_afford = math.floor(bal / row_rate) + row_sold = math.min(row_sold, can_afford) end - if remaining == 0 then break end + if row_sold > 0 then + local red_del_stmt + + if row_sold < row_amount then + red_stmt:bind_names({ + id = row.Id, + delta = row_sold, + }) + red_del_stmt = red_stmt + else -- row_sold == row_amount + 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;") + return false, "Database Busy." + elseif red_del_res ~= sqlite3.DONE then + search_stmt:reset() + sql_error(db:errmsg()) + end + + 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 + + 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 + search_stmt:reset() + db:exec("ROLLBACK;") + return false, ch_err + end + + local ch_succ, ch_err = self:change_balance(p_name, revenue) + 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 .. " sold " .. row_amount .. " " .. + item_name .. " to you. (-" .. revenue .. ")", + poster) + if not log_succ then + search_stmt:reset() + db:exec("ROLLBACK;") + return false, log_err + end + + local log_succ, log_err = + self:log("Sold " .. row_amount .. " " .. 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_amount .. " " .. + item_name .. " to yourself.", + p_name) + if not log_succ then + search_stmt:reset() + db:exec("ROLLBACK;") + return false, log_err + end + end + + order_book_cache(ex_name)[item_name] = nil + + remaining = remaining - row_sold + + if remaining == 0 then break end + end end search_stmt:reset() - 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) + self:add_order(p_name, ex_name, "sell", item_name, wear, remaining, rate) if not add_succ then db:exec("ROLLBACK;") return false, add_err end - end db:exec("COMMIT;") @@ -1023,11 +1161,10 @@ function ex_methods.view_inbox(self, p_name) stmt:bind_values(p_name) - local res,n = {},1 + local res = {} for row in stmt:nrows() do - res[n] = row - n = n+1 + table.insert(res, row) end stmt:reset() @@ -1044,10 +1181,7 @@ function ex_methods.take_inbox(self, id, amount) local red_stmt = self.stmts.red_inbox_stmt local del_stmt = self.stmts.del_inbox_stmt - get_stmt:bind_names({ - id = id, - change = amount - }) + get_stmt:bind_names({ id = id }) local res = get_stmt:step() @@ -1066,44 +1200,34 @@ function ex_methods.take_inbox(self, id, amount) db:exec("BEGIN TRANSACTION;") + local red_del_stmt + if available > amount then red_stmt:bind_names({ - id = id, + 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() + red_del_stmt = red_stmt 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() + del_stmt:bind_values(id) + red_del_stmt = del_stmt end + local red_del_res = red_del_stmt:step() + + if red_del_res == sqlite3.BUSY then + red_del_stmt:reset() + db:exec("ROLLBACK;") + return false, "Database Busy." + elseif red_del_res ~= sqlite3.DONE then + sql_error(db:errmsg()) + end + + red_del_stmt:reset() + db:exec("COMMIT;") return true, math.min(amount, available) - end @@ -1116,16 +1240,15 @@ end function ex_methods.market_summary(self) local stmt = self.stmts.summary_stmt - local res,n = {},1 + local res = {} for a in stmt:rows() do - res[n] = { - item_name = a[1], - buy_volume = a[2], - buy_max = a[3], + table.insert(res, { + item_name = a[1], + buy_volume = a[2], + buy_max = a[3], sell_volume = a[4], - sell_min = a[5], - } - n = n+1 + sell_min = a[5], + }) end stmt:reset() @@ -1136,13 +1259,12 @@ end -- 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_names({ p_name = p_name }) + stmt:bind_values(p_name) - local res,n = {},1 + local res = {} for row in stmt:nrows() do - res[n] = row - n = n+1 + table.insert(res, row) end stmt:reset() @@ -1199,12 +1321,12 @@ function exports.test() -- 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) + local succ, err = ex:buy("Alice", "", "default:cobble", 32767, 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) + local succ, err = ex:sell("Bob", "", "default:cobble", 32767, 20, 1) print("Success: ", succ, " ", err) print_balances() @@ -1213,3 +1335,4 @@ end return exports +-- vim:set ts=4 sw=4 noet: diff --git a/exchange_machine.lua b/exchange_machine.lua index 341f43d..0e75cea 100644 --- a/exchange_machine.lua +++ b/exchange_machine.lua @@ -1,35 +1,38 @@ +local exchange, formlib = ... -local exchange = ... local search_cooldown = 2 local summary_interval = 600 +local global_inv = nil + +local function is_integer(x) + return math.floor(x) == x +end + local summary_fs = "" local function mk_summary_fs() - local res = { - "size[8,8]", - "label[0,0;Updated Periodically]", - "tablecolumns[text;text;text;text;text;text]", - "table[0,1;8,6;summary_table;", - "Item,Description,Buy Vol,Buy Max,Sell Vol,Sell Min" - } + local fs = formlib.Builder() + + fs("tablecolumns[text;text;text;text;text;text]") + fs("table[0,0;11.75,9;summary_table;") + fs("Item,Description,Buy Vol,Buy Max,Sell Vol,Sell Min") local all_items = minetest.registered_items for i, row in ipairs(exchange:market_summary()) do - local n = #res+1 - res[n] = "," .. row.item_name local def = all_items[row.item_name] or {} - res[n+1] = "," .. (def.description or "Unknown Item") - res[n+2] = "," .. (row.buy_volume or 0) - res[n+3] = "," .. (row.buy_max or "N/A") - res[n+4] = "," .. (row.sell_volume or 0) - res[n+5] = "," .. (row.sell_min or "N/A") + fs(",", formlib.escape(row.item_name)) + fs(",", formlib.escape(def.description or "Unknown Item")) + fs(",", formlib.escape(row.buy_volume or 0)) + fs(",", formlib.escape(row.buy_max or "N/A")) + fs(",", formlib.escape(row.sell_volume or 0)) + fs(",", formlib.escape(row.sell_min or "N/A")) end - res[#res+1] = "]" - res[#res+1] = "button[3,7;2,1;back;Back]" + fs("]") - summary_fs = table.concat(res) + summary_fs = tostring(fs) end + minetest.after(0, mk_summary_fs) @@ -42,26 +45,38 @@ minetest.register_globalstep(function(dtime) end end) -local summary_form = "global_exchange:summary" -local function show_summary(p_name) - minetest.show_formspec(p_name, summary_form, summary_fs) + +local wear_levels = { + [1] = { index = 1, text = "New (-0%)", wear = math.floor(0.00*65535) }, + [2] = { index = 2, text = "Good (-10%)", wear = math.floor(0.10*65535) }, + [3] = { index = 3, text = "Worn (-50%)", wear = math.floor(0.50*65535) }, + [4] = { index = 4, text = "Junk (-100%)", wear = math.floor(1.00*65535) }, +} + +-- Allow lookup by text label as well as index +for _,v in ipairs(wear_levels) do + wear_levels[tostring(v.text)] = v end +local function wear_string(wear) + return "-" .. math.ceil(100 * wear / 65535) .. "%" +end + + 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 +-- ^ A per-player state for the main form. minetest.register_on_joinplayer(function(player) exchange:new_account(player:get_player_name()) --just to make sure main_state[player:get_player_name()] = { - old_fields = {}, - search_results = {}, - last_search_time = 0, + tab = 1, + buy_item = "", + buy_wear = wear_levels[1].text, + buy_price = "", + buy_amount = "1", + sell_price = "", + buy_page = 1, + selected_index = 0, } end) @@ -70,268 +85,293 @@ minetest.register_on_leaveplayer(function(player) end) +-- Something similar to creative inventory +local pagemax = 1 +local pagewidth = 12 +local pageheight = 4 +local pageitems = pagewidth * pageheight +local selectable_list = {} + +-- Create inventory list after loading all mods +minetest.after(0, function() + for name, def in pairs(minetest.registered_items) do + if (def.groups.not_in_creative_inventory or 0) == 0 and + (def.description or "") ~= "" then + selectable_list[#selectable_list + 1] = name + end + end + table.sort(selectable_list) + + pagemax = math.max(math.ceil(#selectable_list / pageitems), 1) +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) - fs_tab[#fs_tab+1] = str - end - - fs_tab = { - tablecolumns, - "table[" .. x .. "," .. y .. ";" .. w .. "," .. h .. ";", - "result_table;", - "Poster,Type,Item,Description,Amount,Rate" - } +local function table_from_results(fs, results, name, x, y, w, h, selected) + fs("tablecolumns[text;text;text;text;text;text;text]") + fs("table[", x, ",", y, ";", w, ",", h, ";") + fs(formlib.escape(name), ";") + fs("Poster,Type,Item,Description,Wear,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(",") local def = all_items[row.Item] or {} - insert(def.description or "Unknown Item") - insert(",") - insert(tostring(row.Amount)) - insert(",") - insert(tostring(row.Rate)) + fs(",", formlib.escape(row.Poster)) + fs(",", formlib.escape(row.Type)) + fs(",", formlib.escape(row.Item)) + fs(",", formlib.escape(def.description or "Unknown Item")) + if row.Wear > 0 then + fs(",", formlib.escape("-" .. math.ceil(100 * row.Wear / 65535) .. "%")) + else + fs(",---") + end + fs(",", formlib.escape(row.Amount)) + fs(",", formlib.escape(row.Rate)) end - if selected and selected ~= "" then - insert(";") - insert(selected) - end - insert("]") - - return table.concat(fs_tab) + local sel_num = math.max(0, tonumber(selected) or 0) + fs(";", sel_num + 1, "]") end +local function mk_main_market_fs(fs, p_name, state) + fs(summary_fs) +end -local function mk_main_fs(p_name, new_item, err_str, success) +local function mk_main_order_book_fs(fs, p_name, x, y, w, h, item_name) + local order_book = exchange:order_book("", item_name) + + fs("tablecolumns[text;text;text;text]") + fs("table[", x, ",", y, ";", w, ",", h, ";", "order_book;") + fs("Type,Rate,Wear,Amount") + + for _,row in ipairs(order_book) do + fs(",", formlib.escape(row.Type)) + fs(",", formlib.escape(row.Rate)) + if row.Wear > 0 then + fs(",", formlib.escape(wear_string(row.Wear))) + else + fs(",---") + end + fs(",", formlib.escape(row.Amount)) + end + + fs(";1]") +end + +local function mk_main_buy_fs(fs, p_name, state) + mk_main_order_book_fs(fs, p_name, 0, 0, 8.75, 3.75, state.buy_item) + + fs:item_image_button(9,0, 1,1, "buy_item", state.buy_item) + + fs:field(10.25,0.40, 2,1, "buy_amount", "Quantity", state.buy_amount, false) + + local wear = wear_levels[state.buy_wear] or wear_levels[1] + fs("dropdown[9,1;3;buy_wear;") + local sep = nil + for _,v in ipairs(wear_levels) do + if sep then fs(sep) end + fs:escape(v.text) + sep = "," + end + fs(";", wear.index, "]") + + fs:field(9.35,2.40, 2.9,1, "buy_price", "Bid (ea.)", state.buy_price, false) + + fs:button(9,3, 3,1, "buy", "Place Bid") + + fs:container(0,4, function() + fs:button( 0,0.25, 1,1, "buy_left", "<<") + fs:button( 5,0.25, 2,1, "position", state.buy_page .. "/" .. pagemax) + fs:button(11,0.25, 1,1, "buy_right", ">>") + + local firstitem = ((state.buy_page - 1) * pageitems) + for y=0,(pageheight-1) do + for x=0,(pagewidth-1) do + local index = firstitem + (pagewidth * y) + x + 1 + if selectable_list[index] then + fs:item_image_button(x,1.25+y, 1,1, "select_" .. index, + selectable_list[index]) + end + end + end + end) +end + +local function mk_main_sell_fs(fs, p_name, state) + local sell_stack = global_inv:get_stack("p_" .. p_name, 1) + local sell_item = (not sell_stack:is_empty() + and sell_stack:get_name()) or "" + + mk_main_order_book_fs(fs, p_name, 0, 0, 8.75, 3.75, sell_item) + + fs:list(9,0, 1,1, "detached:global_exchange", "p_" .. p_name) + + fs:field(9.35,2.40, 2.9,1, "sell_price", "Ask (ea.)", state.sell_price, false) + + fs:button(9,3, 3,1, "sell", "Sell") + + fs:box(1.9375,5.1875, 7.96875,4.03, "#00000020") + + fs:list(2,5.25, 8,4, "current_player", "main") +end + +local function mk_main_own_orders_fs(fs, p_name, state) + if not state.own_results then + state.own_results = exchange:search_player_orders(p_name) or {} + end + + state.selected_index = math.min(state.selected_index or 0, #state.own_results) + + table_from_results(fs, state.own_results, "result_table", 0, 0, 11.75, 8.5, state.selected_index) + fs:button(4.5,8.5, 3,1, "cancel", "Cancel Order") +end + +local main_tabs = { + [1] = { text = "Market", mk_fs = mk_main_market_fs }, + [2] = { text = "Buy", mk_fs = mk_main_buy_fs }, + [3] = { text = "Sell", mk_fs = mk_main_sell_fs }, + [4] = { text = "My Orders", mk_fs = mk_main_own_orders_fs }, +} + +local function mk_main_fs(fs, p_name, err_str, success) local state = main_state[p_name] if not state then return end -- Should have been initialized on player join + + fs:size(12,10) + fs:bgcolor("#606060", false) - 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 "" + fs("tabheader[0,0.65;tab;") + local sep = nil + for _,tab in ipairs(main_tabs) do + if sep then fs(sep) end + fs:escape(tab.text) + sep = "," + end + fs(";", state.tab or 1, ";false;true]") local bal = exchange:get_balance(p_name) - - local fs - if bal then - fs = "label[0,0;Balance: " .. bal .. "]" - else - fs = "label[0.2,0.5;Use an ATM to make your account.]" - end - - fs = fs .. "button[4,0;2,1;summary;Market Summary]" .. - "button[6,0;2,1;your_orders;Your Orders]" .. - "field[0.2,1.5;3,1;item;Item: ;" .. item_def .. "]" .. - "field[3.2,1.5;3,1;amount;Amount: ;" .. amount_def .. "]" .. - "button[6,1;2,1.4;select_item;Select Item]" .. - "checkbox[5,3;sell;Sell;" .. tostring(sell_def) .. "]" .. - "field[0.2,2.5;2,1;rate;Rate: ;" .. rate_def .. "]" .. - "button[2,2;2,1.4;search;Search]" .. - "button[4,2;3,1.4;post_order;Post Order]" + fs:label(0,0.37, "Balance: " .. bal) if err_str then - fs = fs .. "label[0,3;Error: " .. err_str .. "]" + fs:label(4,0.37, err_str) + elseif success then + fs:label(4,0.37, "Success!") end - if success then - fs = fs .. "label[0,3;Success!]" + if main_tabs[state.tab] then + fs:container(0,1, main_tabs[state.tab].mk_fs, p_name, state) end - - return "size[8,9]" .. fs .. table_from_results(results, 0, 4, 8, 5, selected_def) 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)) +local function show_main(p_name, err_str, success) + local fs = formlib.Builder() + mk_main_fs(fs, p_name, err_str, success) + minetest.show_formspec(p_name, main_form, tostring(fs)) end --- Something similar to creative inventory -local pagemax = 1 - --- 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, _, to_list, _,_, 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, _, _, _, _, _, 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,n = {},1 - 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 - selectable_list[n] = name - n = n+1 - 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 - - pagemax = math.ceil((#selectable_list - 1) / (8 * 4)) -end) - - minetest.register_on_joinplayer(function(player) - -- the inventory list name is "p_"..player_name - minetest.get_inventory({ - type="detached", - name="global_exchange" - }):set_size("p_" .. player:get_player_name(), 1) + -- the inventory list name (for selling) is "p_"..player_name + global_inv:set_size("p_" .. player:get_player_name(), 1) end) -local select_form = "global_exchange:select_form" - - -local function mk_select_formspec(p_name, start_i, pagenum) - 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(math.floor(pagenum)).."/"..tostring(pagemax).."]".. - "list[detached:global_exchange;p_" .. p_name .. ";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) - return "size[8,8]" .. - "label[0.5,0.2;Your Orders]" .. - "button[6,0;2,1;refresh;Refresh]" .. - table_from_results(results, 0, 2, 8, 4.5, selected or "") .. - "button[0,7;2,1;cancel;Cancel]" .. - "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 function post_buy(player, ex_name, item_name, wear_str, amount_str, rate_str) local p_name = player:get_player_name() - if item_name == "" then + if (item_name or "") == "" then return false, "You must input an item" - end - - if not minetest.registered_items[item_name] then + elseif not minetest.registered_items[item_name] then return false, "That item does not exist." end + local wear_level = wear_levels[wear_str] + if not wear_level then + return false, "Invalid wear." + end + local amount = tonumber(amount_str) - local rate = tonumber(rate_str) + local rate = tonumber(rate_str) - if not amount then + if not amount or not is_integer(amount) or amount < 1 then return false, "Invalid amount." - end - - if not rate then + elseif not rate or not is_integer(rate) or rate < 1 then return false, "Invalid rate." end - if amount > 1000 then - return false, "Max amount is 1000" - 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) + local succ, res = exchange:buy(p_name, ex_name, item_name, wear_level.wear, amount, rate) + if not succ then + return false, res end + for _,row in ipairs(res) do + stack:set_count(row.amount) + stack:set_wear(row.wear) + + local leftover = p_inv:add_item("main", stack) + + -- Put anything that won't fit in the inventory in the player's inbox + if not leftover:is_empty() then + exchange:put_in_inbox(p_name, item_name, row.wear, leftover:get_count()) + end + end + + -- Refresh market summary "soonish" + elapsed = math.max(elapsed, summary_interval - 5) + + return true +end + +-- Returns success, and also returns an error message if failed. +-- The item to sell is determined by the player's list in global_inv. +local function post_sell(player, ex_name, rate_str) + local p_name = player:get_player_name() + local stack = global_inv:get_stack("p_" .. p_name, 1) + + if not stack or stack:is_empty() then + return false, "You must input an item" + elseif not minetest.registered_items[stack:get_name()] then + return false, "That item does not exist." + end + + if stack.get_meta then + local meta = stack:get_meta() + local def_meta = ItemStack(stack:get_name()):get_meta() + + if not stack:get_meta():equals(def_meta) then + return false, "Cannot sell an item with metadata." + end + elseif (stack:get_metadata() or "") ~= "" then + return false, "Cannot sell an item with metadata." + end + + local rate = tonumber(rate_str) + + if not rate or not is_integer(rate) or rate < 1 then + return false, "Invalid rate." + end + + local item_name = stack:get_name() + local wear = stack:get_wear() + local amount = stack:get_count() + + local succ, res = exchange:sell(p_name, ex_name, item_name, wear, amount, rate) + if not succ then + return false, res + end + + stack:clear() + global_inv:set_stack("p_" .. p_name, 1, stack) + + -- Refresh market summary "soonish" + elapsed = math.max(elapsed, summary_interval - 5) + return true end @@ -339,185 +379,113 @@ end local function handle_main(player, fields) 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 + local copy_fields = { + "buy_wear", + "buy_amount", + "buy_price", + "sell_price" + } + for _,k in ipairs(copy_fields) do + if fields[k] then + state[k] = fields[k] + end 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 - 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 - + if fields.tab then + state.tab = tonumber(fields.tab) or 1 show_main(p_name) end + if fields.buy_left then + state.buy_page = (((state.buy_page or 1) + ((2*pagemax-1) - 1)) % pagemax) + 1 + show_main(p_name) + end + + if fields.buy_right then + state.buy_page = (((state.buy_page or 1) + ((2*pagemax-1) + 1)) % pagemax) + 1 + show_main(p_name) + end + + for name in pairs(fields) do + local index = tonumber(string.match(name, "select_([0-9]+)")) + if index and index >= 1 and index < #selectable_list then + state.buy_item = selectable_list[index] + show_main(p_name) + end + end + + if fields.buy then + local succ, err = + post_buy(player, "", state.buy_item, fields.buy_wear, + fields.buy_amount, fields.buy_price) + if succ then + state.buy_amount = "1" + state.buy_price = "" + state.own_results = nil + show_main(p_name, nil, true) + else + show_main(p_name, err) + end + end + if fields.sell then - state.sell = fields.sell == "true" - 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 - end - - local order_type - if state.sell then - order_type = "sell" - else - order_type = "buy" - end - - local succ, err = - post_order(player, "", order_type, fields.item, fields.amount, fields.rate) - + local succ, err = post_sell(player, "", fields.sell_price) if succ then - state.search_results = {} - show_main(p_name, nil, nil, true) + state.sell_price = "" + state.own_results = nil + show_main(p_name, nil, true) else - show_main(p_name, nil, err) + show_main(p_name, 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 - 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.summary then - show_summary(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 -end - - -local function handle_select(player, fields) - local p_name = player:get_player_name() - - 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 -end - - -local function handle_own_orders(player, fields) - 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 + local own_results = state.own_results or {} - 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] - if not row then return true end - local p_inv = player:get_inventory() - - local amount = row.Amount - local item = row.Item - local stack = ItemStack(item) - stack:set_count(amount) - if row.Type == "sell" then - if not p_inv:room_for_item("main", stack) then - show_own_orders(p_name, state.own_results, "Not enough room.") - return true - end - end - - local succ, err = - exchange:cancel_order(p_name, row.Id, row.Type, row.Item, row.Amount, row.Rate) + if fields.cancel and own_results[idx] then + local succ, res = exchange:cancel_order(p_name, own_results[idx].Id) if succ then - table.remove(results, idx) - if row.Type == "sell" then - p_inv:add_item("main", stack) + if res.Type == "sell" then + local p_inv = player:get_inventory() + local stack = ItemStack(res.Item) + stack:set_count(res.Amount) + stack:set_wear(res.Wear) + local leftover = p_inv:add_item("main", stack) + + -- Put anything that won't fit in the inventory in the player's inbox + if not leftover:is_empty() then + exchange:put_in_inbox(p_name, res.Item, res.Wear, leftover:get_count()) + end end - else - -- Refresh the results, since there might have been a problem. - state.own_results = exchange:search_player_orders(p_name) or {} + + -- Refresh market summary "soonish" + elapsed = math.max(elapsed, summary_interval - 5) end - show_own_orders(p_name, state.own_results) + state.own_results = nil + show_main(p_name) 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, event.row) + show_main(p_name) end end - if fields.back then - show_main(p_name) - end -end + if fields.quit then + -- Return the player's unsold inventory, if any + local stack = global_inv:get_stack("p_" .. p_name, 1) + local p_inv = player:get_inventory() + local leftover = p_inv:add_item("main", stack) + -- Whatever doesn't fit in the player's inventory stays in the form. + -- Note that any items in the form when the server exits are lost. + global_inv:set_stack("p_" .. p_name, 1, leftover) -local function handle_summary(player, fields) - if fields.back then - show_main(player:get_player_name()) + state.own_results = nil end end @@ -525,12 +493,6 @@ end minetest.register_on_player_receive_fields(function(player, formname, fields) if formname == main_form then handle_main(player, fields) - elseif formname == select_form then - handle_select(player, fields) - elseif formname == own_form then - handle_own_orders(player, fields) - elseif formname == summary_form then - handle_summary(player, fields) else return end @@ -538,6 +500,37 @@ minetest.register_on_player_receive_fields(function(player, formname, fields) end) +global_inv = minetest.create_detached_inventory("global_exchange", { + allow_move = function(inv,from_list,from_index,to_list,to_index,count,player) + return 0 + end, + allow_put = function(inv,to_list,to_index,stack,player) + local p_name = player:get_player_name() + + if to_list == "p_" .. p_name then + return stack:get_count() + else + return 0 + end + end, + allow_take = function(inv,from_list,from_index,stack,player) + local p_name = player:get_player_name() + + if from_list == "p_" .. p_name then + return stack:get_count() + else + return 0 + end + end, + on_put = function(inv,to_list,to_index,stack,player) + show_main(player:get_player_name()) + end, + on_take = function(inv,from_list,from_index,stack,player) + show_main(player:get_player_name()) + end, +}) + + minetest.register_node("global_exchange:exchange", { description = "Exchange Terminal", drawtype = "nodebox", @@ -552,18 +545,19 @@ minetest.register_node("global_exchange:exchange", { paramtype = "light", paramtype2 = "facedir", groups = {cracky=2}, + is_ground_content = false, stack_max = 1, light_source = 3, node_box = { type = "fixed", fixed = { - {-8/16, -4/16, 3/16, 0.5, 0.5, 5/16},--screens - {-1/16, -7/16, 5/16, 1/16, 5/16, 7/16},--screen leg - {-3/16, -8/16, 4/16, 3/16, -7/16, 8/16},--leg platform + {-8/16, -4/16, 3/16, 8/16, 8/16, 5/16},--screens + {-1/16, -7/16, 5/16, 1/16, 5/16, 7/16},--screen leg + {-3/16, -8/16, 4/16, 3/16, -7/16, 8/16},--leg platform {-7/16, -8/16, -8/16, 2/16, -6/16, -3/16},--keyboard - {3/16, -8/16, -3/16, 7/16, -7/16, 3/16},--phone low - {4/16, -7/16, -1/16, 6/16, -6/16, 3/16},--phone hi - {2/16, -7/16, 0, 8/16, -5/16, 2/16},--phone speaker + { 3/16, -8/16, -3/16, 7/16, -7/16, 3/16},--phone low + { 4/16, -7/16, -1/16, 6/16, -6/16, 3/16},--phone hi + { 2/16, -7/16, 0/16, 8/16, -5/16, 2/16},--phone speaker } }, on_rightclick = function(_, _, clicker) @@ -581,8 +575,9 @@ minetest.register_node("global_exchange:exchange", { minetest.register_craft( { output = "global_exchange:exchange", recipe = { - { "default:steel_ingot", "default:steel_ingot", "default:steel_ingot" }, - { "default:mese_crystal", "default:steel_ingot", "default:diamond" }, - { "default:steel_ingot", "default:steel_ingot", "default:steel_ingot" }, + { "default:steel_ingot", "default:steel_ingot", "default:steel_ingot" }, + { "default:mese_crystal", "default:steel_ingot", "default:diamond" }, + { "default:steel_ingot", "default:steel_ingot", "default:steel_ingot" }, } }) +-- vim:set ts=4 sw=4 noet: diff --git a/formlib.lua b/formlib.lua new file mode 100644 index 0000000..cb318c0 --- /dev/null +++ b/formlib.lua @@ -0,0 +1,92 @@ +local formlib = {} +local builder_methods = {} + +function formlib.escape(x) + return minetest.formspec_escape(tostring(x or "")) +end + +function builder_methods.escape(fs, ...) + for _,x in ipairs({...}) do + if x then fs(formlib.escape(x)) end + end +end + +function builder_methods.size(fs, w,h, fixed) + if fixed == false then + fs("size[", w, ",", h, ",false]") + elseif fixed then + fs("size[", w, ",", h, ",true]") + else + fs("size[", w, ",", h, "]") + end +end + +function builder_methods.bgcolor(fs, color, fullscreen) + if fullscreen == false then + fs("bgcolor[", formlib.escape(color), ";false]") + elseif fullscreen then + fs("bgcolor[", formlib.escape(color), ";true]") + else + fs("bgcolor[", formlib.escape(color), "]") + end +end + +function builder_methods.list(fs, x,y, w,h, inv_loc, inv_list, start_idx) + fs("list[", formlib.escape(inv_loc), ";", formlib.escape(inv_list), ";", + x, ",", y, ";", w, ",", h, ";", formlib.escape(start_idx), "]") +end + +function builder_methods.button(fs, x,y, w,h, name, text) + fs("button[", x, ",", y, ";", w, ",", h, ";", + formlib.escape(name), ";", formlib.escape(text), "]") +end + +function builder_methods.item_image_button(fs, x,y, w,h, name, item, text) + fs("item_image_button[", x, ",", y, ";", w, ",", h, ";", + formlib.escape(item), ";", formlib.escape(name), ";", + formlib.escape(text), "]") +end + +function builder_methods.label(fs, x,y, text) + fs("label[", x, ",", y, ";", formlib.escape(text), "]") +end + +function builder_methods.field(fs, x,y, w,h, name, label, default, close_on_enter) + fs("field[", x, ",", y, ";", w, ",", h, ";", + formlib.escape(name), ";", formlib.escape(label), ";", + formlib.escape(default), "]") + + if close_on_enter == false then + fs("field_close_on_enter[", formlib.escape(name), ";false]") + end +end + +function builder_methods.container(fs, x,y, sub_fn, ...) + fs("container[", x, ",", y, "]") + sub_fn(fs, ...) + fs("container_end[]") +end + +function builder_methods.box(fs, x,y, w,h, color) + fs("box[", x, ",", y, ";", w, ",", h, ";", formlib.escape(color), "]") +end + +local builder_meta = { + __metatable = "protected", + __index = builder_methods, + __call = function(fs, ...) + for _,x in ipairs({...}) do + if x then table.insert(fs, tostring(x)) end + end + end, + __tostring = table.concat, +} + +function formlib.Builder() + local fs = {} + setmetatable(fs, builder_meta) + return fs +end + +return formlib +-- vim:set ts=4 sw=4 noet: diff --git a/init.lua b/init.lua index 460040d..b889ac6 100644 --- a/init.lua +++ b/init.lua @@ -1,53 +1,43 @@ - local insecure_env = minetest.request_insecure_environment() -assert(insecure_env, - "global_exchange needs to be trusted to run under mod security.") +assert(insecure_env, "global_exchange needs to be trusted to run under mod security.") local modpath = minetest.get_modpath(minetest.get_current_modname()) .. "/" -local income = tonumber(minetest.setting_get("citizens_income")) or 10 -local income_interval = 1200 -local income_msg = "You receive your citizen's income (+" .. income .. ")" - -local next_payout = os.time() + income_interval - -local exchange = - assert(loadfile(modpath .. "exchange.lua"))(insecure_env).open_exchange( - minetest.get_worldpath() .. "/global_exchange.db" -) +local exchange = assert(loadfile(modpath .. "exchange.lua"))(insecure_env). + open_exchange(minetest.get_worldpath() .. "/global_exchange.db") +local formlib = assert(loadfile(modpath .. "formlib.lua"))() minetest.register_on_shutdown(function() - exchange:close() + exchange:close() end) - -local function check_giving() - local now = os.time() - if now < next_payout then - return - end - - next_payout = now + income_interval - - for _, 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 - - minetest.after(5, check_giving) +local function handle_setbalance_command(caller, name, newbalance) + return exchange:set_balance(name, newbalance) end -minetest.after(5, check_giving) +minetest.register_privilege("balance", { + description = "Can use /setbalance", + give_to_singleplayer = false +}) +minetest.register_chatcommand("setbalance", { + params = "[] ", + description = "set a player's trading balance", + privs = {balance=true}, + func = function(caller, param) + local name, balancestr = string.match(param, "([^ ]+) ([0-9]+)") + if not name or not balancestr then + name = caller + balancestr = string.match(param, "([0-9]+)") + if not balancestr then + return false, "Invalid parameters (see /help setbalance)" + end + end + return handle_setbalance_command(caller, name, tonumber(balancestr)) + end, +}) - -assert(loadfile(modpath .. "atm.lua"))(exchange) -assert(loadfile(modpath .. "exchange_machine.lua"))(exchange) -assert(loadfile(modpath .. "digital_mailbox.lua"))(exchange) +assert(loadfile(modpath .. "atm.lua"))(exchange, formlib) +assert(loadfile(modpath .. "exchange_machine.lua"))(exchange, formlib) +assert(loadfile(modpath .. "digital_mailbox.lua"))(exchange, formlib)