diff --git a/config.lua b/config.lua index 57aa2d69b..c3b825196 100644 --- a/config.lua +++ b/config.lua @@ -610,6 +610,10 @@ storage.config = { enabled = true, starting_items = { 'iron-ore', 'copper-ore', 'coal', 'stone' }, limit = 40, -- limit of tracked items per-player + }, + calculator = { + enabled = true, + technology = true, } } diff --git a/control.lua b/control.lua index eca39393f..3cd8f7fa0 100644 --- a/control.lua +++ b/control.lua @@ -191,6 +191,11 @@ end if config.score.enabled then require 'features.gui.score' end +if config.calculator.enabled then + if config.calculator.technology then + require 'features.gui.calculator.technology' + end +end --require 'features.snake.control' diff --git a/features/gui/calculator/technology.lua b/features/gui/calculator/technology.lua new file mode 100644 index 000000000..3dbac309b --- /dev/null +++ b/features/gui/calculator/technology.lua @@ -0,0 +1,453 @@ +-- This feature adds a command "/calculator-technology" that shows a small popup +-- with the breakdown cost to research target technology. +-- made by RedRafe +-- ======================================================= -- + +local Command = require 'utils.command' +local Gui = require 'utils.gui' +local Ranks = require 'resources.ranks' +local Global = require 'utils.global' +local History = require 'utils.history' + +local history = {} +local toggled = {} + +Global.register({ + history = history, + toggled = toggled, +}, function(tbl) + history = tbl.history + toggled = tbl.toggled +end) + +local function get_history(player_index) + local h = history[player_index] + if not h then + h = History.new() + history[player_index] = h + end + return h +end + +local function set_history(player_index, technology_name) + local h = get_history(player_index) + if technology_name == nil then + h:clear() + elseif prototypes.technology[technology_name] ~= nil then + h:add(technology_name) + end +end + +local function dict_to_array(dict) + local array = {} + for k, v in pairs(dict) do + table.insert(array, { name = k, count = v }) + end + return array +end + +local function safe_div(a, b) + return b <= 0 and 0 or a / b +end + +local function sort_lab_input(a, b) + local a_order = prototypes.item[a.name].order + local b_order = prototypes.item[b.name].order + return a_order < b_order +end + +---@param force LuaForce +---@param technology_name string +local function get_research_info(force, technology_name) + if (technology_name == nil) or (force.technologies[technology_name] == nil) then + return { + cost_breakdown = {}, + research_path = {}, + } + end + + local tech = force.technologies[technology_name] + local cost_breakdown = { + -- sci pack name/icon + -- relative count + -- absolute count + -- progress + } + local research_path = { + -- technology name/icon + -- ingredients w/ count, + -- order ? + -- localised_name + } + + local relative = {} + local absolute = {} + local seen = {} + + ---@param technology LuaTechnology + local function compute_cost(technology) + if seen[technology.name] then + return + end + + seen[technology.name] = true + + local ingredients = {} + local absolute_cost = technology.research_unit_count or 0 + local relative_cost = technology.researched and 0 or absolute_cost + + for _, ingredient in pairs(technology.research_unit_ingredients) do + ingredients[ingredient.name] = absolute_cost * ingredient.amount + absolute[ingredient.name] = (absolute[ingredient.name] or 0) + absolute_cost * ingredient.amount + relative[ingredient.name] = (relative[ingredient.name] or 0) + relative_cost * ingredient.amount + end + + ingredients = dict_to_array(ingredients) + table.sort(ingredients, sort_lab_input) + + if not technology.researched then + table.insert(research_path, { + name = technology.name, + localised_name = technology.localised_name, + ingredients = ingredients, + }) + end + + for _, prereq in pairs(technology.prerequisites) do + compute_cost(prereq) + end + end + + compute_cost(tech) + + local tot_abs, tot_rel = 0, 0 + for ingredient, count in pairs(absolute) do + local c = { + name = ingredient, + relative = relative[ingredient], + absolute = count, + progress = 1 - safe_div(relative[ingredient], count), + localised_name = prototypes.item[ingredient].localised_name, + } + table.insert(cost_breakdown, c) + tot_abs = tot_abs + c.absolute + tot_rel = tot_rel + c.relative + end + + table.sort(cost_breakdown, sort_lab_input) + table.sort(research_path, function(a, b) + return a.name < b.name + end) + + return { + research_path = research_path, + cost_breakdown = cost_breakdown, + average = { + absolute = tot_abs, + relative = tot_rel, + progress = 1 - safe_div(tot_rel, tot_abs), + }, + } +end + +-- == GUI ===================================================================== + +local main_frame_name = Gui.uid_name() +local close_button_name = Gui.uid_name() +local history_back_button_name = Gui.uid_name() +local history_forward_button_name = Gui.uid_name() +local select_button_name = Gui.uid_name() +local toggle_list_button_name = Gui.uid_name() +local shortcut_button_name = Gui.uid_name() +local on_technologies, off_technologies = '▼ Technologies', '▲ Technologies' + +local function get_main_frame(player) + local frame = player.gui.screen[main_frame_name] + if frame and frame.valid then + Gui.clear(frame) + return frame + end + + frame = player.gui.screen.add { + type = 'frame', + name = main_frame_name, + direction = 'vertical', + style = 'frame', + } + Gui.set_style(frame, { + horizontally_stretchable = true, + vertically_stretchable = true, + maximal_height = 600, + natural_width = 482, + top_padding = 8, + bottom_padding = 8, + }) + Gui.set_data(frame, { frame = frame }) + + frame.force_auto_center() + player.opened = frame + return frame +end + +local function show_ingredients(parent, ingredients) + local flow = parent.add { type = 'flow', direction = 'horizontal' } + Gui.set_style(flow, { horizontally_stretchable = true }) + + for _, ingredient in pairs(ingredients) do + local b = flow.add { + type = 'sprite-button', + style = 'slot', + number = ingredient.count, + sprite = 'item/' .. ingredient.name, + tooltip = prototypes.item[ingredient.name].localised_name, + } + Gui.set_style(b, { size = 32, font = 'default-small-semibold' }) + end +end + +local function shortcut_button(parent, info) + local b = parent + .add { type = 'flow' } + .add { + type = 'sprite-button', + style = 'transparent_slot', + sprite = 'technology/' .. info.name, + name = shortcut_button_name, + tags = { name = info.name }, + tooltip = {'', '[color=0.5,0.8,0.94][font=var]Click[/font][/color] to go to this technology\'s breakdown'} + } + Gui.set_style(b, { size = 32 }) + return b +end + +local function progressbar(parent, info) + local p = parent.add { + type = 'progressbar', + value = info.progress, + tooltip = ('%.2f %%'):format(info.progress * 100), + style = 'achievement_progressbar', + } + Gui.set_style(p, { width = 80 }) + return p +end + +local function draw(player) + local frame = get_main_frame(player) + local data = Gui.get_data(frame) + local h = get_history(player.index) + local technology_name = h:get() + + local info = get_research_info(player.force, technology_name) + + do --- title + local flow = frame.add { type = 'flow', direction = 'horizontal' } + Gui.set_style(flow, { horizontal_spacing = 8, vertical_align = 'center', bottom_padding = 4 }) + + local label = flow.add { type = 'label', caption = 'Research calculator', style = 'frame_title' } + label.drag_target = frame + + Gui.add_dragger(flow, frame) + + local history_flow = flow.add { type = 'flow', direction = 'horizontal' } + Gui.set_style(history_flow, { horizontal_spacing = 0, padding = 0 }) + + local backward = history_flow.add { + type = 'sprite-button', + name = history_back_button_name, + sprite = 'utility/backward_arrow', + clicked_sprite = 'utility/backward_arrow_black', + style = 'close_button', + tooltip = 'Back', + } + backward.enabled = h:peek_previous() ~= nil + + local forward = history_flow.add { + type = 'sprite-button', + name = history_forward_button_name, + sprite = 'utility/forward_arrow', + clicked_sprite = 'utility/forward_arrow_black', + style = 'close_button', + tooltip = 'Forward', + } + forward.enabled = h:peek_next() ~= nil + + local close_button = flow.add { + type = 'sprite-button', + name = close_button_name, + sprite = 'utility/close', + clicked_sprite = 'utility/close_black', + style = 'close_button', + tooltip = { 'gui.close-instruction' }, + } + Gui.set_data(close_button, { frame = frame }) + end + do --- body + local body = frame.add { type = 'frame', style = 'inside_shallow_frame_packed', direction = 'vertical' }.add { type = 'scroll-pane', style = 'naked_scroll_pane' } + Gui.set_style(body.parent, { horizontally_stretchable = true, top_padding = 8, left_padding = 8, bottom_padding = 8 }) + Gui.set_style(body, { right_padding = 8 }) + + do --- Technology selection + local flow = body.add { type = 'frame', style = 'bordered_frame' }.add { type = 'flow', direction = 'horizontal' } + flow.add { type = 'label', caption = 'Select technology', style = 'caption_label' } + + Gui.add_pusher(flow, 'horizontal') + + local select_button = flow.add { + type = 'choose-elem-button', + name = select_button_name, + style = 'slot_button_in_shallow_frame', + elem_type = 'technology', + technology = technology_name, + } + data.select_button = select_button + Gui.set_data(select_button, data) + end + + do --- Technology cost breakdown + local breakdown = body.add { + type = 'table', + style = 'finished_game_table', + column_count = 4, + draw_horizontal_line_after_headers = false, + } + data.breakdown = breakdown + breakdown.style.column_alignments[2] = 'center' + breakdown.style.column_alignments[3] = 'right' + breakdown.style.column_alignments[4] = 'right' + Gui.set_style(breakdown, { horizontally_stretchable = true }) + + for _, title in pairs({ + 'Science', + 'Progress', + 'Relative', + 'Absolute', + }) do + breakdown.add { type = 'label', caption = title, style = 'bold_label' } + end + for _, c in pairs(info.cost_breakdown) do + breakdown.add { type = 'label', caption = { '', '[img=item/' .. c.name .. '] ', c.localised_name } } + progressbar(breakdown, c) + breakdown.add { type = 'label', caption = c.relative } + breakdown.add { type = 'label', caption = c.absolute } + end + + local avg = info.average + if avg then + breakdown.add { type = 'label', caption = ('Completion %d %%'):format(avg.progress * 100), style = 'info_label' } + progressbar(breakdown, avg) + breakdown.add { type = 'label', caption = avg.relative } + breakdown.add { type = 'label', caption = avg.absolute } + end + + breakdown.visible = (technology_name ~= nil) + end + + do --- Technology research path + local label = body.add { + type = 'label', + style = 'bold_label', + caption = toggled[player.index] and on_technologies or off_technologies, + name = toggle_list_button_name, + tooltip = 'Hide/Show technologies list', + } + data.label = label + Gui.set_data(label, data) + + local deep = body.add { type = 'frame', style = 'deep_frame_in_shallow_frame_for_description', direction = 'vertical' } + Gui.set_style(deep, { padding = 0, minimal_height = 4 }) + + local list = deep.add { type = 'scroll-pane', vertical_scroll_policy = 'never' }.add { type = 'table', style = 'table_with_selection', column_count = 3 } + Gui.set_style(list.parent, { horizontally_squashable = false }) + + list.visible = toggled[player.index] or false + data.list = list + Gui.set_data(list, data) + + for _, t in pairs(info.research_path) do + shortcut_button(list, t) + list.add { type = 'label', caption = t.localised_name } + show_ingredients(list, t.ingredients) + end + end + end +end + +Gui.on_click(close_button_name, function(event) + Gui.destroy(Gui.get_data(event.element).frame) +end) + +Gui.on_custom_close(main_frame_name, function(event) + Gui.destroy(event.element) +end) + +Gui.on_click(history_back_button_name, function(event) + local h = get_history(event.player_index) + h:previous() + draw(event.player) +end) + +Gui.on_click(history_forward_button_name, function(event) + local h = get_history(event.player_index) + h:next() + draw(event.player) +end) + +Gui.on_elem_changed(select_button_name, function(event) + set_history(event.player_index, event.element.elem_value) + draw(event.player) +end) + +Gui.on_click(toggle_list_button_name, function(event) + local data = Gui.get_data(event.element) + data.list.visible = not data.list.visible + toggled[event.player_index] = data.list.visible + data.label.caption = data.list.visible and on_technologies or off_technologies +end) + +Gui.on_click(shortcut_button_name, function(event) + if get_history(event.player_index):get() == event.element.tags.name then + return + end + set_history(event.player_index, event.element.tags.name) + draw(event.player) +end) + +-- == COMMANDS ================================================================ + +Command.add('calculator-technology', { + description = 'Computes the cost in science packs to research target technology', + arguments = { 'technology' }, + default_values = { technology = '' }, + allowed_by_server = false, + required_rank = Ranks.guest, + capture_excess_arguments = true, +}, function(arguments, player, _) + set_history(player.index, arguments.technology) + draw(player) +end) + +Command.add('calculator-technology-for-player', { + description = 'Computes the cost in science packs to research target technology for target player', + arguments = { 'technology', 'player' }, + allowed_by_server = false, + required_rank = Ranks.admin, + capture_excess_arguments = true, +}, function(arguments, player, _) + local target_player = game.get_player(arguments.player) + if not player then + player.print('Invalid ') + return + end + if prototypes.technology[arguments.technology] == nil then + player.print('Invalid ') + return + end + + set_history(player.index, arguments.technology) + draw(player) + + if player ~= target_player then + set_history(target_player.index, arguments.technology) + draw(target_player) + end +end) diff --git a/utils/history.lua b/utils/history.lua new file mode 100644 index 000000000..da4568c98 --- /dev/null +++ b/utils/history.lua @@ -0,0 +1,157 @@ +---@class History +--- A class that manages a history buffer with undo/redo capabilities. +local History = {} +History.__index = History + +script.register_metatable('RedMew_HistoryClass', History) + +local DEFAULT_SIZE = 100 +local math_min = math.min +local math_max = math.max +local insert = table.insert +local remove = table.remove + +--- Creates a new History instance. +---@param size number? Optional maximum size of the history buffer. Defaults to 100 if not provided or invalid. +---@return History +function History.new(size) + return setmetatable({ + buffer = {}, -- Table storing the history elements + index = 0, -- Current position in the history buffer + max_size = (size and size > 0 or DEFAULT_SIZE) -- Max number of items to store + }, History) +end + +--- Keeps only the elements within the specified range [left, right]. +---@param self History The history object. +---@param left number The starting index of the range (1-based). +---@param right number The ending index of the range (1-based). +local function range(self, left, right) + local result = {} + local len = #self.buffer + + left = math_max(1, left) + right = math_min(right, len) + + for i = left, right do + insert(result, self.buffer[i]) + end + + self.buffer = result + + if self.index > #self.buffer then + self.index = #self.buffer + end +end + +--- Returns the current size of the history buffer. +---@param self History The history object. +---@return number +function History:size() + return #self.buffer +end + +--- ADD a new element to the history buffer. +--- If the current position is not at the end, discards all "redo" states. +--- Ensures the buffer does not exceed max_size by trimming oldest entries. +---@param self History The history object. +---@param element any The element to add. Nil elements are ignored. +function History:add(element) + if element == nil then + return + end + + if self.index ~= #self.buffer then + range(self, 1, self.index) + end + + insert(self.buffer, element) + self.index = #self.buffer + + if #self.buffer > self.max_size then + range(self, 1 + #self.buffer - self.max_size, #self.buffer) + end +end + +--- REMOVE the element at the specified index. +---@param self History The history object. +---@param index number? Optional index to remove; defaults to current index. +function History:remove(index) + if index == nil then + index = self.index + end + if not self.buffer[index] then + return + end + -- Adjust index if removing current or previous + if self.index >= index then + self.index = self.index - 1 + end + remove(self.buffer, index) +end + +--- GET the element at the specified index. +--- If no index is provided, returns the current element. +---@param self History The history object. +---@param index number? Optional index to retrieve; defaults to current index. +---@return any +function History:get(index) + if index == nil then + index = self.index + end + return self.buffer[index] +end + +--- GET the current position index in the history buffer. +---@param self History The history object. +---@return number The current index. +function History:get_index() + return self.index +end + +--- GET the previous element in the history without changing the current index. +---@param self History The history object. +---@return any|nil The previous element or nil if none exists. +function History:peek_previous() + return self.buffer[self.index - 1] +end + +--- GET the next element in the history without changing the current index. +---@param self History The history object. +---@return any|nil The next element or nil if none exists. +function History:peek_next() + return self.buffer[self.index + 1] +end + +--- MOVE to the previous history element, if possible. +--- Updates the current index and returns the element. +---@param self History The history object. +---@return any|nil The previous element, or nil if at the beginning. +function History:previous() + if not self.buffer[self.index - 1] then + return nil + end + self.index = self.index - 1 + return self.buffer[self.index] +end + +--- MOVE to the next history element, if possible. +--- Updates the current index and returns the element. +---@param self History The history object. +---@return any|nil The next element, or nil if at the latest. +function History:next() + if not self.buffer[self.index + 1] then + return nil + end + self.index = self.index + 1 + return self.buffer[self.index] +end + +--- CLEAR the entire history buffer and resets the current index. +---@param self History The history object. +function History:clear() + self.buffer = {} + self.index = 0 +end + +return History