Electronic trading exchange in Minetest
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

625 lines
17 KiB

  1. local exchange, formlib = ...
  2. local search_cooldown = 2
  3. local summary_interval = 600
  4. local global_inv = nil
  5. local S = minetest.get_translator("global_exchange")
  6. -- NALC split() function
  7. local function split(str, sep)
  8. if not str then return nil end
  9. local result = {}
  10. local regex = ("([^%s]+)"):format(sep)
  11. for each in str:gmatch(regex) do
  12. if #each > 30 then
  13. each = string.sub(each, 1, 30).."..."
  14. end
  15. table.insert(result, each)
  16. end
  17. return result
  18. end
  19. local function is_integer(x)
  20. return math.floor(x) == x
  21. end
  22. local function wear_string(wear)
  23. if wear > 0 then
  24. return "-" .. math.ceil(100 * wear / 65535) .. "%"
  25. else
  26. return "----"
  27. end
  28. end
  29. local summary_fs = ""
  30. local function mk_summary_fs()
  31. local fs = formlib.Builder()
  32. fs:tablecolumns("text", "text", "text", "text", "text", "text", "text")
  33. fs:table(0,0, 11.75,9, "summary_table", function(add_row)
  34. add_row("Item",
  35. "Description",
  36. S("Wear"),
  37. S("Buy Vol"),
  38. S("Buy Max"),
  39. S("Sell Vol"),
  40. S("Sell Min"))
  41. local all_items = minetest.registered_items
  42. for i, row in ipairs(exchange:market_summary()) do
  43. local def = all_items[row.Item] or {}
  44. add_row(row.Item,
  45. split(def.description, "\n")[1] or S("Unknown Item"),
  46. wear_string(row.Wear),
  47. row.Buy_Volume or 0,
  48. row.Buy_Max or "N/A",
  49. row.Sell_Volume or 0,
  50. row.Sell_Min or "N/A")
  51. end
  52. end)
  53. summary_fs = tostring(fs)
  54. end
  55. minetest.after(0, mk_summary_fs)
  56. local elapsed = 0
  57. minetest.register_globalstep(function(dtime)
  58. elapsed = elapsed + dtime
  59. if elapsed >= summary_interval then
  60. mk_summary_fs()
  61. elapsed = 0
  62. end
  63. end)
  64. local wear_levels = {
  65. [1] = { index = 1, text = S("New (-0%)"), wear = math.floor(0.00*65535) },
  66. [2] = { index = 2, text = S("Good (-10%)"), wear = math.floor(0.10*65535) },
  67. [3] = { index = 3, text = S("Worn (-50%)"), wear = math.floor(0.50*65535) },
  68. [4] = { index = 4, text = S("Junk (-100%)"), wear = math.floor(1.00*65535) },
  69. }
  70. -- Allow lookup by text label as well as index
  71. for _,v in ipairs(wear_levels) do
  72. wear_levels[tostring(v.text)] = v
  73. end
  74. local main_state = {}
  75. -- ^ A per-player state for the main form.
  76. minetest.register_on_joinplayer(function(player)
  77. exchange:new_account(player:get_player_name()) --just to make sure
  78. main_state[player:get_player_name()] = {
  79. tab = 1,
  80. buy_item = "",
  81. buy_wear = wear_levels[1].text,
  82. buy_price = "",
  83. buy_amount = "1",
  84. buy_filter = "",
  85. sell_price = "",
  86. buy_page = 1,
  87. buy_pagemax = 0,
  88. selected_index = 0,
  89. filtered_list = nil,
  90. }
  91. end)
  92. minetest.register_on_leaveplayer(function(player)
  93. main_state[player:get_player_name()] = nil
  94. end)
  95. -- Something similar to creative inventory
  96. local pagemax = 1
  97. local pagewidth = 12
  98. local pageheight = 4
  99. local pageitems = pagewidth * pageheight
  100. local selectable_list = {}
  101. -- Create inventory list after loading all mods
  102. minetest.after(0, function()
  103. for name, def in pairs(minetest.registered_items) do
  104. if (def.groups.not_in_creative_inventory or 0) == 0 and
  105. (split(def.description, "\n")[1] or "") ~= "" then
  106. selectable_list[#selectable_list + 1] = name
  107. end
  108. end
  109. table.sort(selectable_list)
  110. pagemax = math.max(math.ceil(#selectable_list / pageitems), 1)
  111. end)
  112. local function filter_list(filter)
  113. local filtered_list = {}
  114. if not filter or filter == "" then return nil, pagemax end
  115. for index, name in ipairs(selectable_list) do
  116. local match = string.match(name, filter) or nil
  117. if match then
  118. filtered_list[#filtered_list+1] = name
  119. end
  120. end
  121. return filtered_list, math.max(math.ceil(#filtered_list / pageitems), 1)
  122. end
  123. local main_form = "global_exchange:exchange_main"
  124. local function table_from_results(fs, results, name, x, y, w, h, selected)
  125. fs:tablecolumns("text", "text", "text", "text", "text", "text", "text")
  126. fs:table(x,y, w,h, name, function(add_row)
  127. add_row(S("Poster"), "Type", "Item",
  128. "Description",
  129. S("Wear"), S("Qty"), S("Rate"))
  130. local all_items = minetest.registered_items
  131. for i, row in ipairs(results) do
  132. local def = all_items[row.Item] or {}
  133. add_row(row.Poster, row.Type, row.Item,
  134. split(def.description, "\n")[1] or S("Unknown Item"),
  135. wear_string(row.Wear), row.Amount, row.Rate)
  136. end
  137. end, math.max(0, tonumber(selected) or 0) + 1)
  138. end
  139. local function mk_main_market_fs(fs, p_name, state)
  140. fs(summary_fs)
  141. end
  142. local function mk_main_order_book_fs(fs, p_name, x, y, w, h, item_name)
  143. local order_book = exchange:order_book("", item_name)
  144. fs:tablecolumns("text", "text", "text", "text")
  145. fs:table(x,y, w,h, "order_book", function(add_row)
  146. add_row("Type", S("Rate"), S("Wear"), S("Qty"))
  147. for _,row in ipairs(order_book) do
  148. add_row(row.Type, row.Rate, wear_string(row.Wear), row.Amount)
  149. end
  150. end, 1)
  151. end
  152. local function mk_main_buy_fs(fs, p_name, state)
  153. mk_main_order_book_fs(fs, p_name, 0, 0, 8.75, 3.75, state.buy_item)
  154. fs:item_image_button(9,0, 1,1, "buy_item", state.buy_item)
  155. fs:field(10.25,0.40, 2,1, "buy_amount", S("Quantity"), state.buy_amount, false)
  156. local wear = wear_levels[state.buy_wear] or wear_levels[1]
  157. fs:dropdown(9,1, 3, "buy_wear", function(add_item)
  158. for _,v in ipairs(wear_levels) do
  159. add_item(v.text)
  160. end
  161. end, wear.index)
  162. fs:field(9.35,2.40, 2.9,1, "buy_price", S("Bid (ea.)"), state.buy_price, false)
  163. fs:button(9,3, 3,1, "buy", S("Place Bid"))
  164. fs:container(0,4, function()
  165. fs:button( 0,0.25, 1,1, "buy_left", "<<")
  166. fs:button( 1,0.25, 2,1, "position", state.buy_page .. "/" .. state.buy_pagemax)
  167. fs:button(3,0.25, 1,1, "buy_right", ">>")
  168. fs:field(5.28,0.55, 3,1, "buy_filter", S("Filter"), state.buy_filter, false)
  169. fs:button(8, 0.25, 2,1, "filter", S("Search"))
  170. fs:button(10, 0.25, 2,1, "reset_filter", S("Reset"))
  171. local firstitem = ((state.buy_page - 1) * pageitems)
  172. local item_list = state.filtered_list or selectable_list
  173. for y=0,(pageheight-1) do
  174. for x=0,(pagewidth-1) do
  175. local index = firstitem + (pagewidth * y) + x + 1
  176. if item_list[index] then
  177. fs:item_image_button(x,1.25+y, 1,1, "select_" .. index, item_list[index])
  178. end
  179. end
  180. end
  181. end)
  182. end
  183. local function mk_main_sell_fs(fs, p_name, state)
  184. local sell_stack = global_inv:get_stack("p_" .. p_name, 1)
  185. local sell_item = (not sell_stack:is_empty()
  186. and sell_stack:get_name()) or ""
  187. mk_main_order_book_fs(fs, p_name, 0, 0, 8.75, 3.75, sell_item)
  188. fs:list(9,0, 1,1, "detached:global_exchange", "p_" .. p_name)
  189. fs:field(9.35,2.40, 2.9,1, "sell_price", S("Ask (ea.)"), state.sell_price, false)
  190. fs:button(9,3, 3,1, "sell", S("Sell"))
  191. fs:box(1.9375,5.1875, 7.96875,4.03, "#00000020")
  192. fs:list(2,5.25, 8,4, "current_player", "main")
  193. end
  194. local function mk_main_own_orders_fs(fs, p_name, state)
  195. if not state.own_results then
  196. state.own_results = exchange:search_player_orders(p_name) or {}
  197. end
  198. state.selected_index = math.min(state.selected_index or 0, #state.own_results)
  199. table_from_results(fs, state.own_results, "result_table", 0, 0, 11.75, 8.5, state.selected_index)
  200. fs:button(4.5,8.5, 3,1, "cancel", S("Cancel Order"))
  201. end
  202. local main_tabs = {
  203. [1] = { text = S("Market"), mk_fs = mk_main_market_fs },
  204. [2] = { text = S("Buy"), mk_fs = mk_main_buy_fs },
  205. [3] = { text = S("Sell"), mk_fs = mk_main_sell_fs },
  206. [4] = { text = S("My Orders"), mk_fs = mk_main_own_orders_fs },
  207. }
  208. local function mk_main_fs(fs, p_name, err_str, success)
  209. local state = main_state[p_name]
  210. if not state then return end -- Should have been initialized on player join
  211. fs:size(12,10)
  212. fs:bgcolor("#606060", false)
  213. fs:tabheader(0,0.65, "tab", function(add_tab)
  214. for _,tab in ipairs(main_tabs) do
  215. add_tab(tab.text)
  216. end
  217. end, state.tab or 1, false, true)
  218. local bal = exchange:get_balance(p_name)
  219. fs:label(0,0.37, "Balance: " .. bal)
  220. if err_str then
  221. fs:label(4,0.37, err_str)
  222. elseif success then
  223. fs:label(4,0.37, "Success!")
  224. end
  225. if main_tabs[state.tab] then
  226. fs:container(0,1, main_tabs[state.tab].mk_fs, p_name, state)
  227. end
  228. end
  229. local function show_main(p_name, err_str, success)
  230. local fs = formlib.Builder()
  231. mk_main_fs(fs, p_name, err_str, success)
  232. minetest.show_formspec(p_name, main_form, tostring(fs))
  233. end
  234. minetest.register_on_joinplayer(function(player)
  235. -- the inventory list name (for selling) is "p_"..player_name
  236. global_inv:set_size("p_" .. player:get_player_name(), 1)
  237. end)
  238. -- Returns success, and also returns an error message if failed.
  239. local function post_buy(player, ex_name, item_name, wear_str, amount_str, rate_str)
  240. local p_name = player:get_player_name()
  241. if (item_name or "") == "" then
  242. return false, S("You must input an item")
  243. elseif not minetest.registered_items[item_name] then
  244. return false, S("That item does not exist.")
  245. end
  246. local wear_level = wear_levels[wear_str]
  247. if not wear_level then
  248. return false, S("Invalid wear.")
  249. end
  250. local amount = tonumber(amount_str)
  251. local rate = tonumber(rate_str)
  252. if not amount or not is_integer(amount) or amount < 1 then
  253. return false, S("Invalid amount.")
  254. elseif not rate or not is_integer(rate) or rate < 1 then
  255. return false, S("Invalid rate.")
  256. end
  257. local p_inv = player:get_inventory()
  258. local stack = ItemStack(item_name)
  259. local succ, res = exchange:buy(p_name, ex_name, item_name, wear_level.wear, amount, rate)
  260. if not succ then
  261. return false, res
  262. end
  263. for _,row in ipairs(res) do
  264. stack:set_count(row.amount)
  265. stack:set_wear(row.wear)
  266. local leftover = p_inv:add_item("main", stack)
  267. -- Put anything that won't fit in the inventory in the player's inbox
  268. if not leftover:is_empty() then
  269. exchange:put_in_inbox(p_name, item_name, row.wear, leftover:get_count())
  270. end
  271. end
  272. -- Refresh market summary "soonish"
  273. elapsed = math.max(elapsed, summary_interval - 5)
  274. return true
  275. end
  276. -- Returns success, and also returns an error message if failed.
  277. -- The item to sell is determined by the player's list in global_inv.
  278. local function post_sell(player, ex_name, rate_str)
  279. local p_name = player:get_player_name()
  280. local stack = global_inv:get_stack("p_" .. p_name, 1)
  281. if not stack or stack:is_empty() then
  282. return false, S("You must input an item")
  283. elseif not minetest.registered_items[stack:get_name()] then
  284. return false, S("That item does not exist.")
  285. end
  286. if stack.get_meta then
  287. local meta = stack:get_meta()
  288. local def_meta = ItemStack(stack:get_name()):get_meta()
  289. if not stack:get_meta():equals(def_meta) then
  290. return false, S("Cannot sell an item with metadata.")
  291. end
  292. elseif (stack:get_metadata() or "") ~= "" then
  293. return false, S("Cannot sell an item with metadata.")
  294. end
  295. local rate = tonumber(rate_str)
  296. if not rate or not is_integer(rate) or rate < 1 then
  297. return false, S("Invalid rate.")
  298. end
  299. local item_name = stack:get_name()
  300. local wear = stack:get_wear()
  301. local amount = stack:get_count()
  302. local succ, res = exchange:sell(p_name, ex_name, item_name, wear, amount, rate)
  303. if not succ then
  304. return false, res
  305. end
  306. stack:clear()
  307. global_inv:set_stack("p_" .. p_name, 1, stack)
  308. -- Refresh market summary "soonish"
  309. elapsed = math.max(elapsed, summary_interval - 5)
  310. return true
  311. end
  312. local function handle_main(player, fields)
  313. local p_name = player:get_player_name()
  314. local state = main_state[p_name]
  315. local copy_fields = {
  316. "buy_wear",
  317. "buy_amount",
  318. "buy_price",
  319. "buy_filter",
  320. "sell_price"
  321. }
  322. for _,k in ipairs(copy_fields) do
  323. if fields[k] then
  324. state[k] = fields[k]
  325. end
  326. end
  327. if state.buy_pagemax == 0 then
  328. state.buy_pagemax = pagemax
  329. end
  330. if fields.tab then
  331. state.tab = tonumber(fields.tab) or 1
  332. show_main(p_name)
  333. end
  334. if fields.buy_left then
  335. state.buy_page = (((state.buy_page or 1) + ((2*state.buy_pagemax-1) - 1)) % state.buy_pagemax) + 1
  336. show_main(p_name)
  337. end
  338. if fields.buy_right then
  339. state.buy_page = (((state.buy_page or 1) + ((2*state.buy_pagemax-1) + 1)) % state.buy_pagemax) + 1
  340. show_main(p_name)
  341. end
  342. local item_list = state.filtered_list or selectable_list
  343. for name in pairs(fields) do
  344. local index = tonumber(string.match(name, "select_([0-9]+)"))
  345. if index and index >= 1 and index <= #item_list then
  346. state.buy_item = item_list[index]
  347. show_main(p_name)
  348. end
  349. end
  350. if fields.buy then
  351. local succ, err =
  352. post_buy(player, "", state.buy_item, fields.buy_wear,
  353. fields.buy_amount, fields.buy_price)
  354. if succ then
  355. state.buy_amount = "1"
  356. state.buy_price = ""
  357. state.own_results = nil
  358. show_main(p_name, nil, true)
  359. else
  360. show_main(p_name, err)
  361. end
  362. end
  363. if fields.filter then
  364. state.filtered_list, state.buy_pagemax = filter_list(state.buy_filter)
  365. state.buy_page = 1
  366. show_main(p_name)
  367. end
  368. if fields.reset_filter then
  369. state.buy_filter = ""
  370. state.filtered_list, state.buy_pagemax = filter_list(nil)
  371. state.buy_page = 1
  372. show_main(p_name)
  373. end
  374. if fields.sell then
  375. local succ, err = post_sell(player, "", fields.sell_price)
  376. if succ then
  377. state.sell_price = ""
  378. state.own_results = nil
  379. show_main(p_name, nil, true)
  380. else
  381. show_main(p_name, err)
  382. end
  383. end
  384. local idx = state.selected_index
  385. local own_results = state.own_results or {}
  386. if fields.cancel and own_results[idx] then
  387. local succ, res = exchange:cancel_order(p_name, own_results[idx].Id)
  388. if succ then
  389. if res.Type == "sell" then
  390. local p_inv = player:get_inventory()
  391. local stack = ItemStack(res.Item)
  392. stack:set_count(res.Amount)
  393. stack:set_wear(res.Wear)
  394. local leftover = p_inv:add_item("main", stack)
  395. -- Put anything that won't fit in the inventory in the player's inbox
  396. if not leftover:is_empty() then
  397. exchange:put_in_inbox(p_name, res.Item, res.Wear, leftover:get_count())
  398. end
  399. end
  400. -- Refresh market summary "soonish"
  401. elapsed = math.max(elapsed, summary_interval - 5)
  402. end
  403. state.own_results = nil
  404. show_main(p_name)
  405. end
  406. if fields.result_table then
  407. local event = minetest.explode_table_event(fields.result_table)
  408. if event.type == "CHG" then
  409. state.selected_index = event.row - 1
  410. show_main(p_name)
  411. end
  412. end
  413. if fields.quit then
  414. -- Return the player's unsold inventory, if any
  415. local stack = global_inv:get_stack("p_" .. p_name, 1)
  416. local p_inv = player:get_inventory()
  417. local leftover = p_inv:add_item("main", stack)
  418. -- Whatever doesn't fit in the player's inventory stays in the form.
  419. -- Note that any items in the form when the server exits are lost.
  420. global_inv:set_stack("p_" .. p_name, 1, leftover)
  421. state.own_results = nil
  422. end
  423. end
  424. minetest.register_on_player_receive_fields(function(player, formname, fields)
  425. if formname == main_form then
  426. handle_main(player, fields)
  427. else
  428. return
  429. end
  430. return true
  431. end)
  432. global_inv = minetest.create_detached_inventory("global_exchange", {
  433. allow_move = function(inv,from_list,from_index,to_list,to_index,count,player)
  434. return 0
  435. end,
  436. allow_put = function(inv,to_list,to_index,stack,player)
  437. local p_name = player:get_player_name()
  438. if to_list == "p_" .. p_name then
  439. return stack:get_count()
  440. else
  441. return 0
  442. end
  443. end,
  444. allow_take = function(inv,from_list,from_index,stack,player)
  445. local p_name = player:get_player_name()
  446. if from_list == "p_" .. p_name then
  447. return stack:get_count()
  448. else
  449. return 0
  450. end
  451. end,
  452. on_put = function(inv,to_list,to_index,stack,player)
  453. show_main(player:get_player_name())
  454. end,
  455. on_take = function(inv,from_list,from_index,stack,player)
  456. show_main(player:get_player_name())
  457. end,
  458. })
  459. minetest.register_node("global_exchange:exchange", {
  460. description = "Exchange Terminal",
  461. drawtype = "nodebox",
  462. tiles = {
  463. "global_exchange_terminal_top.png",
  464. "global_exchange_terminal_bottom.png",
  465. "global_exchange_terminal_right.png",
  466. "global_exchange_terminal_right.png^[transform4",
  467. "global_exchange_terminal_back.png",
  468. "global_exchange_terminal_front.png",
  469. },
  470. paramtype = "light",
  471. paramtype2 = "facedir",
  472. groups = {cracky=2},
  473. is_ground_content = false,
  474. stack_max = 1,
  475. light_source = 3,
  476. node_box = {
  477. type = "fixed",
  478. fixed = {
  479. {-8/16, -4/16, 3/16, 8/16, 8/16, 5/16},--screens
  480. {-1/16, -7/16, 5/16, 1/16, 5/16, 7/16},--screen leg
  481. {-3/16, -8/16, 4/16, 3/16, -7/16, 8/16},--leg platform
  482. {-7/16, -8/16, -8/16, 2/16, -6/16, -3/16},--keyboard
  483. { 3/16, -8/16, -3/16, 7/16, -7/16, 3/16},--phone low
  484. { 4/16, -7/16, -1/16, 6/16, -6/16, 3/16},--phone hi
  485. { 2/16, -7/16, 0/16, 8/16, -5/16, 2/16},--phone speaker
  486. }
  487. },
  488. on_rightclick = function(_, _, clicker)
  489. local p_name = clicker:get_player_name()
  490. local state = main_state[p_name]
  491. if state then
  492. state.search_results = {}
  493. end
  494. show_main(p_name)
  495. end,
  496. })
  497. minetest.register_craft( {
  498. output = "global_exchange:exchange",
  499. recipe = {
  500. { "default:steel_ingot", "default:steel_ingot", "default:steel_ingot" },
  501. { "default:mese_crystal", "default:steel_ingot", "default:diamond" },
  502. { "default:steel_ingot", "default:steel_ingot", "default:steel_ingot" },
  503. }
  504. })
  505. -- vim:set ts=4 sw=4 noet: