2019-08-16 02:22:56 -06:00
-- internationalization boilerplate
local MP = minetest.get_modpath ( minetest.get_current_modname ( ) )
local S , NS = dofile ( MP .. " /intllib.lua " )
2019-07-27 15:12:52 -06:00
commoditymarket.registered_markets = { }
local log_length_limit = 30
-- from http://lua-users.org/wiki/BinaryInsert
--[[
table.bininsert ( table , value [ , comp ] )
Inserts a given value through BinaryInsert into the table sorted by [ , comp ] .
If ' comp ' is given , then it must be a function that receives
two table elements , and returns true when the first is less
than the second , e.g . comp = function ( a , b ) return a > b end ,
will give a sorted table , with the biggest value on position 1.
[ , comp ] behaves as in table.sort ( table , value [ , comp ] )
returns the index where ' value ' was inserted
] ] --
local comp_default = function ( a , b ) return a < b end
function table . bininsert ( t , value , comp )
-- Initialise compare function
local comp = comp or comp_default
-- Initialise numbers
local iStart , iEnd , iMid , iState = 1 , # t , 1 , 0
-- Get insert position
while iStart <= iEnd do
-- calculate middle
iMid = math.floor ( ( iStart + iEnd ) / 2 )
-- compare
if comp ( value , t [ iMid ] ) then
iEnd , iState = iMid - 1 , 0
else
iStart , iState = iMid + 1 , 1
end
end
local target = iMid + iState
table.insert ( t , target , value )
return target
end
-- lowest price first
local buy_comp = function ( order1 , order2 )
local price1 = order1.price
local price2 = order2.price
if price1 < price2 then
return true
elseif price1 == price2 and order1.timestamp < order2.timestamp then
return true
end
return false
end
-- highest price first
local sell_comp = function ( order1 , order2 )
local price1 = order1.price
local price2 = order2.price
if price1 > price2 then
return true
elseif price1 == price2 and order1.timestamp < order2.timestamp then
return true
end
return false
end
---------------------------------
local get_account = function ( market , player_name )
local account = market.player_accounts [ player_name ]
if account then
return account
end
account = { }
account.search = " "
account.name = player_name
account.balance = 0 -- currency
account.inventory = { } -- items stored in the market inventory that aren't part of sell orders yet. stored as "[item] = count"
account.filter_participating = " false "
account.log = { } -- might want to use a more sophisticated queue, but this isn't going to be a big list so that's more trouble than it's worth right now.
market.player_accounts [ player_name ] = account
return account
end
2019-07-28 02:44:26 -06:00
-- Caution: the data structures produced by sale logging caused me to discover
-- issue https://github.com/minetest/minetest/issues/8719 with minetest.serialize()
-- I'm working around it by using the code in persistence.lua instead
2019-07-27 15:12:52 -06:00
local log_sale = function ( item , quantity , price , purchaser , seller )
2019-07-28 02:44:26 -06:00
local log_entry = { item = item , quantity = quantity , price = price , purchaser = purchaser , seller = seller , timestamp = minetest.get_gametime ( ) }
local purchaser_log = purchaser.log
local seller_log = seller.log
table.insert ( purchaser_log , log_entry )
if # purchaser_log > log_length_limit then
table.remove ( purchaser_log , 1 )
end
if ( purchaser ~= seller ) then
table.insert ( seller_log , log_entry )
if # seller_log > log_length_limit then
table.remove ( seller_log , 1 )
end
end
2019-07-27 15:12:52 -06:00
end
local remove_orders_by_account = function ( orders , account )
if not orders then return end
local i = 1
while i < # orders do
local order = orders [ i ]
if order.account == account then
table.remove ( orders , i )
else
i = i + 1
end
end
end
local remove_account = function ( player_name )
local account = player_accounts [ player_name ]
if account == nil then
return
end
player_accounts [ player_name ] = nil
for item , lists in pairs ( market ) do
remove_orders_by_account ( lists.buy_orders , account )
remove_orders_by_account ( lists.sell_orders , account )
end
end
------------------------------------------------------------------------------------------
local add_inventory_to_account = function ( market , account , item , quantity )
2019-08-05 00:39:36 -06:00
if quantity < 1 then
2019-07-27 15:12:52 -06:00
return false
end
if market.def . currency [ item ] then
account.balance = account.balance + market.def . currency [ item ] * quantity
else
account.inventory [ item ] = ( account.inventory [ item ] or 0 ) + quantity
end
return true
end
local remove_inventory_from_account = function ( account , item , quantity )
if quantity < 1 then
return false
end
local inventory = account.inventory
local current_quantity = inventory [ item ] or 0
if current_quantity < quantity then
return false
end
local new_quantity = current_quantity - quantity
if new_quantity == 0 then
inventory [ item ] = nil
else
inventory [ item ] = new_quantity
end
return true
end
local remove_order = function ( order , array )
for i , market_order in ipairs ( array ) do
if order == market_order then
table.remove ( array , i )
return true
end
end
return false
end
-----------------------------------------------------------------------------------------------------------
local add_sell = function ( market , account , item , price , quantity )
price = tonumber ( price )
quantity = tonumber ( quantity )
2019-07-29 19:02:52 -06:00
local sell_limit = market.def . sell_limit
local sell_limit_exceeded
if sell_limit then
local total_sell = 0
for item , orders in pairs ( market.orders_for_items ) do
for _ , order in ipairs ( orders.sell_orders ) do
if order.account == account then
total_sell = total_sell + order.quantity
end
end
end
sell_limit_exceeded = total_sell + quantity > sell_limit
end
2019-07-27 15:12:52 -06:00
-- validate that this sell order is possible
2019-08-16 02:22:56 -06:00
if sell_limit_exceeded or price < 0 or quantity < 1 or not remove_inventory_from_account ( account , item , quantity ) then
2019-08-20 01:25:40 -06:00
minetest.sound_play ( { name = " commoditymarket_error " , gain = 0.1 } , { to_player = account.name } )
2019-08-16 02:22:56 -06:00
if sell_limit_exceeded then
minetest.chat_send_player ( account.name , S ( " You have too many items listed for sale in this market, please cancel some sell orders to make room for new ones. " ) )
elseif price < 0 then
minetest.chat_send_player ( account.name , S ( " You can't sell items for a negative price. " ) )
elseif quantity < 1 then
minetest.chat_send_player ( account.name , S ( " You can't sell fewer than one item. " ) )
else
minetest.chat_send_player ( account.name , S ( " You don't have enough of that item in your inventory to post this sell order. " ) )
end
2019-07-27 15:12:52 -06:00
return false
end
local buy_market = market.orders_for_items [ item ] . buy_orders
local buy_order = buy_market [ # buy_market ]
local current_buy_volume = market.orders_for_items [ item ] . buy_volume
-- go through existing buy orders that are more expensive than or equal to the price
-- we're demanding, selling them at the order's price until we run out of
-- buy orders or run out of demand
while quantity > 0 and buy_order and buy_order.price >= price do
local quantity_to_sell = math.min ( buy_order.quantity , quantity )
quantity = quantity - quantity_to_sell
local earned = quantity_to_sell * buy_order.price
account.balance = account.balance + earned
add_inventory_to_account ( market , buy_order.account , item , quantity_to_sell )
buy_order.quantity = buy_order.quantity - quantity_to_sell
current_buy_volume = current_buy_volume - quantity_to_sell
if buy_order.account ~= account then
-- don't update the last price if a player is just buying and selling from themselves
market.orders_for_items [ item ] . last_price = buy_order.price
end
log_sale ( item , quantity_to_sell , buy_order.price , buy_order.account , account )
if buy_order.quantity == 0 then
table.remove ( buy_market , # buy_market )
end
buy_order = buy_market [ # buy_market ]
end
market.orders_for_items [ item ] . buy_volume = current_buy_volume
if quantity > 0 then
local sell_market = market.orders_for_items [ item ] . sell_orders
-- create the order and insert it into order arrays
local order = { account = account , price = price , quantity = quantity , timestamp = minetest.get_gametime ( ) }
table.bininsert ( sell_market , order , sell_comp )
market.orders_for_items [ item ] . sell_volume = market.orders_for_items [ item ] . sell_volume + quantity
end
2019-08-16 02:22:56 -06:00
2019-08-20 01:25:40 -06:00
minetest.sound_play ( { name = " commoditymarket_register_opened " , gain = 0.1 } , { to_player = account.name } )
2019-08-16 02:22:56 -06:00
return true
2019-07-27 15:12:52 -06:00
end
local cancel_sell = function ( market , item , order )
local account = order.account
local quantity = order.quantity
local sell_market = market.orders_for_items [ item ] . sell_orders
remove_order ( order , sell_market )
market.orders_for_items [ item ] . sell_volume = market.orders_for_items [ item ] . sell_volume - quantity
add_inventory_to_account ( market , account , item , quantity )
2019-08-16 02:22:56 -06:00
2019-08-20 01:25:40 -06:00
minetest.sound_play ( { name = " commoditymarket_register_closed " , gain = 0.1 } , { to_player = account.name } )
2019-07-27 15:12:52 -06:00
end
-----------------------------------------------------------------------------------------------------------
local test_buy = function ( market , balance , item , price , quantity )
local sell_market = market.orders_for_items [ item ] . sell_orders
local test_quantity = quantity
local test_balance = balance
local i = 0
local sell_order = sell_market [ # sell_market ]
while test_quantity > 0 and sell_order and sell_order.price <= price do
local quantity_to_buy = math.min ( sell_order.quantity , test_quantity )
test_quantity = test_quantity - quantity_to_buy
test_balance = test_balance - quantity_to_buy * sell_order.price
i = i + 1
sell_order = sell_market [ # sell_market - i ]
end
local spent = balance - test_balance
test_balance = test_balance - test_quantity * price
if test_balance < 0 then
return false , spent , test_quantity
end
return true , spent , test_quantity
end
local add_buy = function ( market , account , item , price , quantity )
price = tonumber ( price )
quantity = tonumber ( quantity )
2019-08-16 02:22:56 -06:00
if price < 0 or quantity < 1 or not test_buy ( market , account.balance , item , price , quantity ) then
2019-08-20 01:25:40 -06:00
minetest.sound_play ( { name = " commoditymarket_error " , gain = 0.1 } , { to_player = account.name } )
2019-08-16 02:22:56 -06:00
if price < 0 then
minetest.chat_send_player ( account.name , S ( " You can't pay less than nothing for an item. " ) )
elseif quantity < 1 then
minetest.chat_send_player ( account.name , S ( " You have to buy at least one item. " ) )
else
minetest.chat_send_player ( account.name , S ( " You can't afford that many of this item. " ) )
end
return false
end
2019-07-27 15:12:52 -06:00
local sell_market = market.orders_for_items [ item ] . sell_orders
local sell_order = sell_market [ # sell_market ]
local current_sell_volume = market.orders_for_items [ item ] . sell_volume
-- go through existing sell orders that are cheaper than or equal to the price
-- we're wanting to offer, buying them up at the offered price until we run out of
-- sell orders or run out of supply
while quantity > 0 and sell_order and sell_order.price <= price do
local quantity_to_buy = math.min ( sell_order.quantity , quantity )
quantity = quantity - quantity_to_buy
local spent = quantity_to_buy * sell_order.price
account.balance = account.balance - spent
sell_order.account . balance = sell_order.account . balance + spent
sell_order.quantity = sell_order.quantity - quantity_to_buy
current_sell_volume = current_sell_volume - quantity_to_buy
add_inventory_to_account ( market , account , item , quantity_to_buy )
if sell_order.account ~= account then
-- don't update the last price if a player is just buying and selling from themselves
market.orders_for_items [ item ] . last_price = sell_order.price
end
log_sale ( item , quantity_to_buy , sell_order.price , account , sell_order.account )
-- Sell order completely used up, remove it
if sell_order.quantity == 0 then
table.remove ( sell_market , # sell_market )
end
-- get the next sell order
sell_order = sell_market [ # sell_market ]
end
market.orders_for_items [ item ] . sell_volume = current_sell_volume
if quantity > 0 then
local buy_market = market.orders_for_items [ item ] . buy_orders
-- create the order for the remainder and insert it into order arrays
local order = { account = account , price = price , quantity = quantity , timestamp = minetest.get_gametime ( ) }
account.balance = account.balance - quantity * price -- buy orders are pre-paid
table.bininsert ( buy_market , order , buy_comp )
market.orders_for_items [ item ] . buy_volume = market.orders_for_items [ item ] . buy_volume + quantity
end
2019-08-20 01:25:40 -06:00
minetest.sound_play ( { name = " commoditymarket_register_opened " , gain = 0.1 } , { to_player = account.name } )
2019-07-27 15:12:52 -06:00
return true
end
local cancel_buy = function ( market , item , order )
local account = order.account
local quantity = order.quantity
local price = order.price
local buy_market = market.orders_for_items [ item ] . buy_orders
market.orders_for_items [ item ] . buy_volume = market.orders_for_items [ item ] . buy_volume - quantity
remove_order ( order , buy_market )
account.balance = account.balance + price * quantity
2019-08-16 02:22:56 -06:00
2019-08-20 01:25:40 -06:00
minetest.sound_play ( { name = " commoditymarket_register_closed " , gain = 0.1 } , { to_player = account.name } )
2019-07-27 15:12:52 -06:00
end
-----------------------------------------------------------------------------------------------------------
local remove_market_item = function ( market , item )
local marketitem = market.orders_for_items [ item ]
if marketitem then
local buy_orders = marketitem.buy_orders
while # buy_orders > 0 do
market : cancel_buy ( item , buy_orders [ # buy_orders ] )
end
local sell_orders = marketitem.sell_orders
while # sell_orders > 0 do
market : cancel_sell ( item , sell_orders [ # sell_orders ] )
end
market.orders_for_items [ item ] = nil
end
end
minetest.register_chatcommand ( " market.removeitem " , {
params = " marketname item " ,
privs = { server = true } ,
decription = " remove item from market. All existing buys and sells will be cancelled. " ,
func = function ( name , param )
local params = param : split ( " " )
if # params ~= 2 then
return
end
local market = commoditymarket.registered_markets [ params [ 1 ] ]
if market == nil then
return
end
remove_market_item ( market , params [ 2 ] )
end ,
} )
2019-08-05 00:39:36 -06:00
minetest.register_chatcommand ( " market.purge_unknowns " , {
params = " " ,
privs = { server = true } ,
decription = " removes all unknown items from all markets. All existing buys and sells for those items will be cancelled. " ,
func = function ( name , param )
for market_name , market in pairs ( commoditymarket.registered_markets ) do
local items_to_remove = { }
local items_to_move = { }
for item , orders in pairs ( market.orders_for_items ) do
if minetest.registered_items [ item ] == nil then
table.insert ( items_to_remove , item )
end
end
for _ , item in ipairs ( items_to_remove ) do
remove_market_item ( market , item )
end
end
end ,
} )
2019-07-27 15:12:52 -06:00
-----------------------------------------------------------------------------------------------------------
local initialize_market_item = function ( orders_for_items , item )
if orders_for_items [ item ] == nil then
local lists = { }
lists.buy_orders = { }
lists.sell_orders = { }
lists.buy_volume = 0
lists.sell_volume = 0
lists.item = item
-- leave last_price nil to indicate it's never been sold before
orders_for_items [ item ] = lists
end
end
-- API exposed to the outside world
local add_inventory = function ( self , player_name , item , quantity )
return add_inventory_to_account ( self , get_account ( self , player_name ) , item , quantity )
end
local remove_inventory = function ( self , player_name , item , quantity )
return remove_inventory_from_account ( get_account ( self , player_name ) , item , quantity )
end
local sell = function ( self , player_name , item , quantity , price )
return add_sell ( self , get_account ( self , player_name ) , item , price , quantity )
end
local buy = function ( self , player_name , item , quantity , price )
return add_buy ( self , get_account ( self , player_name ) , item , price , quantity )
end
2019-07-28 02:44:26 -06:00
-- Using this instead of minetest.serialize because of https://github.com/minetest/minetest/issues/8719
local MP = minetest.get_modpath ( minetest.get_current_modname ( ) )
local persistence_store , persistence_load = dofile ( MP .. " /persistence.lua " )
2019-07-27 15:12:52 -06:00
local load_market_data = function ( marketname )
local path = minetest.get_worldpath ( )
2019-07-29 07:53:44 -06:00
local filename = path .. " /market_ " .. marketname .. " .lua "
2019-07-28 02:44:26 -06:00
return persistence_load ( filename )
2019-07-27 15:12:52 -06:00
end
local save_market_data = function ( market )
local path = minetest.get_worldpath ( )
2019-07-29 07:53:44 -06:00
local filename = path .. " /market_ " .. market.name .. " .lua "
2019-07-27 15:12:52 -06:00
local data = { }
data.player_accounts = market.player_accounts
data.orders_for_items = market.orders_for_items
2019-07-28 02:44:26 -06:00
persistence_store ( filename , data )
2019-07-27 15:12:52 -06:00
return true
end
commoditymarket.register_market = function ( market_name , market_def )
assert ( not commoditymarket.registered_markets [ market_name ] )
2019-08-04 23:24:02 -06:00
market_def.currency_symbol = market_def.currency_symbol or " ¤ " -- \u{00A4} -- defaults to the generic currency symbol ("scarab")
2019-07-27 15:12:52 -06:00
market_def.description = market_def.description or " Market "
market_def.long_description = market_def.long_description or " A market where orders to buy or sell items can be placed and fulfilled. "
-- Reprocess currency table into a form easier for the withdraw code to work with
market_def.currency_ordered = { }
for item , amount in pairs ( market_def.currency ) do
table.insert ( market_def.currency_ordered , { item = item , amount = amount } )
end
table.sort ( market_def.currency_ordered , function ( currency1 , currency2 ) return currency1.amount > currency2.amount end )
local new_market = { }
new_market.def = market_def
commoditymarket.registered_markets [ market_name ] = new_market
local loaded_data = load_market_data ( market_name )
if loaded_data then
new_market.player_accounts = loaded_data.player_accounts
new_market.orders_for_items = loaded_data.orders_for_items
else
new_market.player_accounts = { }
new_market.orders_for_items = { }
end
-- If there's a list of initial items in the market def, initialize them. allow_item can trump this.
local initial_items = market_def.initial_items
if initial_items then
-- defer until after to ensure that all initial items have been registered, so we can guard against invalid items
minetest.after ( 0 ,
function ( )
for _ , item in ipairs ( initial_items ) do
if minetest.registered_items [ item ] and
( ( not market_def.allow_item ) or market_def.allow_item ( item ) ) and
not market_def.currency [ item ] then
initialize_market_item ( new_market.orders_for_items , item )
end
end
end )
end
market_def.initial_items = nil -- don't need this any more
new_market.name = market_name
new_market.add_inventory = add_inventory
new_market.remove_inventory = remove_inventory
new_market.sell = sell
new_market.buy = buy
new_market.cancel_sell = cancel_sell
new_market.cancel_buy = cancel_buy
new_market.get_formspec = commoditymarket.get_formspec
new_market.get_account = get_account
new_market.save = save_market_data
-- save markets on shutdown
minetest.register_on_shutdown ( function ( ) new_market : save ( ) end )
-- and also every ten minutes, to be on the safe side in case Minetest crashes
-- TODO: a more sophisticated approach that checks whether the market data is "dirty" before actually saving
local until_next_save = 600
minetest.register_globalstep ( function ( dtime )
until_next_save = until_next_save - dtime
if until_next_save < 0 then
new_market : save ( )
until_next_save = 600
end
end )
----------------------------------------------------------------------
-- Detached inventory for adding items into the market
local inv = minetest.create_detached_inventory ( " commoditymarket: " .. market_name , {
allow_move = function ( inv , from_list , from_index , to_list , to_index , count , player )
return 0
end ,
allow_put = function ( inv , listname , index , stack , player )
local item = stack : get_name ( )
2019-07-28 02:44:26 -06:00
-- reject unknown items
if minetest.registered_items [ item ] == nil then
return 0
end
-- Currency items are always allowed
2019-07-27 15:12:52 -06:00
if new_market.def . currency [ item ] then
return stack : get_count ( )
end
-- only new tools, no used tools
if stack : get_wear ( ) ~= 0 then
return 0
end
--nothing with metadata permitted
local meta = stack : get_meta ( ) : to_table ( )
local fields = meta.fields
local inventory = meta.inventory
if ( fields and next ( fields ) ) or ( inventory and next ( inventory ) ) then
return 0
end
-- If there's no allow_item function defined, allow everything. Otherwise check if the item is allowed
if ( not market_def.allow_item ) or market_def.allow_item ( item ) then
local allowed_count = stack : get_count ( )
if market_def.inventory_limit then
-- limit additions to the inventory_limit, if there is one
local current_count = 0
for _ , inventory_quantity in pairs ( new_market : get_account ( player : get_player_name ( ) ) . inventory ) do
current_count = current_count + inventory_quantity
end
allowed_count = math.min ( allowed_count , allowed_count + market_def.inventory_limit - ( current_count + allowed_count ) )
if allowed_count <= 0 then return 0 end
end
--ensures the item is in the market listing if it wasn't before
initialize_market_item ( new_market.orders_for_items , item )
return allowed_count
end
return 0
end ,
allow_take = function ( inv , listname , index , stack , player )
return 0
end ,
on_move = function ( inv , from_list , from_index , to_list , to_index , count , player )
end ,
on_take = function ( inv , listname , index , stack , player )
end ,
on_put = function ( inv , listname , index , stack , player )
if listname == " add " then
local item = stack : get_name ( )
local count = stack : get_count ( )
new_market : add_inventory ( player : get_player_name ( ) , item , count )
inv : set_list ( " add " , { } )
local name = player : get_player_name ( )
local formspec = new_market : get_formspec ( new_market : get_account ( name ) )
minetest.show_formspec ( name , " commoditymarket: " .. market_name .. " : " .. name , formspec )
end
end
} )
inv : set_size ( " add " , 1 )
end
commoditymarket.show_market = function ( market_name , player_name )
local market = commoditymarket.registered_markets [ market_name ]
if market == nil then return end
local formspec = market : get_formspec ( market : get_account ( player_name ) )
minetest.show_formspec ( player_name , " commoditymarket: " .. market_name .. " : " .. player_name , formspec )
end