--[[ 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, can be a 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, can be a LocalisedString disabled: whether or not the item should be disabled by default disabled_reason: the reason the item is disabled, can be a LocalisedString } ]] 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 math = require 'utils.math' local Color = require 'resources.color_presets' local ScoreTracker = require 'utils.score_tracker' local LocaleBuilder = require 'utils.locale_builder' local format_number = require 'util'.format_number local format = string.format local size = table.size local pairs = pairs local tonumber = tonumber local type = type local change_for_global = ScoreTracker.change_for_global local change_for_player = ScoreTracker.change_for_player local coins_spent_name = 'coins-spent' local currency_name = 'coin' local currency_item_name = {'item-name.coin'} 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 price_format = '%.2f' local translate_single_newline = {'', '\n'} local translate_double_newline = {'', '\n\n'} local Retailer = {} Retailer.events = { --- Triggered when a purchase is made -- Event { -- item = item, -- count = count, -- player = player, -- group_name = group_name, -- } on_market_purchase = Event.generate_event_name('on_market_purchase'), } 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 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 market_group string function Retailer.get_items(market_group) return memory.items[market_group] 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 ---Returns the remaining market group item limit or -1 if there is none for a given player. ---@param market_group string ---@param item_name string ---@param player_index number function Retailer.get_player_item_limit(market_group, item_name, player_index) local item = Retailer.get_items(market_group)[item_name] if not item then Debug.print({message = 'Item not registered in the Retailer', data = { market_group = market_group, item_name = item_name, }}) return -1 end return memory.limited_items[market_group][item_name][player_index] or item.player_limit end ---Returns the configured market group item limit or -1 if there is none. ---@param market_group string ---@param item_name string function Retailer.get_item_limit(market_group, item_name) local item = Retailer.get_items(market_group)[item_name] if not item then Debug.print({message = 'Item not registered in the Retailer', data = { market_group = market_group, item_name = item_name, }}) return -1 end return item.player_limit end ---sets the configured market group item limit for a given player ---@param market_group string ---@param item_name string ---@param player_index number ---@param new_limit number function Retailer.set_player_item_limit(market_group, item_name, player_index, new_limit) if new_limit < 0 then Debug.print({message = 'Cannot set a negative item limit', data = { market_group = market_group, item_name = item_name, new_limit = new_limit, }}) return end local item = Retailer.get_items(market_group)[item_name] if not item then Debug.print({message = 'Item not registered in the Retailer', data = { market_group = market_group, item_name = item_name, }}) return -1 end if new_limit > item.player_limit then Debug.print({message = 'Cannot set an item limit higher than the item prototype defined', data = { market_group = market_group, item_name = item_name, new_limit = new_limit, }}) new_limit = item.player_limit end memory.limited_items[market_group][item_name][player_index] = new_limit 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_index = data.player_index local player_coins = game.get_player(player_index).get_item_count(currency_name) if size(market_items) == 0 then grid.add({type = 'label', caption = {'retailer.no_items_in_market'}}) return end local limited_items = memory.limited_items[data.market_group] 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 if has_player_limit then local item_name = item.name player_limit = limited_items[item_name][player_index] if player_limit == nil then -- no limit set yet player_limit = item.player_limit limited_items[item_name][player_index] = item.player_limit end if player_limit < stack_count then -- ensure the stack count is never higher than the item limit for the player stack_count = player_limit end end local player_bought_max_total = has_player_limit and stack_count == 0 local price = item.price local tooltip = LocaleBuilder({'', 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 and player_limit == 0 then message = {'retailer.item_sold_out'} elseif total_price == 0 then message = {'retailer.item_is_free'} else message = {'', '[img=item.', currency_name, '] ', format_number(total_price, true)} end local missing_coins = total_price - player_coins local is_missing_coins = missing_coins > 0 if price ~= 0 then tooltip = tooltip :add(translate_single_newline) :add({'', currency_item_name, ': ', format(price_format, price)}) end if description then tooltip = tooltip:add(translate_single_newline) if type(description) == 'table' then tooltip = tooltip:add(description) else tooltip = tooltip:add({'', description}) end end if disabled then tooltip = tooltip :add(translate_double_newline) :add(item.disabled_reason or {'retailer.generic_item_disabled_message'}) elseif is_missing_coins then tooltip = tooltip :add(translate_double_newline) :add({'retailer.not_enough_currency', missing_coins, currency_item_name, stack_count}) end if has_player_limit then local item_player_limit = item.player_limit tooltip = tooltip :add(translate_double_newline) :add({'retailer.item_with_player_limit_description', item_player_limit - player_limit, item_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) label.caption = {'', coin_count, ' ', currency_item_name, ' ', {'common.available'}} 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(currency_name) local data = { grid = grid, count = 1, market_items = market_items, 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 = {'', {'common.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 or position[1]) .. ',' .. (position.y or position[2])] end ---Sets the group name for a market at a given position. ---@param position
Position ---@param group_name local function set_market_group_name(position, group_name) memory.markets[(position.x or position[1]) .. ',' .. (position.y or position[2])] = group_name 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 player.opened = nil end local function check_player_in_range(player, market) if not(player and player.valid and player.character and player.character.valid) then return end local player_position = player.physical_position local market_position = market.position local delta_x = player_position.x - market_position.x local delta_y = player_position.y - market_position.y local reach_distance = player.character.reach_distance * 1.05 if delta_x * delta_x + delta_y * delta_y > reach_distance * reach_distance then close_market_gui(player) return false end return true 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(event.player_index) if not player or not player.valid then return end if not check_player_in_range(player, entity) 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) 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(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 = tostring(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 = tostring(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({'retailer.item_no_longer_available'}) return end if item.disabled then player.print({'retailer.item_disabled_reason', item.name_label, {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(currency_name) if cost > coin_count then player.print({'retailer.not_enough_currency', cost - coin_count, currency_item_name, cost}) return end local market_group = data.market_group if item.player_limit ~= -1 then local limited_item = memory.limited_items[market_group][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({'retailer.no_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 = currency_name, count = cost}) end redraw_market_items(data) change_for_player(player.index, coins_spent_name, cost) change_for_global(coins_spent_name, cost) do_coin_label(coin_count - cost, data.coin_label) raise_event(Retailer.events.on_market_purchase, { item = item, count = stack_count, player = player, group_name = market_group, }) 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 ---Returns the group name of the market, nil if not registered. ---@param market_entity LuaEntity function Retailer.get_market_group_name(market_entity) return get_market_group_name(market_entity.position) 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 if not memory.limited_items[group_name] then memory.limited_items[group_name] = {} end local item_name = prototype.name local name_label = prototype.name_label if not name_label then local item_prototype = prototypes.item[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 memory.limited_items[group_name][item_name] = {} 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(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(player_index) if player and player.valid then if not check_player_in_range(player, view_data) then return 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