--[[ The Retailer provides a market replacement GUI with additional functionality. To register a market, you need the market entity and a name for the market group: Retailer.add_market(group_name, market_entity) If you don't have a group name or don't want to re-use it, you can generate a group name with: local group_name = Retailer.generate_group_id() To change the description displayed in the market GUI, you can call: Retailer.set_market_group_label(group_name, 'Name of your market') To add, remove, disable and enable items, you can call their respective functions. Note that each item can be added only once and will replace the previous if set again. Calling any of those functions will trigger a GUI redraw for all markets in that group in the next tick. When an item is bought from the market, it will raise an event you can listen to: Retailer.events.on_market_purchase. Items can be registered with different types. By default the type is 'item', which will insert the item(s) bought into the player's inventory. Anything else will merely remove the coins and trigger the event. You can listen to this event and do whatever custom handling you want. Items support the following structure: { name: the (raw) item inserted in inventory, does nothing when type is not item name_label: the name shown in the GUI. If omitted and a prototype exists for 'name', it will use that LocalisedString sprite: a custom sprite, will use 'item/' if omitted price: the price of an item, supports floats (0.95 for example) description: an additional description displayed in the tooltip disabled: whether or not the item should be disabled by default disabled_reason: the reason the item is disabled } ]] require 'utils.table' local Global = require 'utils.global' local Gui = require 'utils.gui' local Event = require 'utils.event' local Token = require 'utils.token' local Schedule = require 'utils.task' local PlayerStats = require 'features.player_stats' local Game = require 'utils.game' local math = require 'utils.math' local Color = require 'resources.color_presets' local format = string.format local size = table.size local insert = table.insert local pairs = pairs local tonumber = tonumber local set_timeout_in_ticks = Schedule.set_timeout_in_ticks local clamp = math.clamp local floor = math.floor local ceil = math.ceil local raise_event = script.raise_event local market_frame_name = Gui.uid_name() local market_frame_close_button_name = Gui.uid_name() local item_button_name = Gui.uid_name() local count_slider_name = Gui.uid_name() local count_text_name = Gui.uid_name() local Retailer = {} Retailer.events = { --- Triggered when a purchase is made -- Event { -- item = item, -- count = count, -- player = player, -- group_name = group_name, -- } on_market_purchase = script.generate_event_name(), } Retailer.item_types = { --- expects an array of item prototypes that can be inserted directly via --- player.insert() called 'items' in the item prototype. item_package = 'item_package', } local market_gui_close_distance_squared = 6 * 6 + 6 * 6 local do_update_market_gui -- token ---Global storage ---Markets are indexed by the position "x,y" and contains the group it belongs to ---Items are indexed by the group name and is a list indexed by the item name and contains the prices per item ---players_in_market_view is a list of {position, group_name} data local memory = { id = 0, markets = {}, items = {}, group_label = {}, players_in_market_view = {}, market_gui_refresh_scheduled = {}, limited_items = {} } Global.register(memory, function (tbl) memory = tbl end) local function schedule_market_gui_refresh(group_name) if memory.market_gui_refresh_scheduled[group_name] then -- already scheduled return end set_timeout_in_ticks(1, do_update_market_gui, {group_name = group_name}) memory.market_gui_refresh_scheduled[group_name] = true end ---Generates a unique identifier for a market group name, as alternative for a custom name. function Retailer.generate_group_id() local id = memory.id + 1 memory.id = id return 'market-' .. id end ---Sets the name of the market group, provides a user friendly label in the GUI. ---@param group_name string ---@param label string function Retailer.set_market_group_label(group_name, label) memory.group_label[group_name] = label schedule_market_gui_refresh(group_name) end ---Gets the name of the market group. ---@param group_name string function Retailer.get_market_group_label(group_name) return memory.group_label[group_name] or 'Market' end ---Returns all item for the group_name retailer. ---@param group_name string function Retailer.get_items(group_name) return memory.items[group_name] or {} end ---Removes an item from the markets for the group_name retailer. ---@param group_name string ---@param item_name string function Retailer.remove_item(group_name, item_name) if not memory.items[group_name] then return end memory.items[group_name][item_name] = nil schedule_market_gui_refresh(group_name) end local function redraw_market_items(data) local grid = data.grid Gui.clear(grid) local count = data.count local market_items = data.market_items local player_coins = data.player_coins local player_index = data.player_index if size(market_items) == 0 then grid.add({type = 'label', caption = 'No items available at this time'}) return end for i, item in pairs(market_items) do local has_stack_limit = item.stack_limit ~= -1 local stack_limit = has_stack_limit and item.stack_limit or count local stack_count = has_stack_limit and stack_limit < count and item.stack_limit or count local player_limit = item.player_limit local has_player_limit = player_limit ~= -1 local player_item_limit = -1 if has_player_limit then local item_name = item.name local player_limits = memory.limited_items[item_name] if not player_limits then -- no limit set yet player_limits = {[player_index] = player_limit} memory.limited_items[item_name] = player_limits end player_item_limit = player_limits[player_index] if player_item_limit < stack_count then -- ensure the stack count is never higher than the item limit for the player stack_count = player_item_limit end end local player_bought_max_total = has_player_limit and stack_count == 0 local price = item.price local tooltip = {'', item.name_label} local description = item.description local total_price = ceil(price * stack_count) local disabled = item.disabled == true local message if total_price == 0 then message = 'FREE!' elseif total_price == 1 then message = '1 coin' else message = total_price .. ' coins' end local missing_coins = total_price - player_coins local is_missing_coins = missing_coins > 0 if price ~= 0 then insert(tooltip, format('\nprice: %.2f', price)) end if description then insert(tooltip, '\n' .. item.description) end if disabled then insert(tooltip, '\n\n' .. (item.disabled_reason or 'Not available')) elseif is_missing_coins then insert(tooltip, '\n\n' .. format('Missing %d coins to buy %d', missing_coins, stack_count)) end if has_player_limit then insert(tooltip, '\n\n' .. format('You have bought this item %d out of %d times', player_limit - player_item_limit, player_limit)) end local button = grid.add({type = 'flow'}).add({ type = 'sprite-button', name = item_button_name, sprite = item.sprite, number = stack_count, tooltip = tooltip, }) button.style = 'slot_button' Gui.set_data(button, {index = i, data = data, stack_count = stack_count}) local label = grid.add({type = 'label', caption = message}) local label_style = label.style label_style.width = 93 label_style.height = 32 label_style.font = 'default-bold' label_style.vertical_align = 'center' if disabled or player_bought_max_total then label_style.font_color = Color.dark_grey button.enabled = false elseif is_missing_coins then label_style.font_color = Color.red button.enabled = false end end end local function do_coin_label(coin_count, label) if coin_count == 1 then label.caption = '1 coin available' else label.caption = coin_count .. ' coins available' end label.style.font = 'default-bold' end local function draw_market_frame(player, group_name) local frame = player.gui.center.add({ type = 'frame', name = market_frame_name, caption = Retailer.get_market_group_label(group_name), direction = 'vertical', }) local scroll_pane = frame.add({type = 'scroll-pane'}) local scroll_style = scroll_pane.style scroll_style.maximal_height = 600 local grid = scroll_pane.add({type = 'table', column_count = 10}) local market_items = Retailer.get_items(group_name) local player_coins = player.get_item_count('coin') local data = { grid = grid, count = 1, market_items = market_items, player_coins = player_coins, market_group = group_name, player_index = player.index, } local coin_label = frame.add({type = 'label'}) do_coin_label(player_coins, coin_label) data.coin_label = coin_label redraw_market_items(data) local bottom_grid = frame.add({type = 'table', column_count = 2}) bottom_grid.add({type = 'label', caption = 'Quantity: '}).style.font = 'default-bold' local count_text = bottom_grid.add({ type = 'text-box', name = count_text_name, text = '1', }) local count_slider = frame.add({ type = 'slider', name = count_slider_name, minimum_value = 1, maximum_value = 7, value = 1, }) frame.add({name = market_frame_close_button_name, type = 'button', caption = 'Close'}) count_slider.style.width = 115 count_text.style.width = 45 data.slider = count_slider data.text = count_text Gui.set_data(count_slider, data) Gui.set_data(count_text, data) Gui.set_data(frame, data) return frame end ---Returns the group name of the market at the given position, nil if not registered. ---@param position Position local function get_market_group_name(position) return memory.markets[position.x .. ',' .. position.y] end ---Sets the group name for a market at a given position. ---@param position Position ---@param group_name string local function set_market_group_name(position, group_name) memory.markets[position.x .. ',' .. position.y] = group_name end Event.add(defines.events.on_gui_opened, function (event) if not event.gui_type == defines.gui_type.entity then return end local entity = event.entity if not entity or not entity.valid then return end local position = entity.position local group_name = get_market_group_name(position) if not group_name then return end local player = Game.get_player_by_index(event.player_index) if not player or not player.valid then return end memory.players_in_market_view[player.index] = { position = position, group_name = group_name, } local frame = draw_market_frame(player, group_name) player.opened = frame end) Gui.on_custom_close(market_frame_name, function (event) local element = event.element memory.players_in_market_view[event.player.index] = nil Gui.destroy(element) end) local function close_market_gui(player) local element = player.gui.center memory.players_in_market_view[player.index] = nil if element and element.valid then element = element[market_frame_name] if element and element.valid then Gui.destroy(element) end end end Gui.on_click(market_frame_close_button_name, function (event) close_market_gui(event.player) end) Event.add(defines.events.on_player_died, function (event) local player = Game.get_player_by_index(event.player_index or 0) if not player or not player.valid then return end close_market_gui(player) end) Gui.on_value_changed(count_slider_name, function (event) local element = event.element local data = Gui.get_data(element) local value = floor(element.slider_value) local count if value % 2 == 0 then count = 10 ^ (value * 0.5) * 0.5 else count = 10 ^ ((value - 1) * 0.5) end data.count = count data.text.text = count redraw_market_items(data) end) Gui.on_text_changed(count_text_name, function (event) local element = event.element local data = Gui.get_data(element) local count = tonumber(element.text) if count then count = floor(count) count = clamp(count, 1, 1000) data.count = count data.text.text = count else data.count = 1 end redraw_market_items(data) end) Gui.on_click(item_button_name, function (event) local player = event.player local element = event.element local button_data = Gui.get_data(element) local data = button_data.data local stack_count = button_data.stack_count local item = data.market_items[button_data.index] if not item then player.print('This item is no longer available in the market') return end if item.disabled then player.print({'', item.name_label, ' is disabled. ', item.disabled_reason or ''}) return end local name = item.name local price = item.price local cost = ceil(price * stack_count) local coin_count = player.get_item_count('coin') if cost > coin_count then player.print('Insufficient coins') return end if item.player_limit ~= -1 then local limited_item = memory.limited_items[name] limited_item[player.index] = limited_item[player.index] - stack_count end if item.type == 'item' then local inserted = player.insert({name = name, count = stack_count}) if inserted < stack_count then player.print('Insufficient inventory space') if inserted > 0 then player.remove_item({name = name, count = inserted}) end return end end if cost > 0 then player.remove_item({name = 'coin', count = cost}) local player_coins = data.player_coins - cost data.player_coins = player_coins do_coin_label(player_coins, data.coin_label) end redraw_market_items(data) PlayerStats.change_coin_spent(player.index, cost) raise_event(Retailer.events.on_market_purchase, { item = item, count = stack_count, player = player, }) end) ---Add a market to the group_name retailer. ---@param group_name string ---@param market_entity LuaEntity function Retailer.add_market(group_name, market_entity) set_market_group_name(market_entity.position, group_name) end ---Sets an item for all the group_name markets. ---@param group_name string ---@param prototype table with item name and price function Retailer.set_item(group_name, prototype) if not memory.items[group_name] then memory.items[group_name] = {} end local item_name = prototype.name local name_label = prototype.name_label if not name_label then local item_prototype = game.item_prototypes[item_name] name_label = item_prototype and item_prototype.localised_name end prototype.name_label = name_label or item_name prototype.sprite = prototype.sprite or 'item/' .. item_name prototype.type = prototype.type or 'item' if not prototype.stack_limit then prototype.stack_limit = -1 end if not prototype.player_limit then prototype.player_limit = -1 end memory.items[group_name][item_name] = prototype schedule_market_gui_refresh(group_name) end ---Enables a market item by group name and item name if it's registered. ---@param group_name string ---@param item_name string function Retailer.enable_item(group_name, item_name) if not memory.items[group_name] then return end local prototype = memory.items[group_name][item_name] if not prototype then return end prototype.disabled = false prototype.disabled_reason = false schedule_market_gui_refresh(group_name) end ---Disables a market item by group name and item name if it's registered. ---@param group_name string ---@param item_name string ---@param disabled_reason string function Retailer.disable_item(group_name, item_name, disabled_reason) if not memory.items[group_name] then return end local prototype = memory.items[group_name][item_name] if not prototype then return end prototype.disabled = true prototype.disabled_reason = disabled_reason schedule_market_gui_refresh(group_name) end do_update_market_gui = Token.register(function(params) local group_name = params.group_name for player_index, view_data in pairs(memory.players_in_market_view) do if group_name == view_data.group_name then local player = Game.get_player_by_index(player_index) if player and player.valid then local frame = player.gui.center[market_frame_name] if not frame or not frame.valid then -- player already closed the market GUI and somehow this was not reported memory.players_in_market_view[player_index] = nil else redraw_market_items(Gui.get_data(frame)) end else -- player is no longer in the game, remove it from the market view memory.players_in_market_view[player_index] = nil end end end -- mark it as updated memory.market_gui_refresh_scheduled[group_name] = nil end) Event.on_nth_tick(37, function() for player_index, view_data in pairs(memory.players_in_market_view) do local player = Game.get_player_by_index(player_index) if player and player.valid then local player_position = player.position local market_position = view_data.position local delta_x = player_position.x - market_position.x local delta_y = player_position.y - market_position.y if delta_x * delta_x + delta_y * delta_y > market_gui_close_distance_squared then close_market_gui(player) end else -- player is no longer in the game, remove it from the market view memory.players_in_market_view[player_index] = nil end end end) Event.add(Retailer.events.on_market_purchase, function (event) local package = event.item if package.type ~= Retailer.item_types.item_package then return end local player_insert = event.player.insert for _, item in pairs(package.items) do item.count = item.count * event.count player_insert(item) end end) return Retailer