diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..1d3a5c1b4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: therenas +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..7c811adee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,30 @@ +name: Bug report +description: Encounter any crash or other issue? I'd like to fix that. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + *Before filing a bug, please check if it wasn't already reported previously.* + - type: textarea + attributes: + label: Problem Description + description: | + What went wrong? What did you expect to happen? + Please include any context you can, like the crash message, screenshots, or relevant mods. + placeholder: I tried to start a new game, but the game crashed. + validations: + required: true + - type: markdown + attributes: + value: | + **Including the `factorio-current.log` file and a copy of your save is very helpful.** + They can be dropped right into the above textfield, if below 25MB in size. + Alternatively, you can upload them [here](https://drive.google.com/drive/folders/1JZErOFq7ibpk09dSeMV5BwPmG6xzhQNu?usp=sharing) with a descriptive name. + - type: textarea + attributes: + label: Reproduction + description: Let me know the exact steps you took to get the crash to happen, if possible. + placeholder: | + 1. Start a new game + 2. Crash happens! diff --git a/.github/ISSUE_TEMPLATE/suggestion.yml b/.github/ISSUE_TEMPLATE/suggestion.yml new file mode 100644 index 000000000..a116ff8fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/suggestion.yml @@ -0,0 +1,14 @@ +name: Improvement or feature suggestion +description: Have an idea for how to improve the mod? Let me know! +labels: ["improvement"] +body: + - type: textarea + attributes: + label: Suggestion + description: What's your idea for something to improve on? What problem of yours does it solve? + placeholder: I'd like the mod to be able to read my mind. + validations: + required: true + - type: markdown + attributes: + value: I appreciate any and all suggestions, but be aware that it might take me quite a while to get to them. diff --git a/.gitignore b/.gitignore index e69de29bb..ac4b2348d 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +.vscode +modfiles/scenarios diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..2f2db6d9d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "scripts"] + path = scripts + url = https://github.com/ClaudeMetz/FactorioScripts +[submodule "locale"] + path = locale + url = https://github.com/ClaudeMetz/FactoryPlannerLocale diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..754a337d3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Claude Metz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 36a3dd6ad..75da1f7bf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ # Factory Planner -[Placeholder] \ No newline at end of file +Factory Planner is a mod for the game [Factorio](https://www.factorio.com). It is published in zip-format on the Factorio [mod portal](https://mods.factorio.com/mod/factoryplanner), which allows it to be downloaded and updated by players in-game. + +It enables players to plan out their production in detail, specifying the recipes, machines and modules to use. It allows for conceptual separation of assembly lines through the use of separate floors that can be nested infinitely. There exist calculators on the web that can do this already, but a big advantage a mod has is that it can incorporate the exact combination of mods the user has installed, with up-to-date information, which is something that external solutions struggle to provide. + +My focus when creating the interface for this mod was on making it as clear and intuitive as possible while offering all the features that power users need. To that end, users should be able to get started on their first plans relatively easily, after which they can gradually explore all the functionality that is offered. The interface was created with all the modern design standards and interaction paradigms in mind, making it conform well to both user's expectations and the rest of the game. + +There is a public [project board](https://github.com/users/ClaudeMetz/projects/1) organizing what's on my radar. Suggestions and bug reports are centralized here on Github. Feel free to join the Factory Planner [Discord](https://discord.gg/ABqNEQc) to talk to me and others in a more casual way. + +## Contribute + +If you want to contribute to Factory Planner, please join the [Discord](https://discord.gg/ABqNEQc) and talk to me about what you have in mind first. I'm happy to hear about any ideas you have, but this mod is still in active development and I might already have a plan for what you're thinking about. You can of course also open an issue to talk about your ideas. + +### Localisation + +Localisation of Factory Planner is handled entirely through a [separate project](https://github.com/ClaudeMetz/FactoryPlannerLocale) on Github. If you're interested in helping out, feel free take a look. There are further explanations on the project's page. If you have any technical questions, please open an issue on that repo. + +## License + +This mod is licensed under [MIT](https://en.wikipedia.org/wiki/Public_domain), with the exception of the localisation, which is in the [public domain](https://github.com/ClaudeMetz/FactoryPlannerLocale?tab=Unlicense-1-ov-file). diff --git a/TODO.txt b/TODO.txt deleted file mode 100644 index da3547f81..000000000 --- a/TODO.txt +++ /dev/null @@ -1,38 +0,0 @@ -TODO Features: -- Scaling -- Info pane with shortcuts / small Tutorial -- Make the Subfactory bar scalable (row-breakoff, multiple rows) -? Notes and/or custom tooltip text for subfactories - - -TODO Improvements: -? create testsuite for sprite+label-buttons -? more special characters for subfactory names -? error handling to prevent crashing the whole save -* subfactory_bar spacing is a super-mess on different resoultions and scaling modes, - sprite resizing is also still not fixable, might need to reconsider - -BUGS: -- actual Sprite size doesn't adjust to height and width, only it's 'box' does - fix might be coming in 0.17 -- not whole frame considered part of GuiElement, fires no on_gui_click event (see subfactory_dialog) - -TODO 0.17: -- *.style.visible -> *.visible - -TODO Post-Launch: -! Modules -! Energy consumption -- Export/Import -- Multiplayer -? FNEI Linking -? Power Planning - - -API Changes: -A let enter work as a way to submit forms, even when a textfield is in focus -- button with both sprite and text - - -MISC: -- Count LOC: ( find ./ -name '*.lua' -print0 | xargs -0 cat ) | wc -l \ No newline at end of file diff --git a/control.lua b/control.lua deleted file mode 100644 index 64dd4655a..000000000 --- a/control.lua +++ /dev/null @@ -1,6 +0,0 @@ -require("data.init") -require("ui.listeners") -require("ui.dialogs.main") - --- Initiate global variables on load -data_init() \ No newline at end of file diff --git a/data.lua b/data.lua deleted file mode 100644 index 128f038b8..000000000 --- a/data.lua +++ /dev/null @@ -1,3 +0,0 @@ -require("prototypes.hotkeys") -require("prototypes.fonts") -require("prototypes.styles") \ No newline at end of file diff --git a/data/init.lua b/data/init.lua deleted file mode 100644 index 453de65a9..000000000 --- a/data/init.lua +++ /dev/null @@ -1,13 +0,0 @@ -require("subfactory") - --- Initiates all global necessary variables -function data_init() - if global["subfactories"] == nil then global["subfactories"] = {} end - global["selected_subfactory_id"] = 1 - if global["modal_dialog_open"] == nil then global["modal_dialog_open"] = false end - global["currently_editing"] = false - global["currently_deleting"] = false - - -- Enables dev mode features including rebuilding of UI instead of hiding/showing - global["devmode"] = true -end \ No newline at end of file diff --git a/data/subfactory.lua b/data/subfactory.lua deleted file mode 100644 index 5adbe1982..000000000 --- a/data/subfactory.lua +++ /dev/null @@ -1,24 +0,0 @@ --- Adds a new subfactory to the databaae and returns it's id -function add_subfactory(name, icon) - table.insert(global["subfactories"], {name = name, icon = icon}) - - -- Sets the currently selected subfactory to the new one - global["selected_subfactory_id"] = #global["subfactories"] -end - --- Changes a subfactory from the database -function edit_subfactory(id, name, icon) - global["subfactories"][id].name= name - global["subfactories"][id].icon = icon -end - --- Deletes a sunfactory from the database -function delete_subfactory(id) - table.remove(global["subfactories"], id) - - -- Moves the selected subfactory down by 1 if the last in the list is deleted - local subfactories = global["subfactories"] - if subfactories[id] == nil then - global["selected_subfactory_id"] = #subfactories - end -end \ No newline at end of file diff --git a/info.json b/info.json deleted file mode 100644 index c8eb2254d..000000000 --- a/info.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "FactoryPlanner", - "version": "0.16.0", - "title": "Factory Planner", - "author": "Therenas", - "contact": "Therenas.FactoryPlanner@gmail.com", - "homepage": "https://github.com/ClaudeMetz/FactoryPlanner", - "factorio_version": "0.16", - "dependencies": ["base >= 0.16"], - "description": "[Placeholder]" -} \ No newline at end of file diff --git a/locale b/locale new file mode 160000 index 000000000..60e3fbd9b --- /dev/null +++ b/locale @@ -0,0 +1 @@ +Subproject commit 60e3fbd9b78399881305e30c7981918aac835721 diff --git a/locale/en/config.cfg b/locale/en/config.cfg deleted file mode 100644 index ebdeb6591..000000000 --- a/locale/en/config.cfg +++ /dev/null @@ -1,23 +0,0 @@ -[controls] -fp_toggle_main_dialog=Open/Close -fp_confirm=Confirm - -[button-text] -new_subfactory=New Subfactory -edit_subfactory=Edit -delete_subfactory=Delete -delete_subfactory_confirm=Delete? -submit=Submit -cancel=Cancel - -[label] -new_subfactory=New Subfactory -edit_subfactory=Edit Subfactory -subfactory_instruction_1=Enter either or both -subfactory_instruction_2=16 characters max. -subfactory_instruction_3=Alphanumeric only -subfactory_name=Name -subfactory_icon=Icon - -[tooltip] -open_main_dialog=Use __CONTROL__fp_toggle_main_dialog__ to open \ No newline at end of file diff --git a/modfiles/backend/calculation/matrix_engine.lua b/modfiles/backend/calculation/matrix_engine.lua new file mode 100644 index 000000000..679d58eda --- /dev/null +++ b/modfiles/backend/calculation/matrix_engine.lua @@ -0,0 +1,917 @@ +--[[ +Author: Scott Sullivan 2/23/2020 +github: scottmsul + +Algorithm Overview +------------------ +The algorithm is based on the post here: https://kirkmcdonald.github.io/posts/calculation.html +We solve the matrix equation Ax = b, where: + - A is a matrix whose entry in row i and col j is the output/building for item i and recipe j + (negative is input, positive is output) + - x is the vector of unknowns that we're solving for, and whose jth entry will be the # buildings needed for recipe j + - b is the vector whose ith entry is the desired output for item i +Note the current implementation requires a square matrix_engine. +If there are more recipes than items, the problem is under-constrained and some recipes must be deleted. +If there are more items than recipes, the problem is over-constrained (this is more common). + In this case we can construct "pseudo-recipes" for certrain items that produce 1/"building". + Items with pseudo-recipes will be "free" variables that will have some constrained non-zero input or + output after solving. + The solved "number of buildings" will be equal to the extra input or output needed for that item. + Typically these pseudo-recipes will be for external inputs or non-fully-recycled byproducts. +Currently the algorithm assumes any item which is part of at least one input and one output in any recipe + is not a free variable, though the user can click on constrained items in the matrix dialog to make + them free variables. + The dialog calls constrained intermediate items "eliminated" since their output is constrained to zero. +If a recipe has loops, typically the user needs to make voids or free variables. +--]] + +local structures = require("backend.calculation.structures") + +local matrix_engine = {} + + +function matrix_engine.get_recipe_protos(recipe_ids) + local recipe_protos = {} + for i, recipe_id in ipairs(recipe_ids) do + local recipe_proto = prototyper.util.find("recipes", recipe_id, nil) + recipe_protos[i] = recipe_proto + end + return recipe_protos +end + +function matrix_engine.get_item_protos(item_keys) + local item_protos = {} + for i, item_key in ipairs(item_keys) do + local item_proto = matrix_engine.get_item(item_key) + item_protos[i] = item_proto + end + return item_protos +end + +-- for our purposes the string "(item type id)_(item id)" is what we're calling the "item_key" +function matrix_engine.get_item_key(item_type_name, item_name) + local item = prototyper.util.find("items", item_name, item_type_name) + return tostring(item.category_id) .. '_' .. tostring(item.id) +end + +function matrix_engine.get_item(item_key) + local split_str = util.split_string(item_key, "_") + local item_type_id, item_id = split_str[1], split_str[2] + return prototyper.util.find("items", item_id, item_type_id) +end + +-- this is really only used for debugging +function matrix_engine.get_item_name(item_key) + local split_str = util.split_string(item_key, "_") + local item_type_id, item_id = split_str[1], split_str[2] + local item_info = prototyper.util.find("items", item_id, item_type_id) + return item_info.type .. "_" .. item_info.name +end + +function matrix_engine.print_rows(rows) + local s = 'ROWS\n' + for i, k in ipairs(rows.values) do + local item_name = matrix_engine.get_item_name(k) + s = s..'ROW '..i..': '..item_name..'\n' + end + llog(s) +end + +function matrix_engine.print_columns(columns) + local s = 'COLUMNS\n' + for i, k in ipairs(columns.values) do + local col_split_str = util.split_string(k, "_") + if col_split_str[1]=="line" then + s = s..'COL '..i..': '..k..'\n' + else + local item_key = col_split_str[2].."_"..col_split_str[3] + local item_name = matrix_engine.get_item_name(item_key) + s = s..'COL '..i..': '..item_name..'\n' + end + end + llog(s) +end + +function matrix_engine.print_items_set(items) + local item_name_set = {} + for k, _ in pairs(items) do + local item_name = matrix_engine.get_item_name(k) + item_name_set[item_name] = k + end + llog(item_name_set) +end + +function matrix_engine.print_items_list(items) + local item_name_set = {} + for _, k in ipairs(items) do + local item_name = matrix_engine.get_item_name(k) + item_name_set[item_name] = k + end + llog(item_name_set) +end + +function matrix_engine.set_diff(a, b) + local result = {} + for k, _ in pairs(a) do + if not b[k] then + result[k] = true + end + end + return result +end + +function matrix_engine.union_sets(...) + local arg = {...} + local result = {} + for _, set in pairs(arg) do + for val, _ in pairs(set) do + result[val] = true + end + end + return result +end + +function matrix_engine.intersect_sets(...) + local arg = {...} + local counts = {} + local num_sets = #arg + for _, set in pairs(arg) do + for val, _ in pairs(set) do + if not counts[val] then + counts[val] = 1 + else + counts[val] = counts[val] + 1 + end + end + end + local result = {} + for k, count in pairs(counts) do + if count==num_sets then + result[k] = true + end + end + return result +end + +function matrix_engine.num_elements(...) + local arg = {...} + local count = 0 + for _, set in pairs(arg) do + for _, _ in pairs(set) do + count = count + 1 + end + end + return count +end + +function matrix_engine.get_matrix_solver_metadata(factory_data) + local eliminated_items = {} + local free_items = {} + local factory_metadata = matrix_engine.get_factory_metadata(factory_data) + local recipes = factory_metadata.recipes + local all_items = factory_metadata.all_items + local raw_inputs = factory_metadata.raw_inputs + local byproducts = factory_metadata.byproducts + local unproduced_outputs = factory_metadata.unproduced_outputs + local produced_outputs = matrix_engine.set_diff(factory_metadata.desired_outputs, unproduced_outputs) + local free_variables = matrix_engine.union_sets(raw_inputs, byproducts, unproduced_outputs) + local intermediate_items = matrix_engine.set_diff(all_items, free_variables) + if factory_data.matrix_free_items == nil then + eliminated_items = intermediate_items + else + -- by default when a factory is updated, add any new variables to eliminated and let the user select free. + local free_items_list = factory_data.matrix_free_items + for _, free_item in ipairs(free_items_list) do + local identifier = free_item.category_id.."_"..free_item.id + free_items[identifier] = true + end + -- make sure that any items that no longer exist are removed + free_items = matrix_engine.intersect_sets(free_items, intermediate_items) + eliminated_items = matrix_engine.set_diff(intermediate_items, free_items) + end + local num_rows = matrix_engine.num_elements(raw_inputs, byproducts, eliminated_items, free_items) + local num_cols = matrix_engine.num_elements(recipes, raw_inputs, byproducts, free_items) + local result = { + recipes = factory_metadata.recipes, + ingredients = matrix_engine.get_item_protos(matrix_engine.set_to_ordered_list(factory_metadata.raw_inputs)), + products = matrix_engine.get_item_protos(matrix_engine.set_to_ordered_list(produced_outputs)), + byproducts = matrix_engine.get_item_protos(matrix_engine.set_to_ordered_list(factory_metadata.byproducts)), + eliminated_items = matrix_engine.get_item_protos(matrix_engine.set_to_ordered_list(eliminated_items)), + free_items = matrix_engine.get_item_protos(matrix_engine.set_to_ordered_list(free_items)), + num_rows = num_rows, + num_cols = num_cols + } + return result +end + +function matrix_engine.transpose(m) + local transposed = {} + + if #m == 0 then + return transposed + end + + for i=1, #m[1] do + local row = {} + for j=1, #m do + table.insert(row, m[j][i]) + end + table.insert(transposed, row) + end + return transposed +end + +function matrix_engine.get_linear_dependence_data(factory_data, matrix_metadata) + local num_rows = matrix_metadata.num_rows + local num_cols = matrix_metadata.num_cols + + local linearly_dependent_recipes = {} + local linearly_dependent_items = {} + local allowed_free_items = {} + + local linearly_dependent_cols = matrix_engine.run_matrix_solver(factory_data, true) + for col_name, _ in pairs(linearly_dependent_cols) do + local col_split_str = util.split_string(col_name, "_") + if col_split_str[1] == "recipe" then + local recipe_key = col_split_str[2] + linearly_dependent_recipes[recipe_key] = true + else -- "item" + local item_key = col_split_str[2].."_"..col_split_str[3] + linearly_dependent_items[item_key] = true + end + end + -- check which eliminated items could be made free while still retaining linear independence + if #linearly_dependent_cols == 0 and num_cols < num_rows then + local matrix_data = matrix_engine.get_matrix_data(factory_data) + local items = matrix_data.rows + local col_to_item = {} + for k, v in pairs(items.map) do + col_to_item[v] = k + end + + local t_matrix = matrix_engine.transpose(matrix_data.matrix) + table.remove(t_matrix) + matrix_engine.to_reduced_row_echelon_form(t_matrix) + local t_linearly_dependent = matrix_engine.find_linearly_dependent_cols(t_matrix, false) + + local eliminated_items = matrix_metadata.eliminated_items + local eliminated_keys = {} + for _, eliminated_item in ipairs(eliminated_items) do + local key = matrix_engine.get_item_key(eliminated_item.type, eliminated_item.name) + eliminated_keys[key] = eliminated_item + end + + for col, _ in pairs(t_linearly_dependent) do + local item = col_to_item[col] + if eliminated_keys[item] then + allowed_free_items[item] = true + end + end + end + local result = { + linearly_dependent_recipes = matrix_engine.get_recipe_protos( + matrix_engine.set_to_ordered_list(linearly_dependent_recipes)), + linearly_dependent_items = matrix_engine.get_item_protos( + matrix_engine.set_to_ordered_list(linearly_dependent_items)), + allowed_free_items = matrix_engine.get_item_protos( + matrix_engine.set_to_ordered_list(allowed_free_items)) + } + return result +end + +function matrix_engine.get_matrix_data(factory_data) + local matrix_metadata = matrix_engine.get_matrix_solver_metadata(factory_data) + local matrix_free_items = matrix_metadata.free_items + + local factory_metadata = matrix_engine.get_factory_metadata(factory_data) + local all_items = factory_metadata.all_items + local rows = matrix_engine.get_mapping_struct(all_items) + + -- storing the line keys as "line_(lines index 1)_(lines index 2)_..." for arbitrary depths of subfloors + local function get_line_names(prefix, lines) + local line_names = {} + for i, line in ipairs(lines) do + local line_key = prefix.."_"..i + -- these are exclusive because only actual recipes are allowed to be inputs to the matrix solver + if line.subfloor == nil then + line_names[line_key] = true + else + local subfloor_line_names = get_line_names(line_key, line.subfloor.lines) + line_names = matrix_engine.union_sets(line_names, subfloor_line_names) + end + end + return line_names + end + local line_names = get_line_names("line", factory_data.top_floor.lines) + + local raw_free_variables = matrix_engine.union_sets(factory_metadata.raw_inputs, factory_metadata.byproducts) + local free_variables = {} + for k, _ in pairs(raw_free_variables) do + free_variables["item_"..k] = true + end + for _, v in ipairs(matrix_free_items) do + local item_key = matrix_engine.get_item_key(v.type, v.name) + free_variables["item_"..item_key] = true + end + local col_set = matrix_engine.union_sets(line_names, free_variables) + local columns = matrix_engine.get_mapping_struct(col_set) + local matrix = matrix_engine.get_matrix(factory_data, rows, columns) + + return { + matrix = matrix, + rows = rows, + columns = columns, + free_variables = free_variables, + matrix_free_items = matrix_free_items, + } +end + +function matrix_engine.run_matrix_solver(factory_data, check_linear_dependence) + -- run through get_matrix_solver_metadata to check against recipe changes + local factory_metadata = matrix_engine.get_factory_metadata(factory_data) + local matrix_data = matrix_engine.get_matrix_data(factory_data) + local matrix = matrix_data.matrix + local columns = matrix_data.columns + local free_variables = matrix_data.free_variables + local matrix_free_items = matrix_data.matrix_free_items + + matrix_engine.to_reduced_row_echelon_form(matrix) + if check_linear_dependence then + local linearly_dependent_cols = matrix_engine.find_linearly_dependent_cols(matrix, true) + local linearly_dependent_variables = {} + for col, _ in pairs(linearly_dependent_cols) do + local col_name = columns.values[col] + local col_split_str = util.split_string(col_name, "_") + if col_split_str[1] == "line" then + local floor = factory_data.top_floor + for i=2, #col_split_str-1 do + local line_table_id = col_split_str[i] + floor = floor.lines[line_table_id].subfloor + end + local line_table_id = col_split_str[#col_split_str] + local line = floor.lines[line_table_id] + local recipe_id = line.recipe_proto.id + linearly_dependent_variables["recipe_"..recipe_id] = true + else -- item + linearly_dependent_variables[col_name] = true + end + end + return linearly_dependent_variables + end + + local function set_line_results(prefix, floor) + local floor_aggregate = structures.aggregate.init(factory_data.player_index, floor.id) + for i, line in ipairs(floor.lines) do + local line_key = prefix.."_"..i + local line_aggregate = nil + if line.subfloor == nil then + local col_num = columns.map[line_key] + -- want the j-th entry in the last column (output of row-reduction) + local machine_count = matrix[col_num][#columns.values+1] + if machine_count < 0 then + machine_count = 0 + end + line_aggregate = matrix_engine.get_line_aggregate(line, factory_data.player_index, floor.id, + machine_count, factory_metadata, free_variables) + else + line_aggregate = set_line_results(prefix.."_"..i, line.subfloor) + matrix_engine.consolidate(line_aggregate) + end + + -- Lines with subfloors show actual number of machines to build, so each counts are rounded up when summed + floor_aggregate.machine_count = floor_aggregate.machine_count + + math.ceil(line_aggregate.machine_count - 0.001) + + -- add line_aggregate to floor_aggregate first to track fuel as ingredient higher up + floor_aggregate.energy_consumption = floor_aggregate.energy_consumption + line_aggregate.energy_consumption + floor_aggregate.emissions = floor_aggregate.emissions + line_aggregate.emissions + + for _, class in pairs{"Product", "Byproduct", "Ingredient"} do + for _, item in pairs(structures.class.list(line_aggregate[class])) do + structures.class.add(floor_aggregate[class], item) + end + end + + -- remove fuel from Ingredient for display only + if line_aggregate.fuel then + structures.class.subtract(line_aggregate.Ingredient, line_aggregate.fuel, line_aggregate.fuel_amount) + end + + -- need to call consolidate before set_line_result to net any non-fuel catalysts for display + matrix_engine.consolidate(line_aggregate) + + -- Set machine count back to nothing if the recipe doesn't require energy + local machine_count = (line.recipe_proto.energy == 0) and 0 or line_aggregate.machine_count + + solver.set_line_result { + player_index = factory_data.player_index, + floor_id = floor.id, + line_id = line.id, + machine_count = machine_count, + energy_consumption = line_aggregate.energy_consumption, + emissions = line_aggregate.emissions, + production_ratio = line_aggregate.production_ratio, + Product = line_aggregate.Product, + Byproduct = line_aggregate.Byproduct, + Ingredient = line_aggregate.Ingredient, + fuel_amount = line_aggregate.fuel_amount + } + end + return floor_aggregate + end + + local top_floor_aggregate = set_line_results("line", factory_data.top_floor) + + local total = structures.class.init() + for _, item in ipairs(structures.class.list(top_floor_aggregate.Product)) do + structures.class.add(total, item) + end + for _, item in ipairs(structures.class.list(top_floor_aggregate.Byproduct)) do + structures.class.add(total, item) + end + for _, item in ipairs(structures.class.list(top_floor_aggregate.Ingredient)) do + structures.class.subtract(total, item) + end + + local required_amount = {} + for _, product in pairs(factory_data.top_floor.products) do + local key = matrix_engine.get_item_key(product.type, product.name) + required_amount[key] = product.amount + end + + local main_aggregate = structures.aggregate.init(factory_data.player_index, 1) + for _, item in ipairs(structures.class.list(total)) do + local key = matrix_engine.get_item_key(item.type, item.name) + local req = required_amount[key] or 0 + local amount = item.amount - req + if amount > 0 then + structures.class.add(main_aggregate.Byproduct, item, amount) + else + structures.class.add(main_aggregate.Ingredient, item, -amount) + end + end + + -- set products for unproduced items + for _, product in pairs(factory_data.top_floor.products) do + local item_key = matrix_engine.get_item_key(product.type, product.name) + if not factory_metadata.unproduced_outputs[item_key] then + local item = matrix_engine.get_item(item_key) + structures.class.add(main_aggregate.Product, item, product.amount) + end + end + + solver.set_factory_result { + player_index = factory_data.player_index, + factory_id = factory_data.factory_id, + energy_consumption = top_floor_aggregate.energy_consumption, + emissions = top_floor_aggregate.emissions, + Product = main_aggregate.Product, + Byproduct = main_aggregate.Byproduct, + Ingredient = main_aggregate.Ingredient, + matrix_free_items = matrix_free_items + } +end + +-- If an aggregate has items that are both inputs and outputs, deletes whichever is smaller and saves the net amount. +-- If the input and output are identical to within rounding error, delete from both. +-- This is mainly for calculating line aggregates with subfloors for the matrix solver. +function matrix_engine.consolidate(aggregate) + -- Items cannot be both products or byproducts, but they can be both ingredients and fuels. + -- In the case that an item appears as an output, an ingredient, and a fuel, delete from fuel first. + local function compare_classes(input_class, output_class) + for _, output_item in pairs(structures.class.list(aggregate[output_class])) do + local input_amount = aggregate[input_class][output_item.type][output_item.name] or 0 + local net_amount = output_item.amount - input_amount + if net_amount > 0 then + structures.class.subtract(aggregate[input_class], output_item, input_amount) + structures.class.subtract(aggregate[output_class], output_item, input_amount) + else + structures.class.subtract(aggregate[input_class], output_item) + structures.class.subtract(aggregate[output_class], output_item) + end + end + end + compare_classes("Ingredient", "Product") + compare_classes("Ingredient", "Byproduct") +end + + +-- finds inputs and outputs for each line and desired outputs +function matrix_engine.get_factory_metadata(factory_data) + local desired_outputs = {} + for _, product in pairs(factory_data.top_floor.products) do + local item_key = matrix_engine.get_item_key(product.type, product.name) + desired_outputs[item_key] = true + end + local lines_metadata = matrix_engine.get_lines_metadata(factory_data.top_floor.lines, + factory_data.player_index) + local line_inputs = lines_metadata.line_inputs + local line_outputs = lines_metadata.line_outputs + local unproduced_outputs = matrix_engine.set_diff(desired_outputs, line_outputs) + local all_items = matrix_engine.union_sets(line_inputs, line_outputs) + local raw_inputs = matrix_engine.set_diff(line_inputs, line_outputs) + local byproducts = matrix_engine.set_diff(matrix_engine.set_diff(line_outputs, line_inputs), desired_outputs) + return { + recipes = lines_metadata.line_recipes, + desired_outputs = desired_outputs, + all_items = all_items, + raw_inputs = raw_inputs, + byproducts = byproducts, + unproduced_outputs = unproduced_outputs + } +end + +function matrix_engine.get_lines_metadata(lines, player_index) + local line_recipes = {} + local line_inputs = {} + local line_outputs = {} + for _, line in pairs(lines) do + if line.subfloor ~= nil then + local floor_metadata = matrix_engine.get_lines_metadata(line.subfloor.lines, player_index) + for _, subfloor_line_recipe in pairs(floor_metadata.line_recipes) do + table.insert(line_recipes, subfloor_line_recipe) + end + line_inputs = matrix_engine.union_sets(line_inputs, floor_metadata.line_inputs) + line_outputs = matrix_engine.union_sets(line_outputs, floor_metadata.line_outputs) + else + local line_aggregate = matrix_engine.get_line_aggregate(line, player_index, 1, 1) + matrix_engine.consolidate(line_aggregate) + for item_type_name, item_data in pairs(line_aggregate.Ingredient) do + for item_name, _ in pairs(item_data) do + local item_key = matrix_engine.get_item_key(item_type_name, item_name) + line_inputs[item_key] = true + end + end + for item_type_name, item_data in pairs(line_aggregate.Product) do + for item_name, _ in pairs(item_data) do + local item_key = matrix_engine.get_item_key(item_type_name, item_name) + line_outputs[item_key] = true + end + end + table.insert(line_recipes, line.recipe_proto.id) + end + end + return { + line_recipes = line_recipes, + line_inputs = line_inputs, + line_outputs = line_outputs + } +end + +function matrix_engine.get_matrix(factory_data, rows, columns) + -- Returns the matrix to be solved. + -- Format is a list of lists, where outer lists are rows and inner lists are columns. + -- Rows are items and columns are recipes (or pseudo-recipes in the case of free items). + -- Elements have units of items/building, and are positive for outputs and negative for inputs. + + -- initialize matrix to all zeros + local matrix = {} + for _=1, #rows.values do + local row = {} + for _=1, #columns.values+1 do -- extra +1 for desired output column + table.insert(row, 0) + end + table.insert(matrix, row) + end + + -- loop over columns since it's easier to look up items for lines/free vars than vice-versa + for col_num=1, #columns.values do + local col_str = columns.values[col_num] + local col_split_str = util.split_string(col_str, "_") + local col_type = col_split_str[1] + if col_type == "item" then + local item_id = col_split_str[2].."_"..col_split_str[3] + local row_num = rows.map[item_id] + matrix[row_num][col_num] = 1 + else -- "line" + local floor = factory_data.top_floor + for i=2, #col_split_str-1 do + local line_table_id = col_split_str[i] + floor = floor.lines[line_table_id].subfloor + end + local line_table_id = col_split_str[#col_split_str] + local line = floor.lines[line_table_id] + + -- use amounts for 1 building as matrix entries + local line_aggregate = matrix_engine.get_line_aggregate(line, factory_data.player_index, + floor.id, 1) + matrix_engine.consolidate(line_aggregate) + + for item_type_name, items in pairs(line_aggregate.Product) do + for item_name, amount in pairs(items) do + local item_key = matrix_engine.get_item_key(item_type_name, item_name) + local row_num = rows.map[item_key] + matrix[row_num][col_num] = matrix[row_num][col_num] + amount + end + end + + for item_type_name, items in pairs(line_aggregate.Ingredient) do + for item_name, amount in pairs(items) do + local item_key = matrix_engine.get_item_key(item_type_name, item_name) + local row_num = rows.map[item_key] + matrix[row_num][col_num] = matrix[row_num][col_num] - amount + end + end + end + end + + -- final column for desired output. Don't have to explicitly set constrained vars to zero + -- since matrix is initialized with zeros. + for _, product in ipairs(factory_data.top_floor.products) do + local item_id = matrix_engine.get_item_key(product.type, product.name) + local row_num = rows.map[item_id] -- will be nil for unproduced outputs + if row_num ~= nil then + local amount = product.amount + matrix[row_num][#columns.values+1] = amount + end + end + + return matrix +end + +function matrix_engine.get_line_aggregate(line_data, player_index, floor_id, machine_count, factory_metadata, free_variables) + local line_aggregate = structures.aggregate.init(player_index, floor_id) + line_aggregate.machine_count = machine_count + -- the index in the factory_data.top_floor.lines table can be different from the line_id! + local recipe_proto = line_data.recipe_proto + local total_effects = line_data.total_effects + local machine_speed = line_data.machine_speed + local speed_multiplier = 1 + total_effects.speed + local energy = recipe_proto.energy + -- hacky workaround for recipes with zero energy - this really messes up the matrix + if energy==0 then energy=0.000001 end + local time_per_craft = energy / (machine_speed * speed_multiplier) + local total_crafts = machine_count * (1 / time_per_craft) + line_aggregate.production_ratio = total_crafts + + for _, product in pairs(recipe_proto.products) do + local prodded_amount = solver_util.determine_prodded_amount(product, total_effects, + recipe_proto.maximum_productivity) + local item_key = matrix_engine.get_item_key(product.type, product.name) + if factory_metadata ~= nil and (factory_metadata.byproducts[item_key] or free_variables["item_"..item_key]) then + structures.class.add(line_aggregate.Byproduct, product, prodded_amount * total_crafts) + else + structures.class.add(line_aggregate.Product, product, prodded_amount * total_crafts) + end + end + + for _, ingredient in pairs(line_data.ingredients) do + structures.class.add(line_aggregate.Ingredient, ingredient, + ingredient.amount * total_crafts * line_data.resource_drain_rate) + end + + -- Determine energy consumption (including potential fuel needs) and emissions + local fuel_proto = line_data.fuel_proto + local energy_consumption, emissions = solver_util.determine_energy_consumption_and_emissions( + line_data.machine_proto, line_data.recipe_proto, fuel_proto, machine_count, + line_data.total_effects, line_data.pollutant_type) + + local fuel, fuel_amount = nil, nil + if fuel_proto ~= nil then -- Seeing a fuel_proto here means it needs to be re-calculated + fuel_amount = solver_util.determine_fuel_amount(energy_consumption, line_data.machine_proto.burner, + fuel_proto.fuel_value) + + fuel = {type=line_data.fuel_item.type, name=line_data.fuel_item.name, amount=fuel_amount} + structures.class.add(line_aggregate.Ingredient, fuel, fuel_amount) + + if fuel_proto.burnt_result then + local burnt = {type="item", name=fuel_proto.burnt_result, amount=fuel_amount} + local burnt_key = matrix_engine.get_item_key(burnt.type, burnt.name) + if factory_metadata ~= nil and (factory_metadata.byproducts[burnt_key] or free_variables["item_"..burnt_key]) then + structures.class.add(line_aggregate.Byproduct, burnt, fuel_amount) + else + structures.class.add(line_aggregate.Product, burnt, fuel_amount) + end + end + + energy_consumption = 0 -- set electrical consumption to 0 when fuel is used + + elseif line_data.machine_proto.energy_type == "void" then + energy_consumption = 0 -- set electrical consumption to 0 while still polluting + end + + -- Include beacon energy consumption + energy_consumption = energy_consumption + (line_data.beacon_consumption or 0) + + line_aggregate.energy_consumption = energy_consumption + line_aggregate.emissions = emissions + + -- needed for interface.set_line_result + line_aggregate.fuel = fuel + line_aggregate.fuel_amount = fuel_amount + + return line_aggregate +end + +function matrix_engine.print_matrix(m) + local s = "" + s = s.."{\n" + for _, row in ipairs(m) do + s = s.." {" + for j,col in ipairs(row) do + s = s..(col) + if j<#row then + s = s.." " + end + end + s = s.."}\n" + end + s = s.."}" + llog(s) +end + +function matrix_engine.get_mapping_struct(input_set) + -- turns a set into a mapping struct (eg matrix rows or columns) + -- a "mapping struct" consists of a table with: + -- key "values" - array of set values in sort order + -- key "map" - map from input_set values to integers, where the integer is the position in "values" + local values = matrix_engine.set_to_ordered_list(input_set) + local map = {} + for i,k in ipairs(values) do + map[k] = i + end + local result = { + values = values, + map = map + } + return result +end + +function matrix_engine.set_to_ordered_list(s) + local result = {} + for k, _ in pairs(s) do table.insert(result, k) end + table.sort(result) + return result +end + +-- Contains the raw matrix solver. Converts an NxN+1 matrix to reduced row-echelon form. +-- Based on the algorithm from octave: https://fossies.org/dox/FreeMat-4.2-Source/rref_8m_source.html +function matrix_engine.to_reduced_row_echelon_form(m) + local num_rows = #m + if #m==0 then return m end + local num_cols = #m[1] + + -- set tolerance based on max value in matrix + local max_value = 0 + for i = 1, num_rows do + for j = 1, num_cols do + if math.abs(m[i][j]) > max_value then + max_value = math.abs(m[i][j]) + end + end + end + local tolerance = 1e-10 * max_value + + local pivot_row = 1 + + for curr_col = 1, num_cols do + -- find row with highest value in curr col as next pivot + local max_pivot_index = pivot_row + local max_pivot_value = m[pivot_row][curr_col] + for curr_row = pivot_row+1, num_rows do -- does this need an if-wrapper? + local curr_pivot_value = math.abs(m[curr_row][curr_col]) + if math.abs(m[curr_row][curr_col]) > math.abs(max_pivot_value) then + max_pivot_index = curr_row + max_pivot_value = curr_pivot_value + end + end + + if math.abs(max_pivot_value) < tolerance then + -- if highest value is approximately zero, set this row and all rows below to zero + for zero_row = pivot_row, num_rows do + m[zero_row][curr_col] = 0 + end + else + -- swap current row with highest value row + for swap_col = curr_col, num_cols do + local temp = m[pivot_row][swap_col] + m[pivot_row][swap_col] = m[max_pivot_index][swap_col] + m[max_pivot_index][swap_col] = temp + end + + -- normalize pivot row + local factor = m[pivot_row][curr_col] + for normalize_col = curr_col, num_cols do + m[pivot_row][normalize_col] = m[pivot_row][normalize_col] / factor + end + + -- find nonzero cols in this row for the elimination step + local nonzero_pivot_cols = {} + for update_col = curr_col+1, num_cols do + local curr_pivot_col_value = m[pivot_row][update_col] + if curr_pivot_col_value ~= 0 then + nonzero_pivot_cols[update_col] = curr_pivot_col_value + end + end + + -- eliminate current column from other rows + for update_row = 1, pivot_row - 1 do + if m[update_row][curr_col] ~= 0 then + for update_col, pivot_col_value in pairs(nonzero_pivot_cols) do + m[update_row][update_col] = m[update_row][update_col] - m[update_row][curr_col]*pivot_col_value + end + m[update_row][curr_col] = 0 + end + end + for update_row = pivot_row+1, num_rows do + if m[update_row][curr_col] ~= 0 then + for update_col, pivot_col_value in pairs(nonzero_pivot_cols) do + m[update_row][update_col] = m[update_row][update_col] - m[update_row][curr_col]*pivot_col_value + end + m[update_row][curr_col] = 0 + end + end + + -- only add 1 if there is another leading 1 row + pivot_row = pivot_row + 1 + + if pivot_row > num_rows then + break + end + end + end +end + +function matrix_engine.find_linearly_dependent_cols(matrix, ignore_last) + -- Returns linearly dependent columns from a row-reduced matrix + -- Algorithm works as follows: + -- For each column: + -- If this column has a leading 1, track which row maps to this column using the ones_map variable (eg cols 1, 2, 3, 5) + -- Otherwise, this column is linearly dependent (eg col 4) + -- For any non-zero rows in this col, the col which contains that row's leading 1 is also linearly dependent + -- (eg for col 4, we have row 2 -> col 2 and row 3 -> col 3) + -- The example below would give cols 2, 3, 4 as being linearly dependent (x's are non-zeros) + -- 1 0 0 0 0 + -- 0 1 x x 0 + -- 0 0 1 x 0 + -- 0 0 0 0 1 + -- I haven't proven this is 100% correct, this is just something I came up with + local row_index = 1 + local num_rows = #matrix + local num_cols = #matrix[1] + if ignore_last then + num_cols = num_cols - 1 + end + local ones_map = {} + local col_set = {} + for col_index=1, num_cols do + if (row_index <= num_rows) and (matrix[row_index][col_index]==1) then + ones_map[row_index] = col_index + row_index = row_index+1 + else + col_set[col_index] = true + for i=1, row_index-1 do + if matrix[i][col_index] ~= 0 then + col_set[ones_map[i]] = true + end + end + end + end + return col_set +end + +-- utility function that removes from a sorted array in place +function matrix_engine.remove(orig_table, value) + local i = 1 + local found = false + while i<=#orig_table and (not found) do + local curr = orig_table[i] + if curr >= value then + found = true + end + if curr == value then + table.remove(orig_table, i) + end + i = i+1 + end +end + +-- utility function that inserts into a sorted array in place +function matrix_engine.insert(orig_table, value) + local i = 1 + local found = false + while i<=#orig_table and (not found) do + local curr = orig_table[i] + if curr >= value then + found=true + end + if curr > value then + table.insert(orig_table, i, value) + end + i = i+1 + end + if not found then + table.insert(orig_table, value) + end +end + +-- Shallowly and naively copys the base level of the given table +function matrix_engine.shallowcopy(table) + local copy = {} + for key, value in pairs(table) do + copy[key] = value + end + return copy +end + +return matrix_engine diff --git a/modfiles/backend/calculation/sequential_engine.lua b/modfiles/backend/calculation/sequential_engine.lua new file mode 100644 index 000000000..1f5f7a7aa --- /dev/null +++ b/modfiles/backend/calculation/sequential_engine.lua @@ -0,0 +1,291 @@ +local structures = require("backend.calculation.structures") + +-- Contains the 'meat and potatoes' calculation model that struggles with some more complex setups +local sequential_engine = {} + +-- ** LOCAL UTIL ** +local function update_line(line_data, aggregate, looped_fuel) + local recipe_proto = line_data.recipe_proto + local machine_proto = line_data.machine_proto + local total_effects = line_data.total_effects + + local relevant_products, byproducts = {}, {} + local fuel_proto, original_aggregate = line_data.fuel_proto, nil + + for _, product in pairs(recipe_proto.products) do + -- Determine relevant products + if aggregate.Ingredient[product.type][product.name] ~= nil then + table.insert(relevant_products, product) + else + table.insert(byproducts, product) + end + + -- Prepare if this line produces its own fuel + if looped_fuel == nil and fuel_proto ~= nil then -- don't loop if this is already the loop + if product.type == fuel_proto.type and product.name == fuel_proto.name then + original_aggregate = aggregate + aggregate = ftable.deep_copy(aggregate) + end + end + end + + -- Determine production ratio + local production_ratio = 0 + + -- Determines the production ratio that would be needed to fully satisfy the given product + local function determine_production_ratio(relevant_product) + local demand = aggregate.Ingredient[relevant_product.type][relevant_product.name] + local prodded_amount = solver_util.determine_prodded_amount(relevant_product, + total_effects, recipe_proto.maximum_productivity) + return (demand * (line_data.percentage / 100)) / prodded_amount + end + + local relevant_product_count = #relevant_products + if relevant_product_count == 1 then + local relevant_product = relevant_products[1] + production_ratio = determine_production_ratio(relevant_product) + + elseif relevant_product_count >= 2 then + local priority_proto = line_data.priority_product_proto + + for _, relevant_product in ipairs(relevant_products) do + if priority_proto ~= nil then -- Use the priority product to determine the production ratio, if it's set + if relevant_product.type == priority_proto.type and relevant_product.name == priority_proto.name then + production_ratio = determine_production_ratio(relevant_product) + break + end + + else -- Otherwise, determine the highest production ratio needed to fulfill every demand + local ratio = determine_production_ratio(relevant_product) + production_ratio = math.max(production_ratio, ratio) + end + end + end + + local crafts_per_second = (line_data.machine_speed * (1 + total_effects.speed)) / recipe_proto.energy + + -- Limit the machine_count by reducing the production_ratio, if necessary + local machine_limit = line_data.machine_limit + if machine_limit.limit ~= nil and recipe_proto.energy > 0 then + local capped_production_ratio = crafts_per_second * machine_limit.limit + production_ratio = machine_limit.force_limit and capped_production_ratio + or math.min(production_ratio, capped_production_ratio) + end + + + -- Determines the amount of the given item, considering productivity + local function determine_amount_with_productivity(item) + local prodded_amount = solver_util.determine_prodded_amount( + item, total_effects, recipe_proto.maximum_productivity) + return prodded_amount * production_ratio + end + + -- Determine byproducts + local Byproduct = structures.class.init() + for _, byproduct in pairs(byproducts) do + local byproduct_amount = determine_amount_with_productivity(byproduct) + + structures.class.add(Byproduct, byproduct, byproduct_amount) + structures.class.add(aggregate.Byproduct, byproduct, byproduct_amount) + end + + -- Determine products + local Product = structures.class.init() + for _, product in ipairs(relevant_products) do + local product_amount = determine_amount_with_productivity(product) + local product_demand = aggregate.Ingredient[product.type][product.name] or 0 + + if product_amount > product_demand then + local overflow_amount = product_amount - product_demand + structures.class.add(Byproduct, product, overflow_amount) + structures.class.add(aggregate.Byproduct, product, overflow_amount) + product_amount = product_demand -- desired amount + end + + structures.class.add(Product, product, product_amount) + structures.class.subtract(aggregate.Ingredient, product, product_amount) + end + + -- Determine ingredients + local Ingredient = structures.class.init() + for _, ingredient in pairs(line_data.ingredients) do + local ingredient_amount = (ingredient.amount * production_ratio * line_data.resource_drain_rate) + + structures.class.add(Ingredient, ingredient, ingredient_amount) + + -- Reduce line-byproducts and -ingredients so only the net amounts remain + local byproduct_amount = Byproduct[ingredient.type][ingredient.name] + if byproduct_amount ~= nil then + structures.class.subtract(Byproduct, ingredient, ingredient_amount) + structures.class.subtract(Ingredient, ingredient, byproduct_amount) + end + end + structures.class.balance_items(Ingredient, aggregate.Byproduct, aggregate.Ingredient) + + + -- Determine machine count + local machine_count = production_ratio / crafts_per_second + -- Add the integer machine count to the aggregate so it can be displayed on the origin_line + aggregate.machine_count = aggregate.machine_count + math.ceil(machine_count - 0.001) + + + -- Determine energy consumption (including potential fuel needs) and emissions + local energy_consumption, emissions = solver_util.determine_energy_consumption_and_emissions( + machine_proto, recipe_proto, fuel_proto, machine_count, total_effects, line_data.pollutant_type) + + local fuel_amount = nil + if fuel_proto ~= nil then + local fuel_item = line_data.fuel_item + fuel_amount = solver_util.determine_fuel_amount(energy_consumption, machine_proto.burner, + fuel_proto.fuel_value) + + if original_aggregate ~= nil then -- meaning this line produces its own fuel + local ingredient_class = original_aggregate.Ingredient[fuel_item.type] + local initial_demand = ingredient_class[fuel_item.name] + local ratio = fuel_amount / initial_demand + + if ratio + 0.001 < 1 then -- a ratio >= 1 means this can't outproduce itself + -- Need a lot of precision here, hence the exponent of 20 + local bumped_demand = initial_demand * ((1 - ratio ^ 20) / (1 - ratio)) + ingredient_class[fuel_item.name] = bumped_demand + + -- Run line with fuel amount bumped to account for own consumption + update_line(line_data, original_aggregate, bumped_demand - initial_demand) + return + end + end + + -- Removed looped fuel from main aggregate as its used right away + local corrected_amount = fuel_amount - (looped_fuel or 0) + local fuel_item = {type=fuel_item.type, name=fuel_item.name, amount=corrected_amount} + structures.class.add(aggregate.Ingredient, fuel_item) -- add to floor + -- Fuel is set via a special amount variable on the line itself + + if fuel_proto.burnt_result then + local burnt = {type="item", name=fuel_proto.burnt_result, amount=fuel_amount} + structures.class.add(Byproduct, burnt) -- add to line + structures.class.add(aggregate.Byproduct, burnt) -- add to floor + end + + energy_consumption = 0 -- set electrical consumption to 0 when fuel is used + + elseif machine_proto.energy_type == "void" then + energy_consumption = 0 -- set electrical consumption to 0 while still polluting + end + + -- Include beacon energy consumption + energy_consumption = energy_consumption + (line_data.beacon_consumption or 0) + + aggregate.energy_consumption = aggregate.energy_consumption + energy_consumption + aggregate.emissions = aggregate.emissions + emissions + + + -- Update the actual line with the calculated results + solver.set_line_result { + player_index = aggregate.player_index, + floor_id = aggregate.floor_id, + line_id = line_data.id, + machine_count = machine_count, + energy_consumption = energy_consumption, + emissions = emissions, + production_ratio = production_ratio, + Product = Product, + Byproduct = Byproduct, + Ingredient = Ingredient, + fuel_amount = fuel_amount + } +end + + +local function update_floor(floor_data, aggregate) + local desired_products = structures.class.list(aggregate.Ingredient) + + for _, line_data in ipairs(floor_data.lines) do + local subfloor = line_data.subfloor + if subfloor ~= nil then + -- Determine the products that are relevant for this subfloor + local subfloor_aggregate = structures.aggregate.init(aggregate.player_index, subfloor.id) + for _, product in pairs(line_data.recipe_proto.products) do + local ingredient_amount = aggregate.Ingredient[product.type][product.name] + if ingredient_amount then + structures.class.add(subfloor_aggregate.Ingredient, product, ingredient_amount) + end + end + + local floor_products = structures.class.list(subfloor_aggregate.Ingredient) + update_floor(subfloor, subfloor_aggregate) -- updates aggregate + + + for _, desired_product in pairs(floor_products) do + local ingredient_amount = aggregate.Product[desired_product.type][desired_product.name] or 0 + local produced_amount = desired_product.amount - ingredient_amount + structures.class.subtract(aggregate.Ingredient, desired_product, produced_amount) + end + + structures.class.balance_items(subfloor_aggregate.Ingredient, aggregate.Byproduct, aggregate.Ingredient) + structures.class.balance_items(subfloor_aggregate.Byproduct, aggregate.Product, aggregate.Byproduct) + + -- Update the main aggregate with the results + aggregate.machine_count = aggregate.machine_count + subfloor_aggregate.machine_count + aggregate.energy_consumption = aggregate.energy_consumption + subfloor_aggregate.energy_consumption + aggregate.emissions = aggregate.emissions + subfloor_aggregate.emissions + + + -- Update the parent line of the subfloor with the results from the subfloor aggregate + solver.set_line_result { + player_index = aggregate.player_index, + floor_id = aggregate.floor_id, + line_id = line_data.id, + machine_count = subfloor_aggregate.machine_count, + energy_consumption = subfloor_aggregate.energy_consumption, + emissions = subfloor_aggregate.emissions, + production_ratio = nil, + Product = subfloor_aggregate.Product, + Byproduct = subfloor_aggregate.Byproduct, + Ingredient = subfloor_aggregate.Ingredient, + fuel_amount = nil + } + else + -- Update aggregate according to the current line, which also adjusts the respective line object + update_line(line_data, aggregate, nil) -- updates aggregate + end + end + + -- Desired products that aren't ingredients anymore have been produced + for _, desired_product in pairs(desired_products) do + local ingredient_amount = aggregate.Ingredient[desired_product.type][desired_product.name] or 0 + local produced_amount = desired_product.amount - ingredient_amount + structures.class.add(aggregate.Product, desired_product, produced_amount) + end +end + + +-- ** TOP LEVEL ** +function sequential_engine.update_factory(factory_data) + -- Initialize aggregate with the top level items + local aggregate = structures.aggregate.init(factory_data.player_index, 1) + for _, product in pairs(factory_data.top_floor.products) do + structures.class.add(aggregate.Ingredient, product) + end + + update_floor(factory_data.top_floor, aggregate) -- updates aggregate + + -- Remove any top level items that are still ingredients, meaning unproduced + for _, product in pairs(factory_data.top_floor.products) do + local ingredient_amount = aggregate.Ingredient[product.type][product.name] or 0 + structures.class.subtract(aggregate.Ingredient, product, ingredient_amount) + end + + -- Fuels are combined with ingredients for top-level purposes + solver.set_factory_result { + player_index = factory_data.player_index, + factory_id = factory_data.factory_id, + energy_consumption = aggregate.energy_consumption, + emissions = aggregate.emissions, + Product = aggregate.Product, + Byproduct = aggregate.Byproduct, + Ingredient = aggregate.Ingredient + } +end + +return sequential_engine diff --git a/modfiles/backend/calculation/solver.lua b/modfiles/backend/calculation/solver.lua new file mode 100644 index 000000000..1905400fb --- /dev/null +++ b/modfiles/backend/calculation/solver.lua @@ -0,0 +1,418 @@ +local sequential_engine = require("backend.calculation.sequential_engine") +local matrix_engine = require("backend.calculation.matrix_engine") +local structures = require("backend.calculation.structures") + +solver, solver_util = {}, {} + +-- ** LOCAL UTIL ** +local function set_blank_line(player, floor, line) + local blank_class = structures.class.init() + solver.set_line_result { + player_index = player.index, + floor_id = floor.id, + line_id = line.id, + machine_count = 0, + energy_consumption = 0, + emissions = 0, + production_ratio = (line.class == "Line") and 0 or nil, + Product = blank_class, + Byproduct = blank_class, + Ingredient = blank_class, + fuel_amount = 0 + } +end + +local function set_blank_floor(player, floor) + for line in floor:iterator() do + if line.class == "Floor" then + set_blank_line(player, floor, line) + set_blank_floor(player, line) + else + set_blank_line(player, floor, line) + end + end +end + +local function set_blank_factory(player, factory) + local blank_class = structures.class.init() + + solver.set_factory_result { + player_index = player.index, + factory_id = factory.id, + energy_consumption = 0, + emissions = 0, + Product = blank_class, + Byproduct = blank_class, + Ingredient = blank_class, + matrix_free_items = factory.matrix_free_items + } + + set_blank_floor(player, factory.top_floor) +end + + +local function factory_products(factory) + local products = {} + for product in factory:iterator() do + local product_data = { + name = product.proto.name, + type = product.proto.type, + amount = product:get_required_amount() + } + table.insert(products, product_data) + end + return products +end + +local function get_temperature_name(line, ingredient) + if ingredient.type == "fluid" then + local temperature = line.temperatures[ingredient.name] + return (temperature ~= nil) and (ingredient.name .. "-" .. temperature) or nil + else + return ingredient.name + end +end + +local function line_ingredients(line) + local ingredients = {} + for _, ingredient in pairs(line.recipe_proto.ingredients) do + local name = get_temperature_name(line, ingredient) + -- If any relevant ingredient has no temperature set, this line is invalid + if name == nil then return nil end + + table.insert(ingredients, { + name = name, + type = ingredient.type, + amount = ingredient.amount + }) -- don't need min/max temperatures here + end + return ingredients +end + + +-- Generates structured data of the given floor for calculation +local function generate_floor_data(player, factory, floor) + local floor_data = { + id = floor.id, + products = (floor.level == 1) and factory_products(factory) + or floor.first.recipe_proto.products, + lines = {} + } + + for line in floor:iterator() do + local line_data = { id = line.id } + + if line.class == "Floor" then + line_data.recipe_proto = line.first.recipe_proto + line_data.subfloor = generate_floor_data(player, factory, line) + table.insert(floor_data.lines, line_data) + else + local relevant_line = (line.parent.level > 1) and line.parent.first or nil --[[@as Line]] + local ingredients = line_ingredients(line) -- builds in chosen temperatures + + local fuel = line.machine.fuel + local missing_fuel_temp = (fuel and fuel.proto.type == "fluid" and not fuel.temperature) + + -- If a line has a percentage of zero or is inactive, it is not useful to the result of the factory + -- Alternatively, if this line is on a subfloor and the top line of the floor is useless, it is useless too + if (relevant_line and (relevant_line.percentage == 0 or not relevant_line.active)) + or line.percentage == 0 or not line.active or not line:get_surface_compatibility().overall + or (not factory.matrix_free_items and line.production_type == "consume") + or ingredients == nil or missing_fuel_temp == true then + set_blank_line(player, floor, line) -- useless lines don't need to run through the solver + else + local machine = line.machine + line_data.recipe_proto = line.recipe_proto + line_data.ingredients = ingredients + line_data.percentage = line.percentage -- non-zero + line_data.production_type = line.production_type + line_data.priority_product_proto = line.priority_product + line_data.machine_proto = machine.proto + line_data.machine_limit = {limit=machine.limit, force_limit=machine.force_limit} + line_data.fuel_proto = machine.fuel and machine.fuel.proto or nil + line_data.pollutant_type = factory.parent.location_proto.pollutant_type + + -- Quality effects + local machine_speed = machine.proto.speed + local resource_drain_rate = machine.proto.resource_drain_rate or 1 + + local prototype_category = machine.proto.prototype_category + if prototype_category == "mining_drill" then + resource_drain_rate = resource_drain_rate + * machine.quality_proto.mining_drill_resource_drain_multiplier + elseif prototype_category ~= nil then -- anything non-custom + machine_speed = machine_speed * machine.quality_proto.multiplier + end + line_data.machine_speed = machine_speed + line_data.resource_drain_rate = resource_drain_rate + + -- Effects - update line with recipe effects here if applicable + machine:update_recipe_effects(player.force, factory) + line_data.total_effects = line.total_effects + + -- Beacon total - can be calculated here, which is faster and simpler + local beacon = line.beacon + if beacon ~= nil and beacon.total_amount ~= nil then + line_data.beacon_consumption = beacon.proto.energy_usage * beacon.total_amount * 60 + * beacon.quality_proto.beacon_power_usage_multiplier + end + + local fuel = machine.fuel + if fuel then -- will have a temperature configured if applicable + if fuel.proto.type == "fluid" then + line_data.fuel_item = {name=fuel.proto.name .. "-" .. fuel.temperature, type="fluid"} + else + line_data.fuel_item = {name=fuel.proto.name, type=fuel.proto.type} + end + end + + table.insert(floor_data.lines, line_data) + end + end + end + + return floor_data +end + + +---@class SimpleItem +---@field proto FPItemPrototype +---@field amount number +---@field satisfied_amount number? + +local function item_comparator(a, b) + local a_type, b_type = a.proto.type, b.proto.type + if a_type < b_type then return false + elseif a_type > b_type then return true + elseif a.amount < b.amount then return false + elseif a.amount > b.amount then return true end + return false +end + +local function update_object_items(object, item_category, item_results) + local item_list = {} + + for _, item_result in pairs(structures.class.list(item_results)) do + local item_proto = prototyper.util.find("items", item_result.name, item_result.type) --[[@as FPItemPrototype]] + + -- Floor items keep their temperature, since they can't be configured from there + if object.class ~= "Floor" and item_category == "ingredients" and item_proto.base_name then + item_proto = prototyper.util.find("items", item_proto.base_name, "fluid") + end + + if object.class ~= "Floor" or item_proto.type ~= "entity" then + table.insert(item_list, {proto=item_proto, amount=item_result.amount}) + end + end + + table.sort(item_list, item_comparator) + object[item_category] = item_list +end + +local function set_zeroed_items(line, item_category, items) + local item_list = {} + + for _, item in pairs(items) do + local item_proto = prototyper.util.find("items", item.name, item.type) + table.insert(item_list, {proto=item_proto, amount=0}) + end + + line[item_category] = item_list +end + + +-- Goes through every line and setting their satisfied_amounts appropriately +local function update_ingredient_satisfaction(floor, product_class) + product_class = product_class or structures.class.init() + + local function determine_satisfaction(ingredient, name) + local product_amount = product_class[ingredient.proto.type][name] + + if product_amount ~= nil then + if product_amount >= ingredient.amount then + ingredient.satisfied_amount = ingredient.amount + structures.class.subtract(product_class, ingredient) + else -- product_amount < ingredient.amount + ingredient.satisfied_amount = product_amount + structures.class.subtract(product_class, ingredient, product_amount) + end + else + ingredient.satisfied_amount = 0 + end + end + + -- Iterates the lines from the bottom up, setting satisfaction amounts along the way + for line in floor:iterator(nil, floor:find_last(), "previous") do + if line.class == "Floor" then + local subfloor_product_class = ftable.deep_copy(product_class) + update_ingredient_satisfaction(line, subfloor_product_class) + elseif line.machine.fuel then + local fuel = line.machine.fuel + local name = (fuel.temperature) and (fuel.proto.name .. "-" .. fuel.temperature) or fuel.proto.name + determine_satisfaction(fuel, name) + end + + for _, ingredient in pairs(line.ingredients) do + if ingredient.proto.type ~= "entity" then + local name = (line.class == "Floor") and ingredient.proto.name + or get_temperature_name(line, ingredient.proto) + determine_satisfaction(ingredient, name) + end + end + + -- Products and byproducts just get added to the list as being produced + for _, item_category in pairs{"products", "byproducts"} do + for _, product in pairs(line[item_category]) do + structures.class.add(product_class, product) + end + end + end +end + + +-- ** TOP LEVEL ** +-- Updates the whole factory calculations from top to bottom +function solver.update(player, factory, blank) + factory = factory or util.context.get(player, "Factory") + if factory and factory.valid then + local factory_data = solver.generate_factory_data(player, factory) + + if blank then -- sets factory to a blank state + set_blank_factory(player, factory) + elseif factory.matrix_free_items ~= nil then -- meaning the matrix solver is active + local matrix_metadata = matrix_engine.get_matrix_solver_metadata(factory_data) + + if matrix_metadata.num_cols > matrix_metadata.num_rows and #factory.matrix_free_items > 0 then + factory.matrix_free_items = {} + factory_data = solver.generate_factory_data(player, factory) + matrix_metadata = matrix_engine.get_matrix_solver_metadata(factory_data) + end + + if matrix_metadata.num_rows ~= 0 then -- don't run calculations if the factory has no lines + local linear_dependence_data = matrix_engine.get_linear_dependence_data(factory_data, matrix_metadata) + if matrix_metadata.num_rows == matrix_metadata.num_cols + and #linear_dependence_data.linearly_dependent_recipes == 0 then + matrix_engine.run_matrix_solver(factory_data, false) + factory.linearly_dependant = false + else + set_blank_factory(player, factory) -- reset factory by blanking everything + factory.linearly_dependant = true + end + else -- reset top level items + set_blank_factory(player, factory) + end + else + sequential_engine.update_factory(factory_data) + end + end +end + +-- Updates the given factory's ingredient satisfactions +function solver.determine_ingredient_satisfaction(factory) + if not factory.valid then return end + update_ingredient_satisfaction(factory.top_floor, nil) +end + + +-- ** INTERFACE ** +-- Returns a table containing all the data needed to run the calculations for the given factory +function solver.generate_factory_data(player, factory) + local factory_data = { + player_index = player.index, + factory_id = factory.id, + top_floor = generate_floor_data(player, factory, factory.top_floor), + matrix_free_items = factory.matrix_free_items + } + + return factory_data +end + +-- Updates the active factories top-level data with the given result +function solver.set_factory_result(result) + local factory = OBJECT_INDEX[result.factory_id] --[[@as Factory]] + + if factory.parent then factory.parent.needs_refresh = true end + + factory.top_floor.power = result.energy_consumption + factory.top_floor.emissions = result.emissions + factory.matrix_free_items = result.matrix_free_items + + for product in factory:iterator() do + local product_result_amount = result.Product[product.proto.type][product.proto.name] or 0 + product.amount = product_result_amount or 0 + end + + update_object_items(factory.top_floor, "byproducts", result.Byproduct) + update_object_items(factory.top_floor, "ingredients", result.Ingredient) + + -- Determine satisfaction-amounts for all line ingredients + local player = game.players[result.player_index] + if util.globals.preferences(player).ingredient_satisfaction then + solver.determine_ingredient_satisfaction(factory) + end +end + +-- Updates the given line of the given floor of the active factory +function solver.set_line_result(result) + local line = OBJECT_INDEX[result.line_id] --[[@as LineObject]] + + if line.class == "Floor" then + line.machine_count = result.machine_count + else + line.machine.amount = result.machine_count + if line.machine.fuel ~= nil then line.machine.fuel.amount = result.fuel_amount end + + line.production_ratio = result.production_ratio + end + + line.power = result.energy_consumption + line.emissions = result.emissions + + if line.production_ratio == 0 then + local recipe_proto = line.recipe_proto + set_zeroed_items(line, "products", recipe_proto.products) + line.byproducts = {} + set_zeroed_items(line, "ingredients", recipe_proto.ingredients) + else + update_object_items(line, "products", result.Product) + update_object_items(line, "byproducts", result.Byproduct) + update_object_items(line, "ingredients", result.Ingredient) + end +end + + +-- **** UTIL **** +-- Calculates the product amount after applying productivity bonuses +function solver_util.determine_prodded_amount(item, total_effects, maximum_productivity) + -- No negative productivity, and none above the recipe-determined cap + local productivity = math.min(math.max(total_effects.productivity, 0), maximum_productivity) + if productivity == 0 then return item.amount end + + -- Return formula is a simplification of the following formula: + -- item.amount - item.proddable_amount + (item.proddable_amount * (productivity + 1)) + return item.amount + (item.proddable_amount * productivity) +end + +-- Determines the amount of energy needed for a machine and the emissions that produces +function solver_util.determine_energy_consumption_and_emissions(machine_proto, recipe_proto, + fuel_proto, machine_count, total_effects, pollutant_type) + local energy_consumption = machine_count * (machine_proto.energy_usage * 60) * (1 + total_effects.consumption) + local drain = math.ceil(machine_count - 0.001) * (machine_proto.energy_drain * 60) + local total_consumption = energy_consumption + drain + + if pollutant_type == nil then return total_consumption, 0 end + + local fuel_multiplier = (fuel_proto ~= nil) and fuel_proto.emissions_multiplier or 1 + local total_multiplier = fuel_multiplier * (1 + total_effects.pollution) * recipe_proto.emissions_multiplier + + local emissions_per_joule = energy_consumption * (machine_proto.emissions_per_joule[pollutant_type] or 0) + local emissions_per_second = machine_count * (machine_proto.emissions_per_second[pollutant_type] or 0) + local total_emissions = (emissions_per_joule + emissions_per_second) * total_multiplier * 60 + + return total_consumption, total_emissions +end + +-- Determines the amount of fuel needed in the given context +function solver_util.determine_fuel_amount(energy_consumption, burner, fuel_value) + return (energy_consumption / burner.effectivity) / fuel_value +end diff --git a/modfiles/backend/calculation/structures.lua b/modfiles/backend/calculation/structures.lua new file mode 100644 index 000000000..455fb76bd --- /dev/null +++ b/modfiles/backend/calculation/structures.lua @@ -0,0 +1,116 @@ +local structures = { + aggregate = {}, + class = {} +} + +---@class SolverAgggregate +---@field player_index integer +---@field floor_id integer +---@field machine_count number +---@field energy_consumption number +---@field emissions number +---@field production_ratio number? +---@field Product SolverClass +---@field Byproduct SolverClass +---@field Ingredient SolverClass + +---@param player_index integer +---@param floor_id integer +---@return SolverAgggregate +function structures.aggregate.init(player_index, floor_id) + return { + player_index = player_index, + floor_id = floor_id, + machine_count = 0, + energy_consumption = 0, + emissions = 0, + production_ratio = nil, + Product = structures.class.init(), + Byproduct = structures.class.init(), + Ingredient = structures.class.init() + } +end + + +---@alias SolverClass { item: SolverMap, fluid: SolverMap, entity: SolverMap } +---@alias SolverMap { [string]: number } + +---@return SolverClass +function structures.class.init() + return { + item = {}, + fluid = {}, + entity = {} + } +end + +---@alias SolverInputItem SolverItem | FPItemPrototype | SimpleItem | Ingredient | FormattedProduct + +---@param class SolverClass +---@param item SolverInputItem +---@param amount number? +function structures.class.add(class, item, amount) + local type = (item.proto ~= nil) and item.proto.type or item.type + local name = (item.proto ~= nil) and item.proto.name or item.name + local amount_to_add = amount or item.amount + + local type_table = class[type] + type_table[name] = (type_table[name] or 0) + amount_to_add + if type_table[name] == 0 then type_table[name] = nil end +end + +---@param class SolverClass +---@param item SolverInputItem +---@param amount number? +function structures.class.subtract(class, item, amount) + structures.class.add(class, item, -(amount or item.amount)) +end + + +--- Puts the items into their destination class in the given aggregate, +--- stopping for balancing at the depot-class +---@param class SolverClass +---@param depot SolverClass +---@param destination SolverClass +function structures.class.balance_items(class, depot, destination) + for _, item in pairs(structures.class.list(class)) do + local depot_amount = depot[item.type][item.name] + + if depot_amount ~= nil then -- Use up depot items, if available + if depot_amount >= item.amount then + structures.class.subtract(depot, item) + else + structures.class.subtract(depot, item, depot_amount) + structures.class.add(destination, item, (item.amount - depot_amount)) + end + + else -- add to destination if this item is not present in the depot + structures.class.add(destination, item) + end + end +end + + +---@class SolverItem +---@field type string +---@field name string +---@field amount number + +---@param class SolverClass +---@param copy boolean? +---@return SolverItem[] +function structures.class.list(class) + local list = {} + for type, items_of_type in pairs(class) do + for name, amount in pairs(items_of_type) do + table.insert(list, { + name = name, + type = type, + amount = amount + }) + end + end + return list +end + +return structures diff --git a/modfiles/backend/data/Beacon.lua b/modfiles/backend/data/Beacon.lua new file mode 100644 index 000000000..ac123645a --- /dev/null +++ b/modfiles/backend/data/Beacon.lua @@ -0,0 +1,197 @@ +local Object = require("backend.data.Object") +local ModuleSet = require("backend.data.ModuleSet") + +---@class Beacon: Object, ObjectMethods +---@field class "Beacon" +---@field parent Line +---@field proto FPBeaconPrototype | FPPackedPrototype +---@field quality_proto FPQualityPrototype +---@field amount integer +---@field total_amount number? +---@field module_set ModuleSet +---@field total_effects ModuleEffects +---@field effects_tooltip LocalisedString +local Beacon = Object.methods() +Beacon.__index = Beacon +script.register_metatable("Beacon", Beacon) + +---@param proto FPBeaconPrototype +---@param parent Line +---@return Beacon +local function init(proto, parent) + local object = Object.init({ + proto = proto, + quality_proto = defaults.get_fallback("qualities").proto, + amount = 0, + total_amount = nil, + module_set = nil, + + total_effects = nil, + effects_tooltip = "", + + parent = parent + }, "Beacon", Beacon) --[[@as Beacon]] + object.module_set = ModuleSet.init(object) + return object +end + + +function Beacon:index() + OBJECT_INDEX[self.id] = self + self.module_set:index() +end + + +---@return {name: string, quality: string} +function Beacon:elem_value() + return {name=self.proto.name, quality=self.quality_proto.name} +end + + +---@return double profile_multiplier +function Beacon:profile_multiplier() + if self.amount == 0 then + return 0 + else + local profile_count = #self.proto.profile + local index = (self.amount > profile_count) and profile_count or self.amount + return self.proto.profile[index] + end +end + + +function Beacon:summarize_effects() + local profile_mulitplier = self:profile_multiplier() + local effectivity = self.proto.effectivity + (self.quality_proto.level * self.proto.quality_bonus) + local effect_multiplier = self.amount * profile_mulitplier * effectivity + + local effects = self.module_set:get_effects() + for name, effect in pairs(effects) do + effects[name] = effect * effect_multiplier + end + + self.total_effects = effects + self.effects_tooltip = util.effects.format(effects) + + self.parent:summarize_effects() +end + +---@return boolean uses_effects +function Beacon:uses_effects() + -- This method is here for ModuleSet to use generically + return self.parent:uses_beacon_effects() +end + + +---@param player LuaPlayer +function Beacon:reset(player) + local beacon_default = defaults.get(player, "beacons", nil) + + self.proto = beacon_default.proto --[[@as FPBeaconPrototype]] + self.quality_proto = beacon_default.quality + if beacon_default.beacon_amount then self.amount = beacon_default.beacon_amount end + + self.module_set:clear() + if beacon_default.modules then self.module_set:ingest_default(beacon_default.modules) end +end + + +---@param object CopyableObject +---@return boolean success +---@return string? error +function Beacon:paste(object) + if object.class == "Beacon" then + self.parent:set_beacon(object) + if not object.module_set.first then + self.parent:set_beacon(nil) + return false, "incompatible" + else + return true, nil + end + elseif object.class == "Module" and self.module_set ~= nil then + -- Only allow modules to be pasted if this is a non-fake beacon + return self.module_set:paste(object) + else + return false, "incompatible_class" + end +end + + +---@class PackedBeacon: PackedObject +---@field class "Beacon" +---@field proto FPBeaconPrototype +---@field quality_proto FPQualityPrototype +---@field amount number +---@field total_amount number? +---@field module_set PackedModuleSet + +---@return PackedBeacon packed_self +function Beacon:pack() + return { + class = self.class, + proto = prototyper.util.simplify_prototype(self.proto, nil), + quality_proto = prototyper.util.simplify_prototype(self.quality_proto, nil), + amount = self.amount, + total_amount = self.total_amount, + module_set = self.module_set:pack() + } +end + +---@param packed_self PackedBeacon +---@param parent Line +---@return Beacon machine +local function unpack(packed_self, parent) + local unpacked_self = init(packed_self.proto, parent) + unpacked_self.quality_proto = packed_self.quality_proto + unpacked_self.amount = packed_self.amount + unpacked_self.total_amount = packed_self.total_amount + unpacked_self.module_set = ModuleSet.unpack(packed_self.module_set, unpacked_self) + + return unpacked_self +end + +---@return Beacon clone +function Beacon:clone() + local clone = unpack(self:pack(), self.parent) + clone:validate() + return clone +end + + +---@return boolean valid +function Beacon:validate() + self.proto = prototyper.util.validate_prototype_object(self.proto, nil) + self.valid = (not self.proto.simplified) + + self.quality_proto = prototyper.util.validate_prototype_object(self.quality_proto, nil) + self.valid = (not self.quality_proto.simplified) and self.valid + + self.valid = self.parent:uses_beacon_effects() and self.valid + self.valid = self.module_set:validate() and self.valid + + return self.valid +end + +---@param player LuaPlayer +---@return boolean success +function Beacon:repair(player) + self.valid = true + + if self.proto.simplified or not self.parent:uses_beacon_effects() then + self.valid = false -- this situation can't be repaired + -- This could try another beacon, but it's not really worth it + end + + if self.valid and self.quality_proto.simplified then + self.quality_proto = defaults.get_fallback("qualities").proto + end + + if self.valid then + self.module_set:repair(player) -- always becomes valid + if self.module_set.module_count == 0 then self.valid = false end + end + + return self.valid +end + +return {init = init, unpack = unpack} diff --git a/modfiles/backend/data/District.lua b/modfiles/backend/data/District.lua new file mode 100644 index 000000000..f1d857584 --- /dev/null +++ b/modfiles/backend/data/District.lua @@ -0,0 +1,136 @@ +local Object = require("backend.data.Object") +local DistrictItemSet = require("backend.data.DistrictItemSet") + +---@class District: Object, ObjectMethods +---@field class "District" +---@field parent Realm +---@field next District? +---@field previous District? +---@field name string +---@field location_proto FPLocationPrototype +---@field item_set DistrictItemSet +---@field first Factory? +---@field power number +---@field emissions number +---@field needs_refresh boolean +---@field collapsed boolean +local District = Object.methods() +District.__index = District +script.register_metatable("District", District) + +---@param name string? +---@return District +local function init(name) + local object = Object.init({ + name = name or "Nauvis", + location_proto = defaults.get_fallback("locations").proto, + item_set = DistrictItemSet.init(), + first = nil, + + power = 0, + emissions = 0, + + needs_refresh = false, + collapsed = false + }, "District", District) --[[@as District]] + return object +end + + +function District:index() + OBJECT_INDEX[self.id] = self + self.item_set:index() + for factory in self:iterator() do factory:index() end +end + + +---@param factory Factory +---@param relative_object Factory? +---@param direction NeighbourDirection? +function District:insert(factory, relative_object, direction) + factory.parent = self + self:_insert(factory, relative_object, direction) +end + +---@param factory Factory +function District:remove(factory) + -- Make sure the nth_tick handlers are cleaned up + if factory.tick_of_deletion then util.nth_tick.cancel(factory.tick_of_deletion) end + factory.parent = nil + self:_remove(factory) +end + +---@param factory Factory +---@param direction NeighbourDirection +---@param spots integer? +function District:shift(factory, direction, spots) + local filter = { archived = factory.archived } + self:_shift(factory, direction, spots, filter) +end + + +---@param filter ObjectFilter +---@param pivot Factory? +---@param direction NeighbourDirection? +---@return Factory? factory +function District:find(filter, pivot, direction) + return self:_find(filter, pivot, direction) --[[@as Factory?]] +end + + +---@param filter ObjectFilter? +---@param pivot Factory? +---@param direction NeighbourDirection? +---@return fun(): Factory? +function District:iterator(filter, pivot, direction) + return self:_iterator(filter, pivot, direction) +end + +---@param filter ObjectFilter? +---@param direction NeighbourDirection? +---@param pivot Factory? +---@return number count +function District:count(filter, pivot, direction) + return self:_count(filter, pivot, direction) +end + + +-- Updates the power, emissions and items of this District if requested +function District:refresh() + if not self.needs_refresh then return end + self.needs_refresh = false + + self.power = 0 + self.emissions = 0 + self.item_set:clear() + + for factory in self:iterator({archived=false, valid=true}) do + self.power = self.power + factory.top_floor.power + self.emissions = self.emissions + factory.top_floor.emissions + + self.item_set:add_items(factory:as_list(), "production") + self.item_set:add_items(factory.top_floor.byproducts, "production") + self.item_set:add_items(factory.top_floor.ingredients, "consumption") + end + + self.item_set:diff() + self.item_set:sort() +end + + +---@return boolean valid +function District:validate() + self:_validate() -- invalid factories don't make the district invalid + + -- Invalid locations are just replaced with valid ones to make the district valid + self.location_proto = prototyper.util.validate_prototype_object(self.location_proto, nil) + if self.location_proto.simplified then + self.location_proto = defaults.get_fallback("locations").proto + end + + -- The item set doesn't need validationa as it is automaticaly redone by :refresh() + + return self.valid -- always true +end + +return {init = init} diff --git a/modfiles/backend/data/DistrictItem.lua b/modfiles/backend/data/DistrictItem.lua new file mode 100644 index 000000000..2f76c3d02 --- /dev/null +++ b/modfiles/backend/data/DistrictItem.lua @@ -0,0 +1,43 @@ +local Object = require("backend.data.Object") + +---@alias DistrictItemMode "production" | "consumption" + +---@class DistrictItemData +---@field amount number + +---@class DistrictItem: Object, ObjectMethods +---@field class "DistrictItem" +---@field proto FPItemPrototype +---@field production DistrictItemData +---@field consumption DistrictItemData +local DistrictItem = Object.methods() +DistrictItem.__index = DistrictItem +script.register_metatable("DistrictItem", DistrictItem) + +---@param proto FPItemPrototype +---@return DistrictItem +local function init(proto) + local object = Object.init({ + proto = proto, + production = {amount=0}, + consumption = {amount=0}, + + overall = nil, + abs_diff = 0 + }, "DistrictItem", DistrictItem) --[[@as DistrictItem]] + return object +end + + +function DistrictItem:index() + OBJECT_INDEX[self.id] = self +end + + +---@param amount number +---@param mode DistrictItemMode +function DistrictItem:add(amount, mode) + self[mode].amount = self[mode].amount + amount +end + +return {init = init} diff --git a/modfiles/backend/data/DistrictItemSet.lua b/modfiles/backend/data/DistrictItemSet.lua new file mode 100644 index 000000000..fe8d878f2 --- /dev/null +++ b/modfiles/backend/data/DistrictItemSet.lua @@ -0,0 +1,93 @@ +local Object = require("backend.data.Object") +local DistrictItem = require("backend.data.DistrictItem") + +---@class DistrictItemSet: Object, ObjectMethods +---@field class "DistrictItemSet" +---@field first DistrictItem? +---@field map { [FPItemPrototype]: DistrictItem } +local DistrictItemSet = Object.methods() +DistrictItemSet.__index = DistrictItemSet +script.register_metatable("DistrictItemSet", DistrictItemSet) + +---@return DistrictItemSet +local function init() + local object = Object.init({ + first = nil, + map = {} + }, "DistrictItemSet", DistrictItemSet) --[[@as DistrictItemSet]] + return object +end + + +function DistrictItemSet:index() + OBJECT_INDEX[self.id] = self + for district_item in self:iterator() do district_item:index() end +end + + +---@param items SimpleItem[] +---@param mode DistrictItemMode +function DistrictItemSet:add_items(items, mode) + for _, item in pairs(items) do + local district_item = self.map[item.proto] + + if not district_item then + district_item = DistrictItem.init(item.proto) + district_item.parent = self + self:_insert(district_item) + self.map[district_item.proto] = district_item + end + + district_item:add(item.amount, mode) + end +end + + +---@param district_item DistrictItem +function DistrictItemSet:remove(district_item) + district_item.parent = nil + self:_remove(district_item) +end + + +---@param filter ObjectFilter? +---@param pivot DistrictItem? +---@param direction NeighbourDirection? +---@return fun(): DistrictItem? +function DistrictItemSet:iterator(filter, pivot, direction) + return self:_iterator(filter, pivot, direction) +end + + +-- Sorts (awkwardly) based on type first ("item" before "fluid") and then amount +local function item_comparator(a, b) + local a_type, b_type = a.proto.type, b.proto.type + if a_type < b_type then return true + elseif a_type > b_type then return false + elseif a.abs_diff < b.abs_diff then return true + elseif a.abs_diff > b.abs_diff then return false end + return false +end + +function DistrictItemSet:sort() + self:_sort(item_comparator) +end + + +function DistrictItemSet:diff() + for item in self:iterator() do + local diff = item.production.amount - item.consumption.amount + item.overall = (diff > 0) and "production" or "consumption" + item.abs_diff = math.abs(diff) + + if item.abs_diff < MAGIC_NUMBERS.margin_of_error then self:remove(item) end + end +end + + +function DistrictItemSet:clear() + self.first = nil + self.map = {} +end + +return {init = init} diff --git a/modfiles/backend/data/Factory.lua b/modfiles/backend/data/Factory.lua new file mode 100644 index 000000000..06e99b084 --- /dev/null +++ b/modfiles/backend/data/Factory.lua @@ -0,0 +1,283 @@ +local Object = require("backend.data.Object") +local Floor = require("backend.data.Floor") +local Product = require("backend.data.Product") + +---@class Factory: Object, ObjectMethods +---@field class "Factory" +---@field parent District +---@field next Factory? +---@field previous Factory? +---@field archived boolean +---@field name string +---@field matrix_free_items FPItemPrototype[]? +---@field last_recipe_items FPItemPrototype[]? +---@field blueprints string[] +---@field notes string +---@field productivity_boni { string: ModuleEffectValue } +---@field first Product? +---@field top_floor Floor +---@field linearly_dependant boolean? +---@field tick_of_deletion uint? +---@field last_valid_modset ModToVersion? +local Factory = Object.methods() +Factory.__index = Factory +script.register_metatable("Factory", Factory) + +---@param name string +---@return Factory +local function init(name) + local object = Object.init({ + archived = false, + --owner = nil, + --shared = false, + + name = name, + matrix_free_items = nil, + last_recipe_items = nil, + blueprints = {}, + notes = "", + productivity_boni = {}, + first = nil, + top_floor = Floor.init(1), + + linearly_dependant = false, + tick_of_deletion = nil, + last_valid_modset = nil + }, "Factory", Factory) --[[@as Factory]] + object.top_floor.parent = object + return object +end + + +function Factory:index() + OBJECT_INDEX[self.id] = self + for product in self:iterator() do product:index() end + self.top_floor:index() +end + + +---@param product Product +---@param relative_object Product? +---@param direction NeighbourDirection? +function Factory:insert(product, relative_object, direction) + product.parent = self + self:_insert(product, relative_object, direction) +end + +---@param product Product +function Factory:remove(product) + product.parent = nil + self:_remove(product) +end + +---@param product Product +---@param new_product Product +function Factory:replace(product, new_product) + new_product.parent = self + self:_replace(product, new_product) +end + +---@param product Product +---@param direction NeighbourDirection +---@param spots integer? +function Factory:shift(product, direction, spots) + self:_shift(product, direction, spots) +end + + +---@param filter ObjectFilter +---@param pivot Product? +---@param direction NeighbourDirection? +---@return Product? product +function Factory:find(filter, pivot, direction) + return self:_find(filter, pivot, direction) --[[@as Product?]] +end + +---@return Product? +function Factory:find_last() + return self:_find_last() --[[@as Product?]] +end + + +---@param filter ObjectFilter? +---@param pivot Product? +---@param direction NeighbourDirection? +---@return fun(): Product? +function Factory:iterator(filter, pivot, direction) + return self:_iterator(filter, pivot, direction) +end + +---@param filter ObjectFilter? +---@param pivot Product? +---@param direction NeighbourDirection? +---@return Product[] +function Factory:as_list(filter, pivot, direction) + return self:_as_list(filter, pivot, direction) +end + +---@param filter ObjectFilter? +---@param pivot Product? +---@param direction NeighbourDirection? +---@return number count +function Factory:count(filter, pivot, direction) + return self:_count(filter, pivot, direction) +end + + +---@param attach_products boolean +---@param export_format boolean +---@return LocalisedString caption +---@return LocalisedString? tooltip +function Factory:tostring(attach_products, export_format) + local caption, tooltip = self.name, nil -- don't return a tooltip for the export_format + + if attach_products and self.valid then + local product_string = "" + for product in self:iterator() do + product_string = product_string .. "[img=" .. product.proto.sprite .. "]" + end + if product_string ~= "" then product_string = product_string .. " " end + caption = product_string .. caption + end + + if not export_format then + local status_string = "" + if self.tick_of_deletion then status_string = status_string .. "[img=fp_trash_red] " end + if not self.valid then status_string = status_string .. "[img=fp_warning_red] " end + caption = status_string .. caption + + local trashed_string = "" ---@type LocalisedString + if self.tick_of_deletion then + local ticks_left_in_trash = self.tick_of_deletion - game.tick + local minutes_left_in_trash = math.ceil(ticks_left_in_trash / 3600) + trashed_string = {"fp.factory_trashed", minutes_left_in_trash} + end + + local invalid_string = (not self.valid) and {"fp.factory_invalid"} or "" + tooltip = {"", {"fp.tt_title", caption}, trashed_string, invalid_string} + end + + return caption, tooltip +end + + +---@param force LuaForce +---@param recipe_name string +---@return ModuleEffectValue productivity_bonus +function Factory:get_productivity_bonus(force, recipe_name) + local custom_bonus = self.productivity_boni[recipe_name] + if custom_bonus then return custom_bonus + else return util.get_recipe_productivity(force, recipe_name) end +end + + +-- Only used when switching between belts and lanes +---@param new_defined_by ProductDefinedBy +function Factory:update_product_definitions(new_defined_by) + for product in self:iterator() do + product:change_definition(new_defined_by) + end +end + + +---@class PackedFactory: PackedObject +---@field class "Factory" +---@field name string +---@field matrix_free_items FPPackedPrototype[]? +---@field last_recipe_items FPPackedPrototype[]? +---@field blueprints string[] +---@field notes string +---@field productivity_boni { string: ModuleEffectValue } +---@field products PackedProduct[]? +---@field top_floor PackedFloor + +---@return PackedFactory packed_self +function Factory:pack() + return { + class = self.class, + name = self.name, + matrix_free_items = prototyper.util.simplify_prototypes(self.matrix_free_items, "type"), + last_recipe_items = prototyper.util.simplify_prototypes(self.last_recipe_items, "type"), + blueprints = self.blueprints, + notes = self.notes, + productivity_boni = self.productivity_boni, + products = self:_pack(), + top_floor = self.top_floor:pack() + } +end + +---@param packed_self PackedFactory +---@return Factory factory +local function unpack(packed_self) + local unpacked_self = init(packed_self.name) + + -- Product prototypes will be automatically unpacked by the validation process + unpacked_self.matrix_free_items = packed_self.matrix_free_items + unpacked_self.last_recipe_items = packed_self.last_recipe_items + unpacked_self.blueprints = packed_self.blueprints + unpacked_self.notes = packed_self.notes + unpacked_self.productivity_boni = packed_self.productivity_boni + + unpacked_self.first = Object.unpack(packed_self.products, Product.unpack, unpacked_self) --[[@as Product]] + + unpacked_self.top_floor = Floor.unpack(packed_self.top_floor) + unpacked_self.top_floor.parent = unpacked_self + + return unpacked_self +end + +---@return Factory clone +function Factory:clone() + local clone = unpack(self:pack()) + clone:validate() + return clone +end + + +---@return boolean valid +function Factory:validate() + local previous_validity = self.valid + self.valid = true + + self.valid = self:_validate() and self.valid + self.valid = self.top_floor:validate() and self.valid + + local matrix_free_items, valid = prototyper.util.validate_prototype_objects(self.matrix_free_items, "type") + self.matrix_free_items = matrix_free_items + self.valid = valid and self.valid + + -- Remove any invalid boni, no need to mark the factory as invalid + for recipe_name, _ in pairs(self.productivity_boni) do + if not PRODUCTIVITY_RECIPES[recipe_name] then + self.productivity_boni[recipe_name] = nil + end + end + + if self.valid then self.last_valid_modset = nil + -- If this factory became invalid with the current configuration, retain the modset before the current one + -- The one in storage is still the previous one as it's only updated after migrations + elseif previous_validity and not self.valid then self.last_valid_modset = storage.installed_mods end + + return self.valid +end + +---@param player LuaPlayer +---@return boolean success +function Factory:repair(player) + self:_repair(player) + self.top_floor:repair(player) + + -- Remove any unrepairable free items so the factory remains valid + local free_items = self.matrix_free_items or {} + for index = #free_items, 1, -1 do + if free_items[index].simplified --[[@as AnyPrototype]] then + table.remove(free_items, index) + end + end + + self.last_valid_modset = nil + self.valid = true + return self.valid +end + +return {init = init, unpack = unpack} diff --git a/modfiles/backend/data/Floor.lua b/modfiles/backend/data/Floor.lua new file mode 100644 index 000000000..083a41d49 --- /dev/null +++ b/modfiles/backend/data/Floor.lua @@ -0,0 +1,259 @@ +local Object = require("backend.data.Object") +local Line = require("backend.data.Line") + +---@alias LineObject Line | Floor +---@alias LineParent Factory | Floor + +---@class Floor: Object, ObjectMethods +---@field class "Floor" +---@field parent LineParent +---@field next LineObject? +---@field previous LineObject? +---@field level integer +---@field first LineObject? +---@field products SimpleItem[] +---@field byproducts SimpleItem[] +---@field ingredients SimpleItem[] +---@field power number +---@field emissions number +---@field machine_count integer +local Floor = Object.methods() +Floor.__index = Floor +script.register_metatable("Floor", Floor) + +---@param level integer +---@return Floor +local function init(level) + local object = Object.init({ + level = level, + first = nil, + + products = {}, + byproducts = {}, + ingredients = {}, + power = 0, + emissions = 0, + machine_count = 0 + }, "Floor", Floor) --[[@as Floor]] + return object +end + + +function Floor:index() + OBJECT_INDEX[self.id] = self + for line in self:iterator() do line:index() end +end + + +---@param line LineObject +---@param relative_object LineObject? +---@param direction NeighbourDirection? +function Floor:insert(line, relative_object, direction) + line.parent = self + self:_insert(line, relative_object, direction) +end + +---@param line LineObject +---@param preserve boolean? +function Floor:remove(line, preserve) + line.parent = nil + self:_remove(line) + + if preserve then return end + -- Convert floor to line in parent if only defining line remains + if self.level > 1 and self.first.next == nil then + self.parent:replace(self, self.first) + end +end + +---@param line LineObject +---@param new_line LineObject +function Floor:replace(line, new_line) + new_line.parent = self + self:_replace(line, new_line) +end + + +---@param line LineObject +---@param direction NeighbourDirection +---@param spots integer? +function Floor:shift(line, direction, spots) + self:_shift(line, direction, spots) +end + + +---@return LineObject? +function Floor:find_last() + return self:_find_last() --[[@as LineObject?]] +end + +---@param filter ObjectFilter? +---@param pivot LineObject? +---@param direction NeighbourDirection? +---@return fun(): LineObject? +function Floor:iterator(filter, pivot, direction) + return self:_iterator(filter, pivot, direction) +end + +---@param filter ObjectFilter? +---@param pivot LineObject? +---@param direction NeighbourDirection? +---@return number count +function Floor:count(filter, pivot, direction) + return self:_count(filter, pivot, direction) +end + + +---@alias ComponentDataSet { proto: FPPrototype, amount: number } + +---@class ComponentData +---@field machines { [string]: ComponentDataSet } +---@field modules { [string]: ComponentDataSet } + +-- Returns the machines and modules needed to actually build this floor +---@param skip_done boolean +---@param component_table ComponentData? +---@return ComponentData components +function Floor:get_component_data(skip_done, component_table) + local components = component_table or {machines={}, modules={}} + + local function add_component(table, proto, quality_proto, amount) + local combined_name = proto.name .. "-" .. quality_proto.name + local component = table[combined_name] + if component == nil then + table[combined_name] = {proto = proto, quality_proto = quality_proto, amount = amount} + else + component.amount = component.amount + amount + end + end + + local function add_machine(object, amount) + if object.proto.built_by_item then + add_component(components.machines, object.proto.built_by_item, object.quality_proto, amount) + end + + for module in object.module_set:iterator() do + add_component(components.modules, module.proto, module.quality_proto, amount * module.amount) + end + end + + for line in self:iterator() do + if line.class == "Floor" then ---@cast line Floor + line:get_component_data(skip_done, components) + + elseif not skip_done or not line.done then + local machine = line.machine + local ceil_machine_count = math.ceil(machine.amount - 0.001) + add_machine(machine, ceil_machine_count) + + local beacon = line.beacon + if beacon and beacon.total_amount then + local ceil_total_amount = math.ceil(beacon.total_amount - 0.001) + add_machine(beacon, ceil_total_amount) + end + end + end + + return components +end + + +---@param object LineObject +---@return boolean compatible +function Floor:check_product_compatibility(object) + if self.level == 1 then return true end + + local relevant_line = (object.class == "Floor") and object.first or object + -- The triple loop is crappy, but it's the simplest way to check + for _, product in pairs(relevant_line.recipe_proto.products) do + for line in self:iterator() do + for _, ingredient in pairs(line.ingredients) do + if ingredient.proto.type == product.type and ingredient.proto.name == product.name then + return true + end + end + end + end + return false +end + +function Floor:reset_surface_compatibility() + for line in self:iterator() do + if line.class == "Floor" then ---@cast line Floor + line:reset_surface_compatibility() + else + line.surface_compatibility = nil + end + end +end + +---@param object CopyableObject +---@return boolean success +---@return string? error +function Floor:paste(object) + if object.class == "Line" or object.class == "Floor" then + ---@cast object LineObject + if not self:check_product_compatibility(object) then + return false, "recipe_irrelevant" -- found no use for the recipe's products + end + + self.parent:replace(self, object) + return true, nil + else + return false, "incompatible_class" + end +end + + +---@alias PackedLineObject PackedLine | PackedFloor + +---@class PackedFloor: PackedObject +---@field class "Floor" +---@field level integer +---@field lines PackedLineObject[]? + +---@return PackedFloor packed_self +function Floor:pack() + return { + class = self.class, + level = self.level, + lines = self:_pack() + } +end + +---@param packed_self PackedFloor +---@return Floor floor +local function unpack(packed_self) + local unpacked_self = init(packed_self.level) + + local function unpacker(line) return (line.class == "Floor") and unpack(line) or Line.unpack(line) end + unpacked_self.first = Object.unpack(packed_self.lines, unpacker, unpacked_self) --[[@as LineObject]] + + return unpacked_self +end + + +---@return boolean valid +function Floor:validate() + self.valid = self:_validate() + return self.valid +end + +---@param player LuaPlayer +---@return boolean success +function Floor:repair(player) + local pivot = self.first + if self.level > 1 and self.first and not self.first.valid then + local line_valid = self.first:repair(player) + -- If the defining line can't be repaired, the floor is dead + if not line_valid then return false end + pivot = self.first.next + end + + if pivot then self:_repair(player, pivot) end + self.valid = true + + return self.valid +end + +return {init = init, unpack = unpack} diff --git a/modfiles/backend/data/Fuel.lua b/modfiles/backend/data/Fuel.lua new file mode 100644 index 000000000..cf1e6c5fd --- /dev/null +++ b/modfiles/backend/data/Fuel.lua @@ -0,0 +1,131 @@ +local Object = require("backend.data.Object") + +---@class Fuel: Object, ObjectMethods +---@field class "Fuel" +---@field parent Machine +---@field proto FPFuelPrototype | FPPackedPrototype +---@field temperature float? +---@field temperature_data TemperatureData +---@field amount number +---@field satisfied_amount number +local Fuel = Object.methods() +Fuel.__index = Fuel +script.register_metatable("Fuel", Fuel) + +---@param proto FPFuelPrototype +---@param parent Machine +---@return Fuel +local function init(proto, parent) + local object = Object.init({ + proto = proto, + temperature = nil, + + temperature_data = nil, + + amount = 0, + satisfied_amount = 0, + + parent = parent + }, "Fuel", Fuel) --[[@as Fuel]] + + -- Initialize data related to fuel temperature if applicable + if proto.simplified ~= true then object:build_temperatures_data() end + + return object +end + + +function Fuel:index() + OBJECT_INDEX[self.id] = self +end + + +-- Builds temperature data cache, and optionally migrates previous temperature +function Fuel:build_temperatures_data() + local previous = self.temperature + + self.temperature = nil + self.temperature_data = nil + + if self.proto.type == "fluid" then + local temperature, data = util.temperature.generate_data(self.proto, previous) + + self.temperature = temperature + self.temperature_data = data + end +end + + +---@param object CopyableObject +---@return boolean success +---@return string? error +function Fuel:paste(object) + if object.class == "Fuel" then + local burner = self.parent.proto.burner -- will exist if there is fuel to paste on + -- Check invididual categories so you can paste between combined_categories + for category_name, _ in pairs(burner.categories) do + if object.proto.category == category_name then + self.proto = object.proto + self:build_temperatures_data() + return true, nil + end + end + return false, "incompatible" + else + return false, "incompatible_class" + end +end + + +---@class PackedFuel: PackedObject +---@field class "Fuel" +---@field proto FPFuelPrototype + +---@return PackedFuel packed_self +function Fuel:pack() + return { + class = self.class, + proto = prototyper.util.simplify_prototype(self.proto, "combined_category"), + temperature = self.temperature + } +end + +---@param packed_self PackedFuel +---@param parent Machine +---@return Fuel machine +local function unpack(packed_self, parent) + local unpacked_self = init(packed_self.proto, parent) + unpacked_self.temperature = packed_self.temperature -- will be migrated through validation + + return unpacked_self +end + + +---@return boolean valid +function Fuel:validate() + self.proto = prototyper.util.validate_prototype_object(self.proto, "combined_category") + self.valid = (not self.proto.simplified) + + if self.valid then + local burner = (not self.parent.proto.simplified) and self.parent.proto.burner or nil + + -- Machine is simplified, doesn't have a burner anymore, or has a different category, is all bad + if not burner or burner.combined_category ~= self.proto.combined_category then + self.proto = prototyper.util.simplify_prototype(self.proto, "combined_category") + self.valid = false + end + end + + -- Updates temperature data cache and migrates previous temperature choice + if self.valid then self:build_temperatures_data(self.temperature) end + + return self.valid +end + +---@param player LuaPlayer +---@return boolean success +function Fuel:repair(player) + return false -- the parent machine will try to replace it with another fuel of the same category +end + +return {init = init, unpack = unpack} diff --git a/modfiles/backend/data/Line.lua b/modfiles/backend/data/Line.lua new file mode 100644 index 000000000..22f9adb4e --- /dev/null +++ b/modfiles/backend/data/Line.lua @@ -0,0 +1,397 @@ +local Object = require("backend.data.Object") +local Machine = require("backend.data.Machine") +local Beacon = require("backend.data.Beacon") + +---@alias ProductionType "produce" | "consume" + +---@class SurfaceCompatibility +---@field recipe boolean +---@field machine boolean +---@field overall boolean + +---@class Line: Object, ObjectMethods +---@field class "Line" +---@field parent Floor +---@field next LineObject? +---@field previous LineObject? +---@field recipe_proto FPRecipePrototype | FPPackedPrototype +---@field production_type ProductionType +---@field done boolean +---@field active boolean +---@field percentage number +---@field machine Machine +---@field beacon Beacon? +---@field priority_product (FPItemPrototype | FPPackedPrototype)? +---@field temperatures { [string]: float } +---@field comment string +---@field surface_compatibility SurfaceCompatibility? +---@field total_effects ModuleEffects +---@field effects_tooltip LocalisedString +---@field temperature_data { [string]: TemperatureData } +---@field products SimpleItem[] +---@field byproducts SimpleItem[] +---@field ingredients SimpleItem[] +---@field power number +---@field emissions number +---@field production_ratio number? +local Line = Object.methods() +Line.__index = Line +script.register_metatable("Line", Line) + +---@param recipe_proto FPRecipePrototype? +---@param production_type ProductionType +---@return Line +local function init(recipe_proto, production_type) + local object = Object.init({ + recipe_proto = recipe_proto, + production_type = production_type, + done = false, + active = true, + percentage = 100, + machine = nil, + beacon = nil, + priority_product = nil, + temperatures = nil, + comment = "", + + surface_compatibility = nil, -- determined on demand + total_effects = nil, + effects_tooltip = "", + temperature_data = nil, + + products = {}, + byproducts = {}, + ingredients = {}, + power = 0, + emissions = 0, + production_ratio = 0 + }, "Line", Line) --[[@as Line]] + + -- Initialize data related to fluid ingredients temperatures + if recipe_proto and recipe_proto.simplified ~= true then + object:build_temperatures_data({}) + end + + return object +end + + +function Line:index() + OBJECT_INDEX[self.id] = self + self.machine:index() + if self.beacon then self.beacon:index() end +end + + +-- Returns whether the given machine can be used for this line/recipe +---@param machine_proto FPMachinePrototype +---@return boolean applicable +function Line:is_machine_compatible(machine_proto) + local type_counts = self.recipe_proto.type_counts + local valid_ingredient_count = (machine_proto.ingredient_limit >= type_counts.ingredients.items) + local valid_product_count = (machine_proto.product_limit >= type_counts.products.items) + local valid_input_channels = (machine_proto.fluid_channels.input >= type_counts.ingredients.fluids) + local valid_output_channels = (machine_proto.fluid_channels.output >= type_counts.products.fluids) + + return (valid_ingredient_count and valid_product_count and valid_input_channels and valid_output_channels) +end + +-- Sets this line's machine to be the given prototype +---@param player LuaPlayer +---@param proto FPMachinePrototype +function Line:change_machine_to_proto(player, proto) + if not self.machine then + self.machine = Machine.init(proto, self) + self.machine:summarize_effects() + else + self.machine.proto = proto + + self.machine.module_set:normalize({compatibility=true, trim=true, effects=true}) + if not self:uses_beacon_effects() then self:set_beacon(nil) end + self.surface_compatibility = nil -- reset it since the machine changed + end + + -- Make sure the machine's fuel still applies + self.machine:normalize_fuel(player) +end + +-- Up- or downgrades this line's machine, if possible +-- Returns false if no compatible machine can be found, true otherwise +---@param player LuaPlayer +---@param action "upgrade" | "downgrade" +---@param current_proto FPMachinePrototype? +---@return boolean success +function Line:change_machine_by_action(player, action, current_proto) + local current_machine_proto = current_proto or self.machine.proto + local category_id = current_machine_proto.category_id + + local function try_machine(new_machine_id) + current_machine_proto = prototyper.util.find("machines", new_machine_id, category_id) --[[@as FPMachinePrototype]] + + if self:is_machine_compatible(current_machine_proto) then + self:change_machine_to_proto(player, current_machine_proto) + return true + end + return false + end + + if action == "upgrade" then + local max_machine_id = #prototyper.util.find("machines", nil, category_id).members + while current_machine_proto.id < max_machine_id do + if try_machine(current_machine_proto.id + 1) then return true end + end + else -- action == "downgrade" + while current_machine_proto.id > 1 do + if try_machine(current_machine_proto.id - 1) then return true end + end + end + + return false -- if the above loop didn't return, no machine could be found +end + +-- Changes this line's machine to its default, if possible +-- Returns false if no compatible machine can be found, true otherwise +---@param player LuaPlayer +---@return boolean success +function Line:change_machine_to_default(player) + -- All categories are guaranteed to have at least one machine, so this is never nil + local machine_default = defaults.get(player, "machines", self.recipe_proto.category) + local default_proto = machine_default.proto --[[@as FPMachinePrototype]] + + local success = false + -- If the default is applicable, just set it straight away + if self:is_machine_compatible(default_proto) then + self:change_machine_to_proto(player, default_proto) + success = true + -- Otherwise, go up, then down the category to find an alternative + elseif self:change_machine_by_action(player, "upgrade", default_proto) then + success = true + elseif self:change_machine_by_action(player, "downgrade", default_proto) then + success = true + end + + if success then self.machine.quality_proto = machine_default.quality end + return success +end + + +---@param beacon Beacon? +function Line:set_beacon(beacon) + self.beacon = beacon -- can be nil + + if beacon ~= nil then + self.beacon.parent = self + + beacon.module_set:normalize({compatibility=true, effects=true}) + -- Normalization already summarizes beacon's effects + else + self:summarize_effects() + end +end + +---@param player LuaPlayer +function Line:setup_beacon(player) + local beacon_defaults = defaults.get(player, "beacons", nil) + if beacon_defaults.modules and beacon_defaults.beacon_amount ~= 0 then + local blank_beacon = Beacon.init(beacon_defaults.proto, self) + self:set_beacon(blank_beacon) + blank_beacon:reset(player) + end +end + + +---@return boolean uses_effects +function Line:uses_beacon_effects(player) + local effect_receiver = self.machine.proto.effect_receiver --[[@as EffectReceiver]] + if effect_receiver == nil then return false end + return effect_receiver.uses_beacon_effects +end + + +function Line:summarize_effects() + local beacon_effects = (self.beacon) and self.beacon.total_effects or nil + local merged_effects = util.effects.merge({self.machine.total_effects, beacon_effects}) + local limited_effects, indications = util.effects.limit(merged_effects, self.recipe_proto.maximum_productivity) + + self.total_effects = limited_effects + self.effects_tooltip = util.effects.format(limited_effects, {indications=indications}) +end + + +---@return PrototypeFilter filter +function Line:compile_machine_filter() + local compatible_machines = {} + + local machine_category = prototyper.util.find("machines", nil, self.machine.proto.category) + for _, machine_proto in pairs(machine_category.members) do + if self:is_machine_compatible(machine_proto) then + table.insert(compatible_machines, machine_proto.name) + end + end + + return {{filter="name", name=compatible_machines}} +end + + +---@param properties SurfaceProperties? +---@param conditions SurfaceCondition[]? +---@return boolean compatible +local function check_compatibility(properties, conditions) + if not properties or not conditions then return true end + for _, condition in pairs(conditions) do + local property = properties[condition.property] + if property and (property < condition.min or property > condition.max) then + return false + end + end + return true +end + +---@return SurfaceCompatibility compatibility +function Line:get_surface_compatibility() + -- Determine and save compatibility on the fly when requested + if self.surface_compatibility == nil then + local object = self.parent --[[@as Object]] -- find the District this is in + while object.class ~= "District" do object = object.parent --[[@as District]] end + local properties = object.location_proto.surface_properties + + local recipe = check_compatibility(properties, self.recipe_proto.surface_conditions) + local machine = check_compatibility(properties, self.machine.proto.surface_conditions) + self.surface_compatibility = {recipe=recipe, machine=machine, overall=(recipe and machine)} + end + return self.surface_compatibility +end + + +-- Builds temperature data caches, and optionally migrates previous temperatures +---@param previous_temperatures { [string]: float } +function Line:build_temperatures_data(previous_temperatures) + self.temperatures = {} + self.temperature_data = {} + + for _, ingredient in pairs(self.recipe_proto.ingredients) do + if ingredient.type == "fluid" then + local previous = previous_temperatures[ingredient.name] + local temperature, data = util.temperature.generate_data(ingredient, previous) + + self.temperatures[ingredient.name] = temperature + self.temperature_data[ingredient.name] = data + end + end +end + + + +---@param object CopyableObject +---@return boolean success +---@return string? error +function Line:paste(object) + if object.class == "Line" or object.class == "Floor" then + ---@cast object LineObject + if not self.parent:check_product_compatibility(object) then + return false, "recipe_irrelevant" -- found no use for the recipe's products + end + + self.parent:replace(self, object) + return true, nil + else + return false, "incompatible_class" + end +end + + +---@class PackedLine: PackedObject +---@field class "Line" +---@field recipe_proto FPPackedPrototype +---@field production_type ProductionType +---@field done boolean +---@field active boolean +---@field percentage number +---@field machine PackedMachine +---@field beacon PackedBeacon? +---@field priority_product FPPackedPrototype? +---@field comment string + +---@return PackedLine packed_self +function Line:pack() + return { + class = self.class, + recipe_proto = prototyper.util.simplify_prototype(self.recipe_proto, nil), + production_type = self.production_type, + done = self.done, + active = self.active, + percentage = self.percentage, + machine = self.machine:pack(), + beacon = self.beacon and self.beacon:pack(), + priority_product = prototyper.util.simplify_prototype(self.priority_product, "type"), + temperatures = self.temperatures, + comment = self.comment + } +end + +---@param packed_self PackedLine +---@return Line line +local function unpack(packed_self) + local unpacked_self = init(packed_self.recipe_proto, packed_self.production_type) + unpacked_self.done = packed_self.done + unpacked_self.active = packed_self.active + unpacked_self.percentage = packed_self.percentage + unpacked_self.machine = Machine.unpack(packed_self.machine, unpacked_self) --[[@as Machine]] + unpacked_self.beacon = packed_self.beacon and Beacon.unpack(packed_self.beacon, unpacked_self) --[[@as Beacon]] + -- The prototype will be automatically unpacked by the validation process + unpacked_self.priority_product = packed_self.priority_product + unpacked_self.temperatures = packed_self.temperatures -- will be migrated through validation + unpacked_self.comment = packed_self.comment + + return unpacked_self +end + + +---@return boolean valid +function Line:validate() + self.recipe_proto = prototyper.util.validate_prototype_object(self.recipe_proto, nil) + self.valid = (not self.recipe_proto.simplified) + + if self.valid then self.valid = self.machine:validate() and self.valid end + + if self.valid and self.beacon then self.valid = self.beacon:validate() and self.valid end + + if self.valid and self.priority_product then + self.priority_product = prototyper.util.validate_prototype_object(self.priority_product, "type") + self.valid = (not self.priority_product.simplified) and self.valid + end + + -- Updates temperature data caches and migrates previous temperature choices + if self.valid then self:build_temperatures_data(self.temperatures or {}) end + + self.surface_compatibility = nil -- reset cached value + + return self.valid +end + +---@param player LuaPlayer +---@return boolean success +function Line:repair(player) + self.valid = true + + if self.recipe_proto.simplified then + self.valid = false -- this situation can't be repaired + end + + if self.valid and not self.machine.valid then + self.valid = self.machine:repair(player) + end + + if self.valid and self.beacon and not self.beacon.valid then + -- Repairing a beacon always either fixes or gets it removed, so no influence on validity + if not self.beacon:repair(player) then self.beacon = nil end + end + + if self.valid and self.priority_product and self.priority_product.simplified then + self.priority_product = nil + end + + return self.valid +end + +return {init = init, unpack = unpack} diff --git a/modfiles/backend/data/Machine.lua b/modfiles/backend/data/Machine.lua new file mode 100644 index 000000000..bf4b8313e --- /dev/null +++ b/modfiles/backend/data/Machine.lua @@ -0,0 +1,299 @@ +local Object = require("backend.data.Object") +local Fuel = require("backend.data.Fuel") +local ModuleSet = require("backend.data.ModuleSet") + +---@class Machine: Object, ObjectMethods +---@field class "Machine" +---@field parent Line +---@field proto FPMachinePrototype | FPPackedPrototype +---@field quality_proto FPQualityPrototype +---@field limit number? +---@field force_limit boolean +---@field fuel Fuel? +---@field module_set ModuleSet +---@field amount number +---@field total_effects ModuleEffects +---@field effects_tooltip LocalisedString +---@field recipe_effects ModuleEffects? +local Machine = Object.methods() +Machine.__index = Machine +script.register_metatable("Machine", Machine) + +---@param proto FPMachinePrototype +---@param parent Line +---@return Machine +local function init(proto, parent) + local object = Object.init({ + proto = proto, + quality_proto = defaults.get_fallback("qualities").proto, + limit = nil, + force_limit = true, -- ignored if limit is not set + fuel = nil, -- needs to be set by calling Machine.normalize_fuel afterwards + module_set = nil, + + amount = 0, + total_effects = nil, + effects_tooltip = "", + recipe_effects = nil, + + parent = parent + }, "Machine", Machine) --[[@as Machine]] + object.module_set = ModuleSet.init(object) + return object +end + + +function Machine:index() + OBJECT_INDEX[self.id] = self + if self.fuel then self.fuel:index() end + self.module_set:index() +end + + +---@return {name: string, quality: string} +function Machine:elem_value() + return {name=self.proto.name, quality=self.quality_proto.name} +end + + +---@param player LuaPlayer +function Machine:normalize_fuel(player) + if self.proto.energy_type ~= "burner" then self.fuel = nil; return end + -- no need to continue if this machine doesn't have a burner + + local burner = self.proto.burner + -- Check if fuel has a valid category for this machine, replace otherwise + if self.fuel and not burner.categories[self.fuel.proto.category] then self.fuel = nil end + + if self.fuel == nil then -- add a fuel for this machine if it doesn't have one here + local default_fuel_proto = defaults.get(player, "fuels", burner.combined_category).proto + self.fuel = Fuel.init(default_fuel_proto, self) + else -- make sure the fuel is of the right combined category + if burner.combined_category ~= self.fuel.proto.category then + self.fuel.proto = prototyper.util.find("fuels", self.fuel.proto.name, burner.combined_category) + end + end + + self.fuel:build_temperatures_data() -- validate temperature +end + + +function Machine:summarize_effects() + local module_effects = self.module_set:get_effects() + local machine_effects = self.proto.effect_receiver.base_effect + + self.total_effects = util.effects.merge({module_effects, machine_effects, self.recipe_effects}) + self.effects_tooltip = util.effects.format(module_effects, + {machine_effects=machine_effects, recipe_effects=self.recipe_effects}) + + self.parent:summarize_effects() +end + +---@return boolean uses_effects +function Machine:uses_effects() + if self.proto.effect_receiver == nil then return false end + return self.proto.effect_receiver.uses_module_effects +end + +--- Called when the solver runs because it's the most convenient spot for it +---@param force LuaForce +---@param factory Factory +function Machine:update_recipe_effects(force, factory) + local recipe_proto = self.parent.recipe_proto + + local recipe_name = nil + local drill = self.proto.prototype_category == "mining_drill" + if drill and self.proto.uses_force_mining_productivity_bonus then recipe_name = "custom-mining" + elseif not recipe_proto.custom then recipe_name = recipe_proto.name + else return end -- no recipe effects for custom recipes + + local recipe_bonus = factory:get_productivity_bonus(force, recipe_name) + self.recipe_effects = {productivity=recipe_bonus} + self:summarize_effects() +end + + +function Machine:compile_fuel_filter() + local compatible_fuels = {} + + local fuel_category = prototyper.util.find("fuels", nil, self.proto.burner.combined_category) + for _, fuel_proto in pairs(fuel_category.members) do + table.insert(compatible_fuels, fuel_proto.name) + end + + return {{filter="name", name=compatible_fuels}} +end + +---@param player LuaPlayer +function Machine:reset(player) + self.parent:change_machine_to_default(player) + self:normalize_fuel(player) + + self.limit = nil + self.force_limit = true + + self.module_set:clear() + local machine_default = defaults.get(player, "machines", self.proto.category) + if machine_default.modules then self.module_set:ingest_default(machine_default.modules) end +end + + +---@param object CopyableObject +---@return boolean success +---@return string? error +function Machine:paste(object, player) + if object.class == "Machine" then + local corresponding_proto = prototyper.util.find("machines", object.proto.name, self.proto.category) + if corresponding_proto and self.parent:is_machine_compatible(object.proto) then + self.parent:change_machine_to_proto(player, corresponding_proto) + self.quality_proto = object.quality_proto + + self.limit = object.limit + self.force_limit = object.force_limit + + if object.fuel then + self.fuel = object.fuel + self.fuel.parent = self + end + + self.module_set = object.module_set + self.module_set.parent = self + -- Need to verify compatibility because it depends on the recipe too + self.module_set:normalize({compatibility=true, effects=true}) + + return true, nil + else + return false, "incompatible" + end + elseif object.class == "Module" then + return self.module_set:paste(object) + else + return false, "incompatible_class" + end +end + + +---@class PackedMachine: PackedObject +---@field class "Machine" +---@field proto FPMachinePrototype +---@field quality_proto FPQualityPrototype +---@field limit number? +---@field force_limit boolean +---@field fuel PackedFuel? +---@field module_set PackedModuleSet + +---@return PackedMachine packed_self +function Machine:pack() + return { + class = self.class, + proto = prototyper.util.simplify_prototype(self.proto, "category"), + quality_proto = prototyper.util.simplify_prototype(self.quality_proto, nil), + limit = self.limit, + force_limit = self.force_limit, + fuel = self.fuel and self.fuel:pack(), + module_set = self.module_set:pack() + } +end + +---@param packed_self PackedMachine +---@param parent Line +---@return Machine machine +local function unpack(packed_self, parent) + local unpacked_self = init(packed_self.proto, parent) + unpacked_self.quality_proto = packed_self.quality_proto + unpacked_self.limit = packed_self.limit + unpacked_self.force_limit = packed_self.force_limit + unpacked_self.fuel = packed_self.fuel and Fuel.unpack(packed_self.fuel, unpacked_self) + unpacked_self.module_set = ModuleSet.unpack(packed_self.module_set, unpacked_self) + + return unpacked_self +end + +---@return Machine clone +function Machine:clone() + local clone = unpack(self:pack(), self.parent) + + -- Copy these over so we don't need to run the solver + clone.amount = self.amount + clone.recipe_effects = self.recipe_effects + if self.fuel then + clone.fuel.amount = self.fuel.amount + clone.fuel.satisfied_amount = self.fuel.satisfied_amount + end + + clone:validate() + return clone +end + + +---@return boolean valid +function Machine:validate() + local recipe_category = self.parent.recipe_proto.category + if recipe_category ~= self.proto.category then + local corresponding_proto = prototyper.util.find("machines", self.proto.name, recipe_category) + if corresponding_proto then -- check if the machine just moved categories + self.proto = corresponding_proto -- this is okay in this specific context + else -- otherwise, this machine is invalid + self.proto = prototyper.util.simplify_prototype(self.proto, "category") + self.valid = false + end + else + self.proto = prototyper.util.validate_prototype_object(self.proto, "category") + self.valid = (not self.proto.simplified) + end + + self.quality_proto = prototyper.util.validate_prototype_object(self.quality_proto, nil) + self.valid = (not self.quality_proto.simplified) and self.valid + + -- Only need to check compatibility when the below is valid, else it'll be replaced anyways + if not self.proto.simplified and not self.parent.recipe_proto.simplified then + self.valid = self.parent:is_machine_compatible(self.proto) and self.valid + end + + if self.valid then -- only makes sense if the machine is valid + if self.proto.burner and not self.fuel then + -- If this machine changed to require fuel, add this dummy + local dummy = {name = "", category = self.proto.burner.combined_category, + data_type = "fuels", simplified = true} + self.fuel = Fuel.init(dummy, self) + end + if self.fuel then self.valid = self.fuel:validate() and self.valid end + end + + self.valid = self.module_set:validate() and self.valid + + return self.valid +end + +---@param player LuaPlayer +---@return boolean success +function Machine:repair(player) + self.valid = true + + -- Simplified or incompatible machine can potentially be replaced with a different one + if self.proto.simplified or not self.parent:is_machine_compatible(self.proto) then + -- Changing to the default machine also fixes the category not matching the recipe + if not self.parent:change_machine_to_default(player) then + self.valid = false -- this situation can't be repaired + end + end + + if self.valid and self.quality_proto.simplified then + self.quality_proto = defaults.get_fallback("qualities").proto + end + + if self.valid and self.fuel and not self.fuel.valid then + if not self.fuel:repair(player) then + self.fuel = nil -- replace fuel with its default + self:normalize_fuel(player) + end + end + + if self.valid then + self.module_set:repair(player) -- always becomes valid + end + + return self.valid +end + +return {init = init, unpack = unpack} diff --git a/modfiles/backend/data/Module.lua b/modfiles/backend/data/Module.lua new file mode 100644 index 000000000..9459c2451 --- /dev/null +++ b/modfiles/backend/data/Module.lua @@ -0,0 +1,161 @@ +local Object = require("backend.data.Object") + +---@class Module: Object, ObjectMethods +---@field class "Module" +---@field parent ModuleSet +---@field proto FPModulePrototype | FPPackedPrototype +---@field quality_proto FPQualityPrototype +---@field amount integer +---@field total_effects ModuleEffects +---@field effects_tooltip LocalisedString +local Module = Object.methods() +Module.__index = Module +script.register_metatable("Module", Module) + +---@param proto FPModulePrototype | FPPackedPrototype +---@param amount integer +---@param quality_proto FPQualityPrototype +---@return Module +local function init(proto, amount, quality_proto) + local object = Object.init({ + proto = proto, + quality_proto = quality_proto, + amount = amount, + + total_effects = nil, + effects_tooltip = "" + }, "Module", Module) --[[@as Module]] + if not proto.simplified then object:summarize_effects() end + return object +end + + +function Module:index() + OBJECT_INDEX[self.id] = self +end + + +---@return {name: string, quality: string} +function Module:elem_value() + return {name=self.proto.name, quality=self.quality_proto.name} +end + + +---@param new_amount integer +function Module:set_amount(new_amount) + self.amount = new_amount + self.parent:count_modules() + self:summarize_effects() +end + +function Module:summarize_effects() + local effects = ftable.shallow_copy(BLANK_EFFECTS) + for name, effect in pairs(self.proto.effects) do + local is_positive = util.effects.is_positive(name, effect) + local multiplier = (is_positive) and self.quality_proto.multiplier or 1 + effects[name] = effect * self.amount * multiplier + end + + self.total_effects = effects + self.effects_tooltip = util.effects.format(effects) +end + + +---@param object CopyableObject +---@return boolean success +---@return string? error +function Module:paste(object) + if object.class == "Module" then + ---@cast object Module + if self.parent:check_compatibility(object.proto) then + if self.proto == object.proto and self.quality_proto == object.quality_proto then + self:set_amount(math.min(object.amount, self.parent.module_limit)) + + self.parent:normalize({effects=true}) + return true, nil + else + local existing_module = self.parent:find({proto=object.proto, quality_proto=object.quality_proto}) + local parent = self.parent -- retain here because it can be changed below + + if existing_module then + existing_module:set_amount(existing_module.amount + object.amount) + parent:remove(self) + else + object:set_amount(math.min(object.amount, self.amount)) + parent:replace(self.parent, object) + end + + parent:normalize({sort=true, effects=true}) + return true, nil + end + else + return false, "incompatible" + end + else + return false, "incompatible_class" + end +end + + +---@class PackedModule: PackedObject +---@field class "Module" +---@field proto FPModulePrototype +---@field quality_proto FPQualityPrototype +---@field amount integer + +---@return PackedModule packed_self +function Module:pack() + return { + class = self.class, + proto = prototyper.util.simplify_prototype(self.proto, "category"), + quality_proto = prototyper.util.simplify_prototype(self.quality_proto, nil), + amount = self.amount + } +end + +---@param packed_self PackedModule +---@return Module module +local function unpack(packed_self, parent) + local unpacked_self = init(packed_self.proto, packed_self.amount) + unpacked_self.quality_proto = packed_self.quality_proto + unpacked_self.parent = parent + + return unpacked_self +end + + +---@return boolean valid +function Module:validate() + self.proto = prototyper.util.validate_prototype_object(self.proto, "category") + self.valid = (not self.proto.simplified) + + self.quality_proto = prototyper.util.validate_prototype_object(self.quality_proto, nil) + self.valid = (not self.quality_proto.simplified) and self.valid + + -- Can't be valid with an invalid parent + self.valid = self.parent.valid and self.valid + + -- Check whether the module is still compatible with its machine or beacon + if self.valid then self.valid = self.parent:check_compatibility(self.proto) end + + if self.valid then self:summarize_effects() end + + return self.valid +end + +---@param player LuaPlayer +---@return boolean success +function Module:repair(player) + if self.proto.simplified or not self.parent:check_compatibility(self.proto) then + return false -- the module can not be salvaged in this case and will be removed + else -- otherwise, the quality just needs to be reset + self.quality_proto = defaults.get_fallback("qualities").proto + end + + self.valid = true -- if it gets to here, the module was successfully repaired + self:summarize_effects() + return true +end + + +return {init = init, unpack = unpack} diff --git a/modfiles/backend/data/ModuleSet.lua b/modfiles/backend/data/ModuleSet.lua new file mode 100644 index 000000000..5c7711468 --- /dev/null +++ b/modfiles/backend/data/ModuleSet.lua @@ -0,0 +1,357 @@ +local Object = require("backend.data.Object") +local Module = require("backend.data.Module") + +---@alias ModuledObject Machine | Beacon + +---@class ModuleSet: Object, ObjectMethods +---@field class "ModuleSet" +---@field parent ModuledObject +---@field first Module? +---@field module_count integer +---@field module_limit integer +---@field empty_slots integer +local ModuleSet = Object.methods() +ModuleSet.__index = ModuleSet +script.register_metatable("ModuleSet", ModuleSet) + +---@param parent ModuledObject +---@return ModuleSet +local function init(parent) + local object = Object.init({ + first = nil, + + module_count = 0, + -- 0 as placeholder for simplified parents + module_limit = parent.proto.module_limit or 0, + empty_slots = parent.proto.module_limit or 0, + + parent = parent + }, "ModuleSet", ModuleSet) --[[@as ModuleSet]] + return object +end + + +function ModuleSet:index() + OBJECT_INDEX[self.id] = self + for module in self:iterator() do module:index() end +end + + +---@param module Module +---@param relative_object Module? +---@param direction NeighbourDirection? +function ModuleSet:insert(module, relative_object, direction) + module.parent = self + self:_insert(module, relative_object, direction) + self:count_modules() +end + +---@param module Module +function ModuleSet:remove(module) + module.parent = nil + self:_remove(module) + self:count_modules() +end + +---@param module Module +---@param new_module Module +function ModuleSet:replace(module, new_module) + new_module.parent = self + self:_replace(module, new_module) + self:count_modules() +end + + +---@param filter ObjectFilter +---@param pivot Module? +---@param direction NeighbourDirection? +---@return Module? module +function ModuleSet:find(filter, pivot, direction) + return self:_find(filter, pivot, direction) --[[@as Module?]] +end + +---@return Module? +function ModuleSet:find_last() + return self:_find_last() --[[@as Module?]] +end + +---@param filter ObjectFilter? +---@param pivot Module? +---@param direction NeighbourDirection? +---@return fun(): Module? +function ModuleSet:iterator(filter, pivot, direction) + return self:_iterator(filter, pivot, direction) +end + +---@param filter ObjectFilter? +---@param direction NeighbourDirection? +---@param pivot Module? +---@return number count +function ModuleSet:count(filter, pivot, direction) + return self:_count(filter, pivot, direction) +end + + +---@class ModuleNormalizeFeatures +---@field compatibility boolean? +---@field trim boolean? +---@field sort boolean? +---@field effects boolean? + +---@param features ModuleNormalizeFeatures +function ModuleSet:normalize(features) + self.module_limit = self.parent.proto.module_limit + + if features.compatibility then self:verify_compatibility() end + if features.trim then self:trim() end + if features.sort then self:sort() end + if features.effects then self.parent:summarize_effects() end + + self:count_modules() +end + +function ModuleSet:count_modules() + local count = 0 + for module in self:iterator() do + count = count + module.amount + end + self.module_count = count + self.empty_slots = self.module_limit - self.module_count +end + +function ModuleSet:verify_compatibility() + local modules_to_remove = {} + for module in self:iterator() do + if not self:check_compatibility(module.proto) then + table.insert(modules_to_remove, module) + end + end + + -- Actually remove incompatible modules; counts updated by calling function + for _, module in pairs(modules_to_remove) do self:remove(module) end +end + +function ModuleSet:trim() + local module_count, module_limit = self.module_count, self.module_limit + -- Return if the module count is within limits + if module_count <= module_limit then return end + + local modules_to_remove = {} + -- Traverse modules in reverse to trim them off the end + for module in self:iterator(nil, self:find_last(), "previous") do + -- Remove a whole module if it brings the count to >= limit + if (module_count - module.amount) >= module_limit then + table.insert(modules_to_remove, module) + module_count = module_count - module.amount + else -- Otherwise, diminish the amount on the module appropriately and break + local new_amount = module.amount - (module_count - module_limit) + module:set_amount(new_amount) + break + end + end + + -- Actually remove superfluous modules; counts updated by calling function + for _, module in pairs(modules_to_remove) do self:remove(module) end +end + + +local function module_comparator(a, b) + local a_module, b_module = a.proto.id, b.proto.id -- IDs are ordered sensibly + local a_quality, b_quality = a.quality_proto.level, b.quality_proto.level + if a_module < b_module then return true + elseif a_module > b_module then return false + elseif a_quality < b_quality then return true + elseif a_quality > b_quality then return false end + return false +end + +-- Sorts modules in a deterministic fashion so they are in the same order for every line +function ModuleSet:sort() + self:_sort(module_comparator) +end + + +---@return ModuleEffects +function ModuleSet:get_effects() + local effects = ftable.shallow_copy(BLANK_EFFECTS) + for module in self:iterator() do + for name, effect in pairs(module.total_effects) do + effects[name] = effects[name] + effect + end + end + return effects +end + + +---@param module_proto FPModulePrototype +---@return boolean compatible +function ModuleSet:check_compatibility(module_proto) + if not self.parent:uses_effects() then + return false + else + local compatible = true + local entity, recipe = self.parent.proto, self.parent.parent.recipe_proto + -- Any non-existing allowed list means all modules are allowed + + local function check_effect_compatibility(allowed_effects) + if allowed_effects == nil then return end + for name, value in pairs(module_proto.effects) do + -- Effects only need to be in the allowed list if they are considered positive + if not allowed_effects[name] and util.effects.is_positive(name, value) then + compatible = false + end + end + end + check_effect_compatibility(entity.allowed_effects) + check_effect_compatibility(recipe.allowed_effects) + + local function check_category_compatibility(allowed_categories) + if allowed_categories == nil then return end + if not allowed_categories[module_proto.category] then + compatible = false + end + end + check_category_compatibility(entity.allowed_module_categories) + check_category_compatibility(recipe.allowed_module_categories) + + return compatible + end +end + +---@return ItemPrototypeFilter[] +function ModuleSet:compile_filter() + local compatible_modules = {} + for module_name, module_proto in pairs(MODULE_NAME_MAP) do + if self:check_compatibility(module_proto) then + table.insert(compatible_modules, module_name) + end + end + + return {{filter="name", name=compatible_modules}} +end + + +function ModuleSet:clear() + self.first = nil + self:normalize({effects=true}) +end + + +---@return DefaultModuleData[] +function ModuleSet:compile_default() + local modules_default = {} + for module in self:iterator() do + table.insert(modules_default, { + prototype = module.proto.name, + quality = module.quality_proto.name, + amount = module.amount + }) + end + return modules_default +end + +---@param default_data DefaultModule[] +---@return boolean equals +function ModuleSet:equals_default(default_data) + if not default_data then return (self.module_count == 0) end + if #default_data ~= self:count() then return false end + + local indexed_default = {} + for _, default in pairs(default_data) do + indexed_default[default.proto.name] = default + end + for module in self:iterator() do + local default = indexed_default[module.proto.name] + if not default or default.quality.name ~= module.quality_proto.name + or default.amount ~= module.amount then + return false + end + end + return true +end + +---@param module_default DefaultModule[] +function ModuleSet:ingest_default(module_default) + if not module_default then return end -- no default to ingest + for _, default_module in pairs(module_default) do + self:insert(Module.init(default_module.proto, default_module.amount, default_module.quality)) + end + -- Compatibility check necessary because the module might not be compatible with the recipe + self:normalize({compatibility=true, trim=true, sort=true, effects=true}) -- normalize for outdated defaults +end + + +---@param module Module +---@return boolean success +---@return string? error +function ModuleSet:paste(module) + if not self:check_compatibility(module.proto) then + return false, "incompatible" + elseif self.empty_slots == 0 then + return false, "no_empty_slots" + end + + local desired_amount = math.min(module.amount, self.empty_slots) + local existing_module = self:find({proto=module.proto, quality_proto=module.quality_proto}) + if existing_module then + existing_module:set_amount(existing_module.amount + desired_amount) + else + self:insert(module) + module:set_amount(desired_amount) + end + + self:normalize({sort=true, effects=true}) + return true, nil +end + + +---@class PackedModuleSet: PackedObject +---@field class "ModuleSet" +---@field modules PackedModule[]? + +---@return PackedModuleSet packed_self +function ModuleSet:pack() + return { + class = self.class, + modules = self:_pack() + } +end + +---@param packed_self PackedModuleSet +---@param parent ModuledObject +---@return ModuleSet module_sets +local function unpack(packed_self, parent) + local unpacked_self = init(parent) + + unpacked_self.first = Object.unpack(packed_self.modules, Module.unpack, unpacked_self) --[[@as Module]] + unpacked_self:count_modules() + + return unpacked_self +end + + +---@return boolean valid +function ModuleSet:validate() + self.valid = self:_validate() + + -- Can't be valid with an invalid parent + self.valid = self.parent.valid and self.valid + + if self.valid then + self:normalize({trim=true, sort=true, effects=true}) + end + + return self.valid +end + +---@param player LuaPlayer +---@return boolean success +function ModuleSet:repair(player) + self:_repair(player) + self:normalize({trim=true, sort=true, effects=true}) + + self.valid = true + return self.valid +end + +return {init = init, unpack = unpack} diff --git a/modfiles/backend/data/Object.lua b/modfiles/backend/data/Object.lua new file mode 100644 index 000000000..e1f4608f3 --- /dev/null +++ b/modfiles/backend/data/Object.lua @@ -0,0 +1,307 @@ +---@alias ObjectID integer + +---@class Object +---@field id ObjectID +---@field class string +---@field valid boolean +---@field parent Object? +---@field next Object? +---@field previous Object? + +---@class ObjectMethods +local methods = {} + +local Object = {} -- class annotation purposefully not attached + +---@param data table +---@param class string +---@param metatable table +---@return Object +function Object.init(data, class, metatable) + local object = ftable.shallow_merge{ + { + id = storage.next_object_ID, + class = class, + valid = true, + parent = nil, + next = nil, + previous = nil + }, + data + } + storage.next_object_ID = storage.next_object_ID + 1 + + setmetatable(object, metatable) + + -- If the index doesn't exist yet, it will be filled in later + if OBJECT_INDEX then OBJECT_INDEX[object.id] = object end + + return object +end + +---@return ObjectMethods +function Object.methods() + return ftable.shallow_copy(methods) +end + + +---@alias NeighbourDirection "next" | "previous" + +---@alias ObjectFilter {id: integer, archived: boolean} +local filter_options = {"id", "archived", "valid", "proto", "quality_proto"} + +---@param object Object +---@param filter ObjectFilter? +---@return boolean matched +local function match(object, filter) + if filter == nil then return true end + + for _, option in pairs(filter_options) do + -- Only match as filtered if object property is explicitly filtered out + if filter[option] ~= nil and object[option] ~= filter[option] then + return false + end + end + + return true +end + + +---@protected +---@param new_object Object +---@param relative_object Object? +---@param direction NeighbourDirection? +function methods:_insert(new_object, relative_object, direction) + new_object.next, new_object.previous = nil, nil + + if self.first == nil then + self.first = new_object + else + if relative_object == nil then -- no relative object means append + relative_object, direction = self:_find_last(), "next" + ---@cast relative_object -nil + ---@cast direction -nil + end + + -- Make sure list header is adjusted if necessary + if direction == "previous" and relative_object.previous == nil then + self.first = new_object + end + + -- Don't ask how I got to this, but it checks out + local other_direction = (direction == "next") and "previous" or "next" + if relative_object[direction] ~= nil then + new_object[direction] = relative_object[direction] + relative_object[direction][other_direction] = new_object + end + + new_object[other_direction] = relative_object + relative_object[direction] = new_object + end +end + +---@protected +---@param object Object +function methods:_remove(object) + if object.previous == nil then + self.first = object.next + if object.next then object.next.previous = nil end + else + object.previous.next = object.next + if object.next then object.next.previous = object.previous end + end +end + +---@protected +---@param object Object +---@param new_object Object +function methods:_replace(object, new_object) + if object.previous == nil then + self.first = new_object + else + new_object.previous = object.previous + object.previous.next = new_object + end + if object.next then + new_object.next = object.next + object.next.previous = new_object + end +end + + +---@protected +---@param object Object +---@param direction NeighbourDirection +---@param spots integer? +---@param filter ObjectFilter? +function methods:_shift(object, direction, spots, filter) + spots = spots or math.huge -- no spots means shift to end + local spots_moved = 0 + + local next_object = object + while spots_moved < spots and next_object[direction] ~= nil do + next_object = next_object[direction] + local matched = match(next_object, filter) + if matched then spots_moved = spots_moved + 1 end + end + + if next_object.id ~= object.id then -- only move if necessary + self:_remove(object) + self:_insert(object, next_object, direction) + end +end + + +---@protected +---@param filter ObjectFilter +---@param pivot Object? +---@param direction NeighbourDirection? +---@return Object? object +function methods:_find(filter, pivot, direction) + local next_object = (not pivot and not direction) and self.first or pivot + while next_object ~= nil do + if match(next_object, filter) then return next_object end + next_object = next_object[direction or "next"] + end + return nil +end + +---@protected +---@return Object? last_object +function methods:_find_last() + if not self.first then return nil end + local last_object = self.first + while last_object.next ~= nil do + last_object = last_object.next + end + return last_object +end + + +---@protected +---@param filter ObjectFilter? +---@param pivot Object? +---@param direction NeighbourDirection? +---@return fun(): Object? +function methods:_iterator(filter, pivot, direction) + local next_object = (not pivot and not direction) and self.first or pivot + return function() + while next_object ~= nil do + local matched = match(next_object, filter) + local current_object = next_object + next_object = next_object[direction or "next"] + if matched then return current_object end + end + end +end + +---@protected +---@param filter ObjectFilter? +---@param pivot Object? +---@param direction NeighbourDirection? +---@return Object[] +function methods:_as_list(filter, pivot, direction) + local list = {} + for object in self:_iterator(filter, pivot, direction) do + table.insert(list, object) + end + return list +end + +---@protected +---@param filter ObjectFilter? +---@param pivot Object? +---@param direction NeighbourDirection? +---@return number count +function methods:_count(filter, pivot, direction) + local count = 0 + for _ in self:_iterator(filter, pivot, direction) do + count = count + 1 + end + return count +end + + +---@protected +---@param comparator function +function methods:_sort(comparator) + local next_object = self.first + self.first = nil -- clear to re-insert into below + + while next_object ~= nil do + local current_object = next_object + next_object = next_object.next + + local inserted = false + for object in self:iterator() do + if comparator(object, current_object) then + self:_insert(current_object, object, "previous") + inserted = true + break + end + end + if not inserted then -- first or last element + self:_insert(current_object) + end + end +end + + +---@class PackedObject +---@field class string + +---@protected +---@return PackedObject[] packed_objects +function methods:_pack() + local packed_objects = {} + for object in self:_iterator() do + table.insert(packed_objects, object:pack()) + end + return packed_objects +end + +---@protected +---@param packed_objects PackedObject[] +---@param unpacker fun(item: PackedObject): Object +---@return Object? first_object +function Object.unpack(packed_objects, unpacker, parent) + local first_object, latest_object = nil, nil + for _, packed_object in pairs(packed_objects) do + local object = unpacker(packed_object) + object.parent = parent + + if not first_object then + first_object = object + else + latest_object.next = object + object.previous = latest_object + end + latest_object = object + end + return first_object +end + + +---@protected +---@return boolean valid +function methods:_validate() + local valid = true + for object in self:_iterator() do + -- Stays true until a single dataset is invalid, then stays false + valid = object:validate() and valid + end + return valid +end + +---@protected +---@param player LuaPlayer +---@param pivot Object? +function methods:_repair(player, pivot) + for object in self:_iterator(nil, pivot) do + if not object.valid and not object:repair(player) then + object.parent:_remove(object) + end + end +end + +return Object diff --git a/modfiles/backend/data/Product.lua b/modfiles/backend/data/Product.lua new file mode 100644 index 000000000..adf5df185 --- /dev/null +++ b/modfiles/backend/data/Product.lua @@ -0,0 +1,138 @@ +local Object = require("backend.data.Object") + +---@alias ProductDefinedBy "amount" | "belts" | "lanes" + +---@class Product: Object, ObjectMethods +---@field class "Product" +---@field parent Factory +---@field proto FPItemPrototype | FPPackedPrototype +---@field defined_by ProductDefinedBy +---@field required_amount number +---@field belt_proto FPBeltPrototype | FPPackedPrototype +---@field amount number +local Product = Object.methods() +Product.__index = Product +script.register_metatable("Product", Product) + +---@param proto FPItemPrototype +---@return Product +local function init(proto) + local object = Object.init({ + proto = proto, + defined_by = "amount", + required_amount = 0, -- always per second + belt_proto = nil, + + amount = 0 -- the amount satisfied by the solver + }, "Product", Product) --[[@as Product]] + return object +end + + +function Product:index() + OBJECT_INDEX[self.id] = self +end + + +-- Returns the amount needed to satisfy this item +---@return number required_amount +function Product:get_required_amount() + if self.defined_by == "amount" then + return self.required_amount + else -- defined_by == "belts" | "lanes" + local multiplier = (self.defined_by == "belts") and 1 or 0.5 + return self.required_amount * (self.belt_proto.throughput * multiplier) + end +end + + +-- Only used when switching between belts and lanes +---@param new_defined_by ProductDefinedBy +function Product:change_definition(new_defined_by) + if self.defined_by ~= "amount" and new_defined_by ~= self.defined_by then + self.defined_by = new_defined_by + + local multiplier = (new_defined_by == "belts") and 0.5 or 2 + self.required_amount = self.required_amount * multiplier + end +end + + +---@param object CopyableObject +---@return boolean success +---@return string? error +function Product:paste(object) + if object.class == "Product" or object.class == "SimpleItem" or object.class == "Fuel" then + -- Avoid duplicate items, but allow pasting over the same item proto + local existing_item = self.parent:find({proto=object.proto}) + if existing_item and not (self.proto.name == object.proto.name) then + return false, "already_exists" + end + + if object.class == "Product" then + self.parent:replace(self, object) + elseif object.class == "SimpleItem" or object.class == "Fuel" then + local product = init(object.proto) -- defined_by = "amount" + product.required_amount = object.amount + self.parent:replace(self, product) + end + + return true, nil + else + return false, "incompatible_class" + end +end + + +---@class PackedProduct: PackedObject +---@field class "Product" +---@field proto FPPackedPrototype +---@field defined_by ProductDefinedBy +---@field required_amount number +---@field belt_proto FPPackedPrototype? + +---@return PackedProduct packed_self +function Product:pack() + return { + class = self.class, + proto = prototyper.util.simplify_prototype(self.proto, "type"), + defined_by = self.defined_by, + required_amount = self.required_amount, + belt_proto = (self.belt_proto) and prototyper.util.simplify_prototype(self.belt_proto, nil) + } +end + +---@param packed_self PackedProduct +---@return Product Product +local function unpack(packed_self) + local unpacked_self = init(packed_self.proto) + + unpacked_self.defined_by = packed_self.defined_by + unpacked_self.required_amount = packed_self.required_amount + unpacked_self.belt_proto = packed_self.belt_proto + + return unpacked_self +end + + +---@return boolean valid +function Product:validate() + self.valid = true + + self.proto = prototyper.util.validate_prototype_object(self.proto, "type") + self.valid = (not self.proto.simplified) and self.valid + + self.belt_proto = (self.belt_proto) and prototyper.util.validate_prototype_object(self.belt_proto, nil) + if self.belt_proto then self.valid = (not self.belt_proto.simplified) and self.valid end + + return self.valid +end + +---@param player LuaPlayer +---@return boolean success +function Product:repair(player) + -- If the item is invalid, either prototype is simplified, making this unrepairable + return false +end + +return {init = init, unpack = unpack} diff --git a/modfiles/backend/data/Realm.lua b/modfiles/backend/data/Realm.lua new file mode 100644 index 000000000..fc6027450 --- /dev/null +++ b/modfiles/backend/data/Realm.lua @@ -0,0 +1,83 @@ +local Object = require("backend.data.Object") +local District = require("backend.data.District") + +---@class Realm: Object, ObjectMethods +---@field class "Realm" +---@field first District +local Realm = Object.methods() +Realm.__index = Realm +script.register_metatable("Realm", Realm) + +---@param district District? +---@return Realm +local function init(district) + local object = Object.init({ + first = nil + }, "Realm", Realm) --[[@as Realm]] + object:insert(district or District.init()) -- one always exists + return object +end + + +function Realm:index() + OBJECT_INDEX[self.id] = self + for district in self:iterator() do district:index() end +end + + +---@param district District +---@param relative_object District? +---@param direction NeighbourDirection? +function Realm:insert(district, relative_object, direction) + district.parent = self + self:_insert(district, relative_object, direction) +end + +---@param district District +function Realm:remove(district) + -- Delete factories separately so they can clean up any nth_tick events + for factory in district:iterator() do district:remove(factory) end + district.parent = nil + self:_remove(district) +end + +---@param district District +---@param direction NeighbourDirection +---@param spots integer? +function Realm:shift(district, direction, spots) + self:_shift(district, direction, spots) +end + + +---@param filter ObjectFilter +---@param pivot District? +---@param direction NeighbourDirection? +---@return District? district +function Realm:find(filter, pivot, direction) + return self:_find(filter, pivot, direction) --[[@as District?]] +end + + +---@param filter ObjectFilter? +---@param pivot District? +---@param direction NeighbourDirection? +---@return fun(): District? +function Realm:iterator(filter, pivot, direction) + return self:_iterator(filter, pivot, direction) +end + +---@param filter ObjectFilter? +---@param direction NeighbourDirection? +---@param pivot District? +---@return number count +function Realm:count(filter, pivot, direction) + return self:_count(filter, pivot, direction) +end + + +--- The realm can't be invalid, this just cleanly validates Districts +function Realm:validate() + self:_validate() +end + +return {init = init} diff --git a/modfiles/backend/handlers/defaults.lua b/modfiles/backend/handlers/defaults.lua new file mode 100644 index 000000000..986baadc9 --- /dev/null +++ b/modfiles/backend/handlers/defaults.lua @@ -0,0 +1,247 @@ +defaults = {} + +---@class DefaultPrototype +---@field proto AnyFPPrototype +---@field quality FPQualityPrototype? +---@field modules DefaultModule[]? +---@field beacon_amount integer? + +---@class DefaultModule +---@field proto FPModulePrototype +---@field quality FPQualityPrototype +---@field amount integer + +---@class DefaultData +---@field prototype string? +---@field quality string? +---@field modules DefaultModuleData[]? +---@field beacon_amount integer? + +---@class DefaultModuleData +---@field prototype string +---@field quality string +---@field amount integer + +---@alias PrototypeDefaultWithCategory { [integer]: DefaultPrototype } +---@alias AnyPrototypeDefault DefaultPrototype | PrototypeDefaultWithCategory + +-- Returns the default prototype for the given type, incorporating the category, if given +---@param player LuaPlayer +---@param data_type DataType +---@param category (integer | string)? +---@return DefaultPrototype +function defaults.get(player, data_type, category) + local default = util.globals.preferences(player)["default_" .. data_type] + local category_table = prototyper.util.find(data_type, nil, category) + return (category_table == nil) and default or default[category_table.id] +end + +-- Sets the default for the given type, incorporating the category if given +---@param player LuaPlayer +---@param data_type DataType +---@param data DefaultData +---@param category (integer | string)? +function defaults.set(player, data_type, data, category) + local default = defaults.get(player, data_type, category) + + if data.prototype then + default.proto = prototyper.util.find(data_type, data.prototype, category) --[[@as AnyFPPrototype]] + end + if data.quality then + default.quality = prototyper.util.find("qualities", data.quality, nil) --[[@as FPQualityPrototype]] + end + if data.modules then + default.modules = {} + for _, default_module in pairs(data.modules) do + table.insert(default.modules, { + proto = MODULE_NAME_MAP[default_module.prototype], + quality = prototyper.util.find("qualities", default_module.quality, nil), + amount = default_module.amount + }) + end + if #default.modules == 0 then default.modules = nil end + end + if data.beacon_amount then + default.beacon_amount = data.beacon_amount + end +end + +-- Sets the default prototype data for all categories of the given type +---@param player LuaPlayer +---@param data_type DataType +---@param data DefaultData +function defaults.set_all(player, data_type, data) + -- Doesn't make sense for prototypes without categories, just use .set() instead + if prototyper.data_types[data_type] == false then return end + + for _, category_data in pairs(storage.prototypes[data_type]) do + local matched_prototype = prototyper.util.find(data_type, data.prototype, category_data.id) + if matched_prototype then + data.prototype = matched_prototype.name + defaults.set(player, data_type, data, category_data.id) + end + end +end + + +---@param player LuaPlayer +---@param data_type DataType +---@param object Machine | Fuel | Beacon +---@param category (integer | string)? +---@return boolean equals +function defaults.equals_default(player, data_type, object, category) + local default = defaults.get(player, data_type, category) + local same_proto = (default.proto.name == object.proto.name) + local same_quality, same_modules = true, true + if object.quality_proto then same_quality = (default.quality.id == object.quality_proto.id) end + if object.module_set then same_modules = object.module_set:equals_default(default.modules) end + return same_proto and same_quality and same_modules +end + +---@param player LuaPlayer +---@param data_type DataType +---@param object Machine | Fuel +---@return boolean equals_all +function defaults.equals_all_defaults(player, data_type, object) + for _, category_data in pairs(storage.prototypes[data_type]) do + local in_category = (prototyper.util.find(data_type, object.proto.name, category_data.id) ~= nil) + local equals_default = defaults.equals_default(player, data_type, object, category_data.id) + if in_category and not equals_default then + return false + end + end + return true +end + + +local prototypes_with_quality = {machines=true, beacons=true, modules=true, pumps=true, wagons=true} + +-- Returns the fallback default for the given type of prototype +---@param data_type DataType +---@return AnyPrototypeDefault +function defaults.get_fallback(data_type) + local prototypes = storage.prototypes[data_type] ---@type AnyIndexedPrototypes + local default_quality = prototypes_with_quality[data_type] and storage.prototypes.qualities[1] or nil + + local fallback = nil + if prototyper.data_types[data_type] == false then + ---@cast prototypes IndexedPrototypes + fallback = {proto=prototypes[1], quality=default_quality, modules=nil, beacon_amount=nil} + else + ---@cast prototypes IndexedPrototypesWithCategory + fallback = {} + for _, category in pairs(prototypes) do + fallback[category.id] = {proto=category.members[1], quality=default_quality, modules=nil, beacon_amount=nil} + end + end + + return fallback +end + + +---@param data_type DataType +---@param fallback DefaultPrototype +---@param default DefaultPrototype +---@param category string? +---@return DefaultPrototype migrated_default +local function migrate_prototype_default(data_type, fallback, default, category) + local equivalent_proto = prototyper.util.find(data_type, default.proto.name, category) + if not equivalent_proto then + return fallback -- full reset if prototype went missing + else + local migrated_default = { + proto = equivalent_proto, + quality = nil, -- only migrated if relevant for this data_type + modules = nil, -- only migrated to anything if previously present + beacon_amount = default.beacon_amount or fallback.beacon_amount -- could be nil + } + + if prototypes_with_quality[data_type] then + local equivalent_quality = prototyper.util.find("qualities", default.quality.name, nil) + migrated_default.quality = equivalent_quality or fallback.quality + end + + if default.modules then + migrated_default.modules = {} + for _, module in pairs(default.modules) do + local equivalent_module = prototyper.util.find("modules", module.proto.name, module.proto.category) + if equivalent_module then + local equivalent_quality = prototyper.util.find("qualities", module.quality.name, nil) + table.insert(migrated_default.modules, { + proto = equivalent_module, + quality = equivalent_quality or fallback.quality, -- same quality as the base proto + amount = module.amount + }) + end + end + if #migrated_default.modules == 0 then migrated_default.modules = nil end + end + + return migrated_default + end +end + +-- Kinda unclean that I have to do this, but it's better than storing it elsewhere +local category_designations = {machines="category", items="type", + fuels="combined_category", wagons="category", modules="category"} + +-- Migrates the default prototype preferences, trying to preserve the users choices +-- When this is called, the loader cache will already exist +---@param player_table PlayerTable +function defaults.migrate(player_table) + local preferences = player_table.preferences + + for data_type, has_categories in pairs(prototyper.data_types) do + local fallback = defaults.get_fallback(data_type) + local default = preferences["default_" .. data_type] + if default == nil then goto skip end + + if not has_categories then + preferences["default_" .. data_type] = migrate_prototype_default(data_type, fallback, default, nil) + else + local default_map = {} + for _, default_data in pairs(default) do + local category_name = default_data.proto[category_designations[data_type]] ---@type string + default_map[category_name] = default_data + end + + local new_defaults = {} + for _, category in pairs(storage.prototypes[data_type]) do + local previous_default = default_map[category.name] + new_defaults[category.id] = (not previous_default) and fallback[category.id] + or migrate_prototype_default(data_type, fallback[category.id], previous_default, category.name) + end + preferences["default_" .. data_type] = new_defaults + end + ::skip:: + end +end + + +---@param player LuaPlayer +---@param data_type DataType +---@param category (integer | string)? +---@return LocalisedString +function defaults.generate_tooltip(player, data_type, category) + local default = defaults.get(player, data_type, category) + local tooltip = {"", {"fp.current_default"}, "\n"} + local quality = default.quality + + local name_line = {"", "[img=" .. default.proto.sprite .. "] ", default.proto.localised_name} + local proto_line = (not quality or not quality.always_show) and {"fp.tt_title", name_line} + or {"fp.tt_title_with_note", name_line, quality.rich_text} + table.insert(tooltip, proto_line) + if default.beacon_amount then table.insert(tooltip, " x" .. default.beacon_amount) end + + if default.modules then + local modules = "" + for _, module in pairs(default.modules) do + for i = 1, module.amount, 1 do + modules = modules .. "[img=" .. module.proto.sprite .. "]" + end + end + table.insert(tooltip, {"", "\n", modules}) + end + + return tooltip +end diff --git a/modfiles/backend/handlers/generator.lua b/modfiles/backend/handlers/generator.lua new file mode 100644 index 000000000..e68890d87 --- /dev/null +++ b/modfiles/backend/handlers/generator.lua @@ -0,0 +1,1394 @@ +local generator_util = require("backend.handlers.generator_util") + +local generator = { + machines = {}, + recipes = {}, + items = {}, + fuels = {}, + belts = {}, + pumps = {}, + wagons = {}, + modules = {}, + beacons = {}, + locations = {}, + qualities = {} +} + + +---@class FPPrototype +---@field id integer +---@field data_type DataType +---@field name string +---@field localised_name LocalisedString +---@field sprite SpritePath + +---@class FPPrototypeWithCategory: FPPrototype +---@field category_id integer + +---@alias AnyFPPrototype FPPrototype | FPPrototypeWithCategory + + +---@param list AnyNamedPrototypes +---@param prototype FPPrototype +---@param category string? +local function insert_prototype(list, prototype, category) + if category == nil then + ---@cast list NamedPrototypes + list[prototype.name] = prototype + else + ---@cast list NamedPrototypesWithCategory + list[category] = list[category] or { name = category, members = {} } + list[category].members[prototype.name] = prototype + end +end + +---@param list AnyNamedPrototypes +---@param name string +---@param category string? +local function remove_prototype(list, name, category) + if category == nil then + ---@cast list NamedPrototypes + list[name] = nil + else + ---@cast list NamedPrototypesWithCategory + list[category].members[name] = nil + if next(list[category].members) == nil then list[category] = nil end + end +end + + +---@class FPMachinePrototype: FPPrototypeWithCategory +---@field data_type "machines" +---@field category string +---@field elem_type ElemType +---@field prototype_category PrototypeCategory? +---@field ingredient_limit integer +---@field fluid_channels FluidChannels +---@field speed double +---@field energy_type "burner" | "electric" | "void" +---@field energy_usage double +---@field energy_drain double +---@field emissions_per_joule EmissionsMap +---@field emissions_per_second EmissionsMap +---@field burner MachineBurner? +---@field built_by_item FPItemPrototype? +---@field effect_receiver EffectReceiver? +---@field allowed_effects AllowedEffects +---@field allowed_module_categories { [string]: boolean }? +---@field module_limit integer +---@field surface_conditions SurfaceCondition[] +---@field resource_drain_rate number? +---@field uses_force_mining_productivity_bonus boolean? + +---@class FluidChannels +---@field input integer +---@field output integer + +---@class MachineBurner +---@field effectivity double +---@field categories { [string]: boolean } +---@field combined_category string + +---@alias EmissionsMap { [string]: double } +---@alias PrototypeCategory ("assembling_machine" | "furnace" | "rocket_silo" | "mining_drill") + +-- Generates a table containing all machines for all categories +---@return NamedPrototypesWithCategory +function generator.machines.generate() + local machines = {} ---@type NamedPrototypesWithCategory + + ---@param category string + ---@param proto LuaEntityPrototype + ---@param prototype_category PrototypeCategory? + ---@return FPMachinePrototype? + local function generate_category_entry(category, proto, prototype_category) + -- First, determine if there is a valid sprite for this machine + local sprite = generator_util.determine_entity_sprite(proto) + if sprite == nil then return end + + -- Determine data related to the energy source + local energy_type, emissions_per_joule = "", {} -- no emissions if no energy source is present + local burner = nil ---@type MachineBurner + local energy_usage, energy_drain = (proto.energy_usage or proto.get_max_energy_usage() or 0), 0 + + -- Determine the name of the item that actually builds this machine for the item requester + -- There can technically be more than one, but bots use the first one, so I do too + local built_by_item = (proto.items_to_place_this) and proto.items_to_place_this[1].name or nil + + -- Determine the details of this entities energy source + local burner_prototype, fluid_burner_prototype = proto.burner_prototype, proto.fluid_energy_source_prototype + if burner_prototype then + energy_type = "burner" + emissions_per_joule = burner_prototype.emissions_per_joule + burner = {effectivity=burner_prototype.effectivity, categories=burner_prototype.fuel_categories, + combined_category=""} -- combined filled in by fuel generator + + -- Only supports fluid energy that burns_fluid for now, as it works the same way as solid burners + -- Also doesn't respect scale_fluid_usage and fluid_usage_per_tick for now, let the reports come + elseif fluid_burner_prototype then + emissions_per_joule = fluid_burner_prototype.emissions_per_joule + + if fluid_burner_prototype.burns_fluid then + energy_type = "burner" + burner = {effectivity=fluid_burner_prototype.effectivity, categories={["fluid-fuel"] = true}, + combined_category=""} -- combined filled in by fuel generator + + else -- Avoid adding this type of complex fluid energy as electrical energy + -- When I add support for this, I need to take care of limiting min/max temps on the fuel + energy_type = "void" + end + + elseif proto.electric_energy_source_prototype then + energy_type = "electric" + energy_drain = proto.electric_energy_source_prototype.drain + emissions_per_joule = proto.electric_energy_source_prototype.emissions_per_joule + + elseif proto.void_energy_source_prototype then + energy_type = "void" + emissions_per_joule = proto.void_energy_source_prototype.emissions_per_joule + end + + -- Determine fluid input/output channels + local fluid_channels = {input = 0, output = 0} + if fluid_burner_prototype then fluid_channels.input = fluid_channels.input - 1 end + + for _, fluidbox in pairs(proto.fluidbox_prototypes) do + if fluidbox.production_type == "output" then + fluid_channels.output = fluid_channels.output + 1 + else -- "input" and "input-output" + fluid_channels.input = fluid_channels.input + 1 + end + end + + local effect_receiver = proto.effect_receiver or { + uses_module_effects = false, + uses_beacon_effects = false, + uses_surface_effects = false + } + local base_effect = effect_receiver.base_effect -- can be nil + effect_receiver.base_effect = generator_util.formatted_effects(base_effect) + + local machine = { + name = proto.name, + localised_name = proto.localised_name, + sprite = sprite, + category = category, + elem_type = "entity", + prototype_category = prototype_category, + ingredient_limit = (proto.ingredient_count or 255), + product_limit = (proto.max_item_product_count or 255), + fluid_channels = fluid_channels, + speed = proto.get_crafting_speed(), + energy_type = energy_type, + energy_usage = energy_usage, + energy_drain = energy_drain, + emissions_per_joule = emissions_per_joule, + emissions_per_second = proto.emissions_per_second or {}, + burner = burner, + built_by_item = built_by_item, + effect_receiver = effect_receiver, + allowed_effects = proto.allowed_effects or {}, + allowed_module_categories = proto.allowed_module_categories, + module_limit = (proto.module_inventory_size or 0), + surface_conditions = proto.surface_conditions, + uses_force_mining_productivity_bonus = proto.uses_force_mining_productivity_bonus + } + generator_util.check_machine_effects(machine) + generator_util.sort_machine_burner_categories(machine) + + return machine + end + + local biggest_chest = nil + + for _, proto in pairs(prototypes.entity) do + if proto.crafting_categories and not proto.hidden and proto.energy_usage ~= nil + and not generator_util.is_irrelevant_machine(proto) then + -- Silo launch recipes use a separate machine + if proto.type == "rocket-silo" then + local machine = generate_category_entry("launch-rocket", proto, nil) + if machine then + machine.energy_usage = 0 + machine.built_by_item = nil + machine.effect_receiver = { + uses_module_effects = false, + uses_beacon_effects = false, + uses_surface_effects = false + } + machine.allowed_effects = {} + machine.module_limit = 0 + insert_prototype(machines, machine, machine.category) + end + end + + -- Silos are also added as normal to produce rocket parts + for category, _ in pairs(proto.crafting_categories) do + local prototype_category = proto.type:gsub("-", "_") + local machine = generate_category_entry(category, proto, prototype_category) + if machine then insert_prototype(machines, machine, machine.category) end + end + + elseif proto.type == "mining-drill" and not proto.hidden then + for category, _ in pairs(proto.resource_categories) do + local machine = generate_category_entry(category, proto, "mining_drill") + if machine then + machine.speed = proto.mining_speed + machine.resource_drain_rate = proto.resource_drain_rate_percent / 100 + insert_prototype(machines, machine, category) + end + end + + elseif proto.type == "offshore-pump" and not proto.hidden then + local fluid_box = proto.fluidbox_prototypes[1] + local fixed_fluid = (fluid_box and fluid_box.filter) and fluid_box.filter.name or nil + local category = (fixed_fluid) and ("offshore-pump-" .. fixed_fluid) or "offshore-pump" + local machine = generate_category_entry(category, proto, nil) + if machine then + machine.speed = proto.get_pumping_speed() + insert_prototype(machines, machine, category) + end + + elseif proto.type == "agricultural-tower" and not proto.hidden then + local machine = generate_category_entry(proto.type, proto, nil) + if machine then + machine.speed = 1 -- could be based on available tiles, but not used for now + machine.energy_usage = 0 -- TODO implemented later: energy_usage, crane_energy_usage + insert_prototype(machines, machine, proto.type) + end + + elseif proto.type == "container" and not proto.hidden then + -- Just find the biggest container as a spoilage machine + local size = proto.get_inventory_size(defines.inventory.chest) + local current_size = biggest_chest and biggest_chest.get_inventory_size(defines.inventory.chest) or 0 + if current_size < size then biggest_chest = proto end + end + + -- Add machines that produce steam (ie. boilers) + --[[ for _, fluidbox in ipairs(proto.fluidbox_prototypes) do + if fluidbox.production_type == "output" and fluidbox.filter + and fluidbox.filter.name == "steam" and proto.target_temperature ~= nil then + -- Exclude any boilers that use heat as their energy source + if proto.burner_prototype or proto.electric_energy_source_prototype then + -- Find the corresponding input fluidbox + local input_fluidbox = nil ---@type LuaFluidBoxPrototype + for _, fb in ipairs(proto.fluidbox_prototypes) do + if fb.production_type == "input-output" or fb.production_type == "input" then + input_fluidbox = fb + break + end + end + + -- Add the machine if it has a valid input fluidbox + if input_fluidbox ~= nil then + local category = "steam-" .. proto.target_temperature + local machine = generate_category_entry(category, proto) + if machine then + local temp_diff = proto.target_temperature - input_fluidbox.filter.default_temperature + local energy_per_unit = input_fluidbox.filter.heat_capacity * temp_diff + machine.speed = machine.energy_usage / energy_per_unit + + insert_prototype(machines, machine, machine.category) + + -- Add every boiler to the general steam category (steam without temperature) + local general_machine = ftable.deep_copy(machine) + general_machine.category = "general-steam" + insert_prototype(machines, general_machine, general_machine.category) + end + end + end + end + end ]] + end + + if biggest_chest then + local machine = generate_category_entry("purposeful-spoiling", biggest_chest, nil) + if machine then + machine.speed, machine.energy_usage = 1, 0 + machine.surface_conditions = nil -- the chest isn't actually needed for spoiling to happen + insert_prototype(machines, machine, "purposeful-spoiling") + end + end + + return machines +end + +---@param machines NamedPrototypesWithCategory +function generator.machines.second_pass(machines) + -- Go over all recipes to find unused categories + local used_category_names = {} ---@type { [string]: boolean } + for _, recipe_proto in pairs(storage.prototypes.recipes) do + used_category_names[recipe_proto.category] = true + end + + for _, machine_category in pairs(machines) do + if used_category_names[machine_category.name] == nil then + machines[machine_category.name] = nil + end + end + + -- Filter out burner machines that don't have any valid fuel categories + local fuels = storage.prototypes.fuels + for _, machine_category in pairs(machines) do + for _, machine_proto in pairs(machine_category.members) do + if machine_proto.energy_type == "burner" and not fuels[machine_proto.burner.combined_category] then + remove_prototype(machines, machine_proto.name, machine_category.name) + end + end + + -- If the category ends up empty because of this, make sure to remove it + if not next(machine_category.members) then machines[machine_category.name] = nil end + end + + + -- Replace built_by_item names with prototype references + local item_prototypes = storage.prototypes.items["item"].members ---@type { [string]: FPItemPrototype } + for _, machine_category in pairs(machines) do + for _, machine_proto in pairs(machine_category.members) do + if machine_proto.built_by_item then + machine_proto.built_by_item = item_prototypes[machine_proto.built_by_item] + end + end + end +end + +---@param a FPMachinePrototype +---@param b FPMachinePrototype +---@return boolean +function generator.machines.sorting_function(a, b) + if a.speed < b.speed then return true + elseif a.speed > b.speed then return false end + return false +end + + +---@class FPRecipePrototype: FPPrototype +---@field data_type "recipes" +---@field category string +---@field energy double +---@field emissions_multiplier double +---@field ingredients Ingredient[] +---@field products FormattedProduct[] +---@field main_product FormattedProduct? +---@field allowed_effects AllowedEffects? +---@field maximum_productivity double +---@field allowed_module_categories { [string]: boolean }? +---@field type_counts { ingredients: ItemTypeCounts, products: ItemTypeCounts } +---@field catalysts { ingredients: Ingredient[], products: FormattedProduct[] } +---@field surface_conditions SurfaceCondition[]? +---@field recycling boolean +---@field barreling boolean +---@field enabling_technologies string[] +---@field custom boolean +---@field enabled_from_the_start boolean +---@field hidden boolean +---@field order string +---@field group ItemGroup +---@field subgroup ItemGroup +---@field tooltip LocalisedString? + +-- Returns all standard recipes + custom mining, steam and rocket recipes +---@return NamedPrototypes +function generator.recipes.generate() + local recipes = {} ---@type NamedPrototypes + + ---@return FPRecipePrototype + local function custom_recipe() + local recipe = { + custom = true, + enabled_from_the_start = true, + hidden = false, + maximum_productivity = math.huge, + type_counts = {}, + catalysts = {products={}, ingredients={}}, + emissions_multiplier = 1 + } + generator_util.add_default_groups(recipe) + return recipe + end + + + -- Determine researchable recipes + local researchable_recipes = {} ---@type { [string]: string[] } + local tech_filter = {{filter="hidden", invert=true}, {filter="has-effects", mode="and"}} + for _, tech_proto in pairs(prototypes.get_technology_filtered(tech_filter)) do + for _, effect in pairs(tech_proto.effects) do + if effect.type == "unlock-recipe" then + local recipe_name = effect.recipe + researchable_recipes[recipe_name] = researchable_recipes[recipe_name] or {} + table.insert(researchable_recipes[recipe_name], tech_proto.name) + end + end + end + + -- Determine plant->seed map and item->launch_result map + local plant_seed_map, launch_products = {}, {} + for _, item_proto in pairs(prototypes.item) do + if item_proto.plant_result then + plant_seed_map[item_proto.plant_result.name] = item_proto.name + elseif #item_proto.rocket_launch_products > 0 then + launch_products[item_proto.name] = item_proto.rocket_launch_products + end + end + + -- Add all standard recipes + local recipe_filter = {{filter="energy", comparison=">", value=0}, + {filter="energy", comparison="<", value=1e+21, mode="and"}} + for recipe_name, proto in pairs(prototypes.get_recipe_filtered(recipe_filter)) do + local machine_category = storage.prototypes.machines[proto.category] ---@type { [string]: FPMachinePrototype } + -- Avoid any recipes that have no machine to produce them, or are irrelevant + if machine_category ~= nil and not generator_util.is_irrelevant_recipe(proto) and not proto.is_parameter then + local recipe = { + name = proto.name, + localised_name = proto.localised_name, + sprite = "recipe/" .. proto.name, + category = proto.category, + energy = proto.energy, + emissions_multiplier = proto.emissions_multiplier, + allowed_effects = proto.allowed_effects or {}, + maximum_productivity = proto.maximum_productivity, + allowed_module_categories = proto.allowed_module_categories, + type_counts = {}, -- filled out by format_recipe below + catalysts = {products={}, ingredients={}}, -- filled out by format_recipe below + surface_conditions = proto.surface_conditions, + recycling = generator_util.is_recycling_recipe(proto), + barreling = generator_util.is_compacting_recipe(proto), + enabling_technologies = researchable_recipes[recipe_name], -- can be nil + custom = false, + enabled_from_the_start = proto.enabled, + hidden = proto.hidden, + order = proto.order, + group = generator_util.generate_group_table(proto.group), + subgroup = generator_util.generate_group_table(proto.subgroup) + } + + generator_util.format_recipe(recipe, proto.products, proto.main_product, proto.ingredients) + insert_prototype(recipes, recipe, nil) + end + end + + for _, proto in pairs(prototypes.entity) do + if proto.type == "resource" and not proto.hidden then + local products = proto.mineable_properties.products + if not products then goto incompatible_proto end + + local recipe = custom_recipe() + recipe.name = "impostor-" .. proto.name + recipe.localised_name = {"", proto.localised_name, " ", {"fp.mining_recipe"}} + recipe.sprite = products[1].type .. "/" .. products[1].name + recipe.order = proto.order + recipe.category = proto.resource_category + + local ingredients = {{type="entity", name="custom-" .. proto.name, amount=1}} + + if not proto.infinite_resource then + -- Set energy to mining time so the forumla for the machine_count works out + recipe.energy = proto.mineable_properties.mining_time + + -- Add mining fluid, if required + if proto.mineable_properties.required_fluid then + table.insert(ingredients, { + type = "fluid", + name = proto.mineable_properties.required_fluid, + -- fluid_amount is given for a 'set' of mining ops, with a set being 10 ore + amount = proto.mineable_properties.fluid_amount / 10 + }) + end + else + recipe.energy = 0 + ingredients[1].amount = 1 + end + + generator_util.format_recipe(recipe, products, products[1], ingredients) + insert_prototype(recipes, recipe, nil) + + ::incompatible_proto:: + + -- Add offshore pump recipes based on fixed fluids + elseif proto.type == "offshore-pump" and not proto.hidden then + local fluid_box = proto.fluidbox_prototypes[1] + local fixed_fluid = (fluid_box and fluid_box.filter) and fluid_box.filter.name or nil + if fixed_fluid then + local fluid = prototypes.fluid[fixed_fluid] + + local recipe = custom_recipe() + recipe.name = "impostor-" .. fluid.name .. "-" .. proto.name + recipe.localised_name = {"", fluid.localised_name, " ", {"fp.pumping_recipe"}} + recipe.sprite = "fluid/" .. fluid.name + recipe.order = proto.order + recipe.category = "offshore-pump-" .. fluid.name + recipe.energy = 1 + + local products = {{type="fluid", name=fluid.name, amount=60, + temperature=fluid.default_temperature}} + generator_util.format_recipe(recipe, products, products[1], {}) + insert_prototype(recipes, recipe, nil) + end + + -- Add agricultural tower recipes + elseif proto.type == "plant" and not proto.hidden then + local products = proto.mineable_properties.products + if not products then goto incompatible_proto end + local seed_name = plant_seed_map[proto.name] + if not seed_name then goto incompatible_proto end + + local recipe = custom_recipe() + recipe.name = "impostor-" .. proto.name + recipe.localised_name = {"", proto.localised_name, " ", {"fp.planting_recipe"}} + recipe.sprite = products[1].type .. "/" .. products[1].name + recipe.order = proto.order + recipe.category = "agricultural-tower" + recipe.energy = 0 + + -- TODO Deal with proto.harvest_emissions + proto.emissions_per_second somehow, probably on machine? + + local ingredients = { + {type="item", name=seed_name, amount=1}, + {type="entity", name="custom-agriculture-square", amount=(proto.growth_ticks / 60)} + } + generator_util.format_recipe(recipe, products, products[1], ingredients) + + insert_prototype(recipes, recipe, nil) + + ::incompatible_proto:: + + elseif proto.type == "rocket-silo" and not proto.hidden then + local parts_recipe = prototypes.recipe[proto.fixed_recipe] + if not parts_recipe then goto incompatible_proto end + local rocket_parts_ingredient = {type="item", name=parts_recipe.main_product.name, + amount=proto.rocket_parts_required} + + -- Add rocket launch product recipes + if not proto.launch_to_space_platforms then + for item_name, products in pairs(launch_products) do + local main_product = prototypes.item[products[1].name] + + local launch_recipe = custom_recipe() + launch_recipe.name = "impostor-launch-" .. item_name .. "-from-" .. proto.name + launch_recipe.localised_name = {"", main_product.localised_name, " ", {"fp.launch"}} + launch_recipe.sprite = "item/" .. main_product.name + launch_recipe.order = main_product.order + launch_recipe.category = "launch-rocket" + launch_recipe.energy = 0 + + local ingredients = {ftable.deep_copy(rocket_parts_ingredient), + {type="item", name=item_name, amount=1}} + generator_util.format_recipe(launch_recipe, products, products[1], ingredients) + insert_prototype(recipes, launch_recipe, nil) + end + end + + -- Add convenience recipe to build whole rocket instead of parts + if SPACE_TRAVEL then + local rocket_recipe = custom_recipe() + + rocket_recipe.name = "impostor-" .. proto.name .. "-rocket" + rocket_recipe.localised_name = {"", proto.localised_name, " ", {"fp.launch"}} + rocket_recipe.sprite = "fp_silo_rocket" + rocket_recipe.order = parts_recipe.order .. "-" .. proto.order + rocket_recipe.category = "launch-rocket" + rocket_recipe.energy = 0 + + local rocket_products = {{type="entity", name="custom-silo-rocket", amount=1}} + local ingredients = {ftable.deep_copy(rocket_parts_ingredient)} + generator_util.format_recipe(rocket_recipe, rocket_products, rocket_products[1], ingredients) + insert_prototype(recipes, rocket_recipe, nil) + end + + ::incompatible_proto:: + end + + -- Add a recipe for producing steam from a boiler + --[[ local existing_recipe_names = {} ---@type { [string]: boolean } + for _, fluidbox in ipairs(proto.fluidbox_prototypes) do + if fluidbox.production_type == "output" and fluidbox.filter + and fluidbox.filter.name == "steam" and proto.target_temperature ~= nil then + -- Exclude any boilers that use heat or fluid as their energy source + if proto.burner_prototype or proto.electric_energy_source_prototype then + local temperature = proto.target_temperature + local recipe_name = "impostor-steam-" .. temperature + + -- Prevent duplicate recipes, in case more than one boiler produces the same temperature of steam + if existing_recipe_names[recipe_name] == nil then + existing_recipe_names[recipe_name] = true + + local recipe = custom_recipe() + recipe.name = recipe_name + recipe.localised_name = {"fp.fluid_at_temperature", {"fluid-name.steam"}, + temperature, {"fp.unit_celsius"}} + recipe.sprite = "fluid/steam" + recipe.category = "steam-" .. temperature + recipe.order = "z-" .. temperature + recipe.energy = 1 + + local ingredients = {{type="fluid", name="water", amount=60}} + local products = {{type="fluid", name="steam", amount=60, temperature=temperature, ignored_by_productivity=60}} + generator_util.format_recipe(recipe, products, products[1], ingredients) + + insert_prototype(recipes, recipe, nil) + end + end + end + end ]] + end + + -- Add offshore pump recipes based on fluid tiles + local pumped_fluids = {} + for _, proto in pairs(prototypes.tile) do + if proto.fluid and not pumped_fluids[proto.fluid.name] and not proto.hidden then + local fluid = proto.fluid ---@cast fluid -nil + pumped_fluids[fluid.name] = true + + local recipe = custom_recipe() + recipe.name = "impostor-" .. fluid.name .. "-" .. proto.name + recipe.localised_name = {"", fluid.localised_name, " ", {"fp.pumping_recipe"}} + recipe.sprite = "fluid/" .. fluid.name + recipe.order = proto.order + recipe.category = "offshore-pump" + recipe.energy = 1 + + local products = {{type="fluid", name=fluid.name, amount=60, + temperature=fluid.default_temperature}} + local ingredients = {{type="entity", name="custom-" .. proto.name, amount=60}} + generator_util.format_recipe(recipe, products, products[1], ingredients) + + insert_prototype(recipes, recipe, nil) + end + end + + -- Add purposeful spoiling recipes + for _, proto in pairs(prototypes.item) do + if proto.get_spoil_ticks() > 0 and proto.spoil_result then + local recipe = custom_recipe() + recipe.name = "impostor-spoiling-" .. proto.name + recipe.localised_name = {"", proto.spoil_result.localised_name, " ", {"fp.spoiling_recipe"}} + recipe.sprite = "item/" .. proto.spoil_result.name + recipe.order = proto.spoil_result.order + recipe.category = "purposeful-spoiling" + recipe.energy = 0 + + local products = {{type="item", name=proto.spoil_result.name, amount=1}} + local ingredients = {{type="item", name=proto.name, amount=1}} + generator_util.format_recipe(recipe, products, products[1], ingredients) + + insert_prototype(recipes, recipe, nil) + end + end + + -- Add a general steam recipe that works with every boiler + --[[ if prototypes.fluid["steam"] then -- make sure the steam prototype exists + local recipe = custom_recipe() + recipe.name = "fp-general-steam" + recipe.localised_name = {"fluid-name.steam"} + recipe.sprite = "fluid/steam" + recipe.category = "general-steam" + recipe.order = "z-0" + recipe.energy = 1 + + local ingredients = {{type="fluid", name="water", amount=60}} + local products = {{type="fluid", name="steam", amount=60, ignored_by_productivity=60}} + generator_util.format_recipe(recipe, products, products[1], ingredients) + + insert_prototype(recipes, recipe, nil) + end ]] + + return recipes +end + +---@param recipes NamedPrototypes +function generator.recipes.second_pass(recipes) + local machines = storage.prototypes.machines + for _, recipe in pairs(recipes) do + -- Check again if all recipes still have a machine to produce them after machine second pass + if not machines[recipe.category] then + remove_prototype(recipes, recipe.name, nil) + -- Give custom recipes a tooltip in a nice central place + elseif recipe.custom then + recipe.tooltip = generator_util.recipe_tooltip(recipe) + end + end +end + + +---@class FPItemPrototype: FPPrototypeWithCategory +---@field data_type "items" +---@field type "item" | "fluid" | "entity" +---@field hidden boolean +---@field stack_size uint? +---@field weight double? +---@field temperature float? +---@field base_name string? +---@field ingredient_only boolean +---@field order string +---@field group ItemGroup +---@field subgroup ItemGroup +---@field tooltip LocalisedString? +---@field fixed_unit LocalisedString? + +---@alias RelevantItems { [ItemType]: { [ItemName]: ItemDetails } } + +---@class ItemDetails +---@field ingredient_only boolean +---@field temperature float? + +-- Returns all relevant items and fluids +---@return NamedPrototypesWithCategory +function generator.items.generate() + local items = {} ---@type NamedPrototypesWithCategory + + -- Build custom items, representing in-world entities mostly + local custom_items, rocket_parts = {}, {} + + for _, proto in pairs(prototypes.entity) do + if proto.type == "resource" and not proto.hidden then + local item_name = "custom-" .. proto.name + custom_items[item_name] = { + name = item_name, + localised_name = {"", proto.localised_name, " ", {"fp.deposit"}}, + sprite = "entity/" .. proto.name, + hidden = true, + order = proto.order + } + generator_util.add_default_groups(custom_items[item_name]) + + -- Mark rocket silo part items here so they can be marked as non-hidden + elseif proto.type == "rocket-silo" and not proto.hidden then + local parts_recipe = prototypes.recipe[proto.fixed_recipe] + if parts_recipe then rocket_parts[parts_recipe.main_product.name] = true end + end + end + + local pumped_fluids = {} + for _, proto in pairs(prototypes.tile) do + if proto.fluid and not pumped_fluids[proto.fluid.name] and not proto.hidden then + pumped_fluids[proto.fluid.name] = true + + local item_name = "custom-" .. proto.name + custom_items[item_name] = { + name = item_name, + localised_name = {"", proto.localised_name, " ", {"fp.lake"}}, + sprite = "tile/" .. proto.name, + hidden = true, + order = proto.order + } + generator_util.add_default_groups(custom_items[item_name]) + end + end + + -- Only need one square item for all agricultural towers + custom_items["custom-agriculture-square"] = { + name = "custom-agriculture-square", + localised_name = {"fp.agriculture_square"}, + sprite = "fp_agriculture_square", + hidden = true, + order = "z", + fixed_unit = {"fp.agriculture_unit"} + } + generator_util.add_default_groups(custom_items["custom-agriculture-square"]) + + if SPACE_TRAVEL then + -- Only need one rocket item for all silos/recipes + local rocket_recipe = { + name = "custom-silo-rocket", + localised_name = {"", {"entity-name.rocket"}, " ", {"fp.launch"}}, + sprite = "fp_silo_rocket", + hidden = false, + order = "z" + } + local vanilla_parts_recipe = prototypes.recipe["rocket-part"] + if vanilla_parts_recipe then -- make it nicer for vanilla at least + rocket_recipe.group = generator_util.generate_group_table(vanilla_parts_recipe.group) + rocket_recipe.subgroup = generator_util.generate_group_table(vanilla_parts_recipe.subgroup) + else + generator_util.add_default_groups(rocket_recipe) + end + custom_items["custom-silo-rocket"] = rocket_recipe + end + + + local relevant_items = {item={}, fluid={}, entity={}} + -- Extract items from recipes and note whether they are ever used as a product + for _, recipe_proto in pairs(storage.prototypes.recipes) do + for _, item_category in pairs({"products", "ingredients"}) do + for _, item_data in pairs(recipe_proto[item_category]) do + local type_data = relevant_items[item_data.type] + if type_data[item_data.name] == nil then + type_data[item_data.name] = { + ingredient_only = true, + temperature = item_data.temperature, + base_name = item_data.base_name + } + end + if item_category == "products" then + type_data[item_data.name].ingredient_only = false + end + end + end + end + + for type, item_table in pairs(relevant_items) do + for item_name, item_details in pairs(item_table) do + local proto_name = item_details.base_name or item_name + local proto = (type == "entity") and custom_items[proto_name] or + prototypes[type][proto_name] ---@type LuaItemPrototype | LuaFluidPrototype + + local item = { + name = item_name, + localised_name = proto.localised_name, + sprite = (type .. "/" .. proto.name), + type = type, + hidden = (not rocket_parts[item_name]) and proto.hidden, + stack_size = (type == "item") and proto.stack_size or nil, + weight = (type == "item") and proto.weight or nil, + temperature = item_details.temperature, + base_name = item_details.base_name, + ingredient_only = item_details.ingredient_only, + order = proto.order, + group = generator_util.generate_group_table(proto.group), + subgroup = generator_util.generate_group_table(proto.subgroup) + } + + if type == "entity" then + item.sprite = proto.sprite + item.group = proto.group + item.subgroup = proto.subgroup + item.tooltip = proto.localised_name + item.fixed_unit = proto.fixed_unit or nil + elseif type == "fluid" and item.temperature then + item.localised_name = {"fp.fluid_with_temperature", proto.localised_name, item.temperature} + item.tooltip = item.localised_name + end + + insert_prototype(items, item, item.type) + end + end + + return items +end + + +---@class FPFuelPrototype: FPPrototypeWithCategory +---@field data_type "fuels" +---@field type "item" | "fluid" +---@field category string | "fluid-fuel" +---@field combined_category string +---@field elem_type ElemType +---@field fuel_value float +---@field stack_size uint? +---@field weight double? +---@field emissions_multiplier double +---@field burnt_result string? + +-- Generates a table containing all fuels that can be used in a burner +---@return NamedPrototypesWithCategory +function generator.fuels.generate() + local fuels = {} ---@type NamedPrototypesWithCategory + + local fuel_filter = {{filter="fuel-value", comparison=">", value=0}, + {filter="fuel-value", comparison="<", value=1e+21, mode="and"}, + {filter="hidden", invert=true, mode="and"}} + + -- Build solid fuels - to be combined into categories afterwards + local item_list = storage.prototypes.items["item"].members ---@type NamedPrototypesWithCategory + local fuel_categories = {} -- temporary list to be combined later + for _, proto in pairs(prototypes.get_item_filtered(fuel_filter)) do + -- Only use fuels that were actually detected/accepted to be items + if item_list[proto.name] then + local fuel = { + name = proto.name, + localised_name = proto.localised_name, + sprite = "item/" .. proto.name, + type = "item", + elem_type = "item", + category = proto.fuel_category, + combined_category = nil, -- set below + fuel_value = proto.fuel_value, + minimum_temperature = nil, -- fluid-only + maximum_temperature = nil, -- fluid-only + stack_size = proto.stack_size, + weight = proto.weight, + emissions_multiplier = proto.fuel_emissions_multiplier, + burnt_result = (proto.burnt_result) and proto.burnt_result.name or nil + } + fuel_categories[fuel.category] = fuel_categories[fuel.category] or {} + table.insert(fuel_categories[fuel.category], fuel) + end + end + + -- Add liquid fuels - they are a category of their own always + local fluid_list = storage.prototypes.items["fluid"].members ---@type NamedPrototypesWithCategory + for _, proto in pairs(prototypes.get_fluid_filtered(fuel_filter)) do + -- Only use fuels that have actually been detected/accepted as fluids + if fluid_list[proto.name] then + local fuel = { + name = proto.name, + localised_name = proto.localised_name, + sprite = "fluid/" .. proto.name, + type = "fluid", + elem_type = "fluid", + category = "fluid-fuel", + combined_category = nil, -- set below + fuel_value = proto.fuel_value, + minimum_temperature = nil, -- unbounded for now + maximum_temperature = nil, -- unbounded for now + stack_size = nil, -- item-only + weight = nil, -- item-only + emissions_multiplier = proto.emissions_multiplier, + burnt_result = nil -- item-only + } + fuel_categories[fuel.category] = fuel_categories[fuel.category] or {} + table.insert(fuel_categories[fuel.category], fuel) + end + end + + -- This manipulates the machine burner, which is improper but necessary + local function add_category(burner) + local combined_category = "" + + -- Determine combined category for the burner + for fuel_category, _ in pairs(burner.categories) do + if fuel_categories[fuel_category] then + combined_category = combined_category .. fuel_category + else + burner.categories[fuel_category] = nil + end + end + burner.combined_category = combined_category + + -- Add all relevant fuels to the combined category + for fuel_category, _ in pairs(burner.categories) do + for _, fuel in pairs(fuel_categories[fuel_category]) do + local fuel_copy = ftable.deep_copy(fuel) + fuel_copy.combined_category = combined_category + insert_prototype(fuels, fuel_copy, combined_category) + end + end + end + + -- Create category for each combination of fuels used by machines + -- Also implicitly filters out fuels that aren't used by any actual machine + for _, machine_category in pairs(storage.prototypes.machines) do + for _, machine_proto in pairs(machine_category.members) do + if machine_proto.burner then add_category(machine_proto.burner) end + end + end + + return fuels +end + +---@param a FPFuelPrototype +---@param b FPFuelPrototype +---@return boolean +function generator.fuels.sorting_function(a, b) + if a.fuel_value < b.fuel_value then return true + elseif a.fuel_value > b.fuel_value then return false end + return false +end + + +---@class FPBeltPrototype: FPPrototype +---@field data_type "belts" +---@field elem_type ElemType +---@field rich_text string +---@field throughput double + +-- Generates a table containing all available transport belts +---@return NamedPrototypes +function generator.belts.generate() + local belts = {} ---@type NamedPrototypes + + local belt_filter = {{filter="type", type="transport-belt"}, + {filter="hidden", invert=true, mode="and"}} + for _, proto in pairs(prototypes.get_entity_filtered(belt_filter)) do + local sprite = generator_util.determine_entity_sprite(proto) + if sprite ~= nil then + local belt = { + name = proto.name, + localised_name = proto.localised_name, + sprite = sprite, + elem_type = "entity", + rich_text = "[entity=" .. proto.name .. "]", + throughput = proto.belt_speed * 480 + } + insert_prototype(belts, belt, nil) + end + end + + return belts +end + +---@param a FPBeltPrototype +---@param b FPBeltPrototype +---@return boolean +function generator.belts.sorting_function(a, b) + if a.throughput < b.throughput then return true + elseif a.throughput > b.throughput then return false end + return false +end + + +---@class FPPumpPrototype: FPPrototype +---@field data_type "pumps" +---@field elem_type ElemType +---@field rich_text string +---@field pumping_speed double + +-- Generates a table containing all available standard pumps +---@return NamedPrototypes +function generator.pumps.generate() + local pumps = {} ---@type NamedPrototypes + + local pump_filter = {{filter="type", type="pump"}, + {filter="hidden", invert=true, mode="and"}} + for _, proto in pairs(prototypes.get_entity_filtered(pump_filter)) do + local sprite = generator_util.determine_entity_sprite(proto) + if sprite ~= nil then + local pump = { + name = proto.name, + localised_name = proto.localised_name, + sprite = sprite, + elem_type = "entity", + rich_text = "[entity=" .. proto.name .. "]", + pumping_speed = proto.get_pumping_speed() * 60 + } + insert_prototype(pumps, pump, nil) + end + end + + return pumps +end + +---@param a FPPumpPrototype +---@param b FPPumpPrototype +---@return boolean +function generator.pumps.sorting_function(a, b) + if a.pumping_speed < b.pumping_speed then return true + elseif a.pumping_speed > b.pumping_speed then return false end + return false +end + + +---@class FPWagonPrototype: FPPrototypeWithCategory +---@field data_type "wagons" +---@field category "cargo-wagon" | "fluid-wagon" +---@field elem_type ElemType +---@field rich_text string +---@field storage number + +-- Generates a table containing all available cargo and fluid wagons +---@return NamedPrototypesWithCategory +function generator.wagons.generate() + local wagons = {} ---@type NamedPrototypesWithCategory + + -- Add cargo wagons + local cargo_wagon_filter = {{filter="type", type="cargo-wagon"}, + {filter="hidden", invert=true, mode="and"}} + for _, proto in pairs(prototypes.get_entity_filtered(cargo_wagon_filter)) do + local inventory_size = proto.get_inventory_size(defines.inventory.cargo_wagon) + if inventory_size > 0 then + local wagon = { + name = proto.name, + localised_name = proto.localised_name, + sprite = generator_util.determine_entity_sprite(proto), + category = "cargo-wagon", + elem_type = "entity", + rich_text = "[entity=" .. proto.name .. "]", + storage = inventory_size + } + insert_prototype(wagons, wagon, wagon.category) + end + end + + -- Add fluid wagons + local fluid_wagon_filter = {{filter="type", type="fluid-wagon"}, + {filter="hidden", invert=true, mode="and"}} + for _, proto in pairs(prototypes.get_entity_filtered(fluid_wagon_filter)) do + if proto.fluid_capacity > 0 then + local wagon = { + name = proto.name, + localised_name = proto.localised_name, + sprite = generator_util.determine_entity_sprite(proto), + category = "fluid-wagon", + elem_type = "entity", + rich_text = "[entity=" .. proto.name .. "]", + storage = proto.fluid_capacity + } + insert_prototype(wagons, wagon, wagon.category) + end + end + + return wagons +end + +---@param a FPWagonPrototype +---@param b FPWagonPrototype +---@return boolean +function generator.wagons.sorting_function(a, b) + if a.storage < b.storage then return true + elseif a.storage > b.storage then return false end + return false +end + + +---@class FPModulePrototype: FPPrototypeWithCategory +---@field data_type "modules" +---@field category string +---@field tier uint +---@field effects ModuleEffects + +-- Generates a table containing all available modules +---@return NamedPrototypesWithCategory +function generator.modules.generate() + local modules = {} ---@type NamedPrototypesWithCategory + + local module_filter = {{filter="type", type="module"}, {filter="hidden", invert=true, mode="and"}} + for _, proto in pairs(prototypes.get_item_filtered(module_filter)) do + local sprite = "item/" .. proto.name + local items = storage.prototypes.items["item"].members + if helpers.is_valid_sprite_path(sprite) and items[proto.name] then + local module = { + name = proto.name, + localised_name = proto.localised_name, + sprite = sprite, + category = proto.category, + tier = proto.tier, + effects = generator_util.formatted_effects(proto.module_effects) + } + insert_prototype(modules, module, module.category) + end + end + + return modules +end + +---@param a FPModulePrototype +---@param b FPModulePrototype +---@return boolean +function generator.modules.sorting_function(a, b) + -- Sorting done so IDs can be used for order comparison + if a.category < b.category then return true + elseif a.category > b.category then return false + elseif a.tier < b.tier then return true + elseif a.tier > b.tier then return false end + return false +end + + +---@class FPBeaconPrototype: FPPrototype +---@field data_type "beacons" +---@field category "beacon" +---@field elem_type ElemType +---@field prototype_category "beacon" +---@field built_by_item FPItemPrototype +---@field allowed_effects AllowedEffects +---@field allowed_module_categories { [string]: boolean }? +---@field module_limit uint +---@field effectivity double +---@field quality_bonus double +---@field profile double[] +---@field energy_usage double + +-- Generates a table containing all available beacons +---@return NamedPrototypes +function generator.beacons.generate() + local beacons = {} ---@type NamedPrototypes + + ---@type NamedPrototypesWithCategory + local item_prototypes = storage.prototypes.items["item"].members + + local beacon_filter = {{filter="type", type="beacon"}, {filter="hidden", invert=true, mode="and"}} + for _, proto in pairs(prototypes.get_entity_filtered(beacon_filter)) do + local sprite = generator_util.determine_entity_sprite(proto) + if sprite ~= nil and proto.module_inventory_size > 0 and proto.distribution_effectivity > 0 then + -- Beacons can refer to the actual item prototype right away because they are built after items are + local items_to_place_this = proto.items_to_place_this + local built_by_item = (items_to_place_this) and item_prototypes[items_to_place_this[1].name] or nil + + local beacon = { + name = proto.name, + localised_name = proto.localised_name, + sprite = sprite, + category = "beacon", -- custom category to be similar to machines + elem_type = "entity", + prototype_category = "beacon", + built_by_item = built_by_item, + allowed_effects = proto.allowed_effects, + allowed_module_categories = proto.allowed_module_categories, + module_limit = proto.module_inventory_size, + effectivity = proto.distribution_effectivity, + quality_bonus = proto.distribution_effectivity_bonus_per_quality_level, + profile = (#proto.profile == 0) and {1} or proto.profile, + energy_usage = proto.energy_usage or proto.get_max_energy_usage() or 0 + } + insert_prototype(beacons, beacon, nil) + end + end + + return beacons +end + +---@param a FPBeaconPrototype +---@param b FPBeaconPrototype +---@return boolean +function generator.beacons.sorting_function(a, b) + if a.module_limit < b.module_limit then return true + elseif a.module_limit > b.module_limit then return false + elseif a.effectivity < b.effectivity then return true + elseif a.effectivity > b.effectivity then return false end + return false +end + + +-- Doesn't need to be a lasting part of the generator as it's only used for LocationPrototypes generation +---@return LuaSurfacePropertyPrototype[] +local function generate_surface_properties() + local properties = {} + + ---@param a LuaSurfacePropertyPrototype + ---@param b LuaSurfacePropertyPrototype + ---@return boolean + local function property_sorting_function(a, b) + if a.order < b.order then return true + elseif a.order > b.order then return false end + return false + end + + for _, proto in pairs(prototypes.surface_property) do + if not proto.hidden then + table.insert(properties, { + name = proto.name, + order = proto.order, + localised_name = proto.localised_name, + localised_unit_key = proto.localised_unit_key, + default_value = proto.default_value, + is_time = proto.is_time + }) + end + end + + table.sort(properties, property_sorting_function) + return properties +end + +---@class FPLocationPrototype: FPPrototype +---@field data_type "locations" +---@field tooltip LocalisedString +---@field surface_properties SurfaceProperties? +---@field pollutant_type string? + +---@alias SurfaceProperties { string: double } + +-- Generates a table containing all 'places' with surface_conditions, like planets and platforms +---@return NamedPrototypes +function generator.locations.generate() + local locations = {} ---@type NamedPrototypes + + local property_prototypes = generate_surface_properties() + + ---@param proto LuaSpaceLocationPrototype | LuaSurfacePrototype + ---@param category string + ---@return FPLocationPrototype? location_proto + local function build_location(proto, category) + if proto.hidden or not proto.surface_properties then return nil end + + local sprite = category .. "/" .. proto.name + if not helpers.is_valid_sprite_path(sprite) then return nil end + + local surface_properties = {} + local tooltip = {"", {"fp.tt_title", proto.localised_name}, "\n"} + local current_table, next_index = tooltip, 4 + + for _, property_proto in pairs(property_prototypes) do + local value = proto.surface_properties[property_proto.name] or property_proto.default_value + surface_properties[property_proto.name] = value + + local value_and_unit = {property_proto.localised_unit_key, value} ---@type LocalisedString + if property_proto.is_time then value_and_unit = util.format.time(value) end + + current_table, next_index = util.build_localised_string( + {"fp.surface_property", property_proto.localised_name, value_and_unit}, current_table, next_index) + end + + return { + name = proto.name, + localised_name = proto.localised_name, + sprite = sprite, + tooltip = tooltip, + surface_properties = surface_properties, + pollutant_type = (category == "space-location" and proto.pollutant_type) + and proto.pollutant_type.name or nil + } + end + + for _, proto in pairs(prototypes.space_location) do + local location = build_location(proto, "space-location") + if location then insert_prototype(locations, location, nil) end + end + + for _, proto in pairs(prototypes.surface) do + local location = build_location(proto, "surface") + if location then insert_prototype(locations, location, nil) end + end + + -- Add special location that has no restrictions + if table_size(locations) > 1 then + insert_prototype(locations, { + name = "universal", + localised_name = {"fp.universal_location"}, + sprite = "fp_universal_planet", + tooltip = {"fp.universal_location_tt"}, + surface_properties = nil, -- accepts all machines and recipes + pollutant_type = nil -- no pollution produced + }) + end + + return locations +end + +---@class FPQualityPrototype: FPPrototype +---@field data_type "qualities" +---@field rich_text LocalisedString +---@field level uint +---@field always_show boolean +---@field multiplier double +---@field beacon_power_usage_multiplier double +---@field mining_drill_resource_drain_multiplier double + +---@return NamedPrototypes +function generator.qualities.generate() + local qualities = {} ---@type NamedPrototypes + + for _, proto in pairs(prototypes.quality) do + if proto.name ~= "quality-unknown" then -- Shouldn't this be hidden by the game? + local sprite = "quality/" .. proto.name + if helpers.is_valid_sprite_path(sprite) then + local quality = { + name = proto.name, + localised_name = proto.localised_name, + sprite = sprite, + rich_text = {"", "[quality=" .. proto.name .. "] ", + generator_util.colored_rich_text(proto.localised_name, proto.color)}, + level = proto.level, + always_show = proto.draw_sprite_by_default, + multiplier = 1 + (proto.level * 0.3), + beacon_power_usage_multiplier = proto.beacon_power_usage_multiplier, + mining_drill_resource_drain_multiplier = proto.mining_drill_resource_drain_multiplier + } + insert_prototype(qualities, quality, nil) + end + end + end + + return qualities +end + +---@param a FPQualityPrototype +---@param b FPQualityPrototype +---@return boolean +function generator.qualities.sorting_function(a, b) + if a.level < b.level then return true + elseif a.level > b.level then return false end + return false +end + + +return generator diff --git a/modfiles/backend/handlers/generator_util.lua b/modfiles/backend/handlers/generator_util.lua new file mode 100644 index 000000000..393d108c9 --- /dev/null +++ b/modfiles/backend/handlers/generator_util.lua @@ -0,0 +1,481 @@ +local generator_util = {} + +-- ** LOCAL UTIL ** +---@alias RecipeItem FormattedProduct | Ingredient +---@alias IndexedItemList { [ItemType]: { [ItemName]: { index: number, item: RecipeItem } } } +---@alias ItemList { [ItemType]: { [ItemName]: RecipeItem } } +---@alias ItemTypeCounts { items: number, fluids: number } + +---@class FormattedProduct +---@field name string +---@field type string +---@field amount number +---@field temperature float? +---@field base_name string? +---@field proddable_amount number? + +---@param product Product +---@return FormattedProduct +local function generate_formatted_product(product) + local base_amount, proddable_amount = 0, 0 + local catalyst_amount = product.ignored_by_productivity or 0 + + if product.amount_min ~= nil and product.amount_max ~= nil then + local min, max = product.amount_min, product.amount_max + base_amount = (min + max) / 2 + (product.extra_count_fraction or 0) + + -- When a recipe has a random output affected by prod and with a catalyst specified, + -- prod only applies when the output rolls above the catalyst amount + local cat_min = math.max(min - catalyst_amount, 0) + local cat_max = math.max(max - catalyst_amount, 0) + proddable_amount = (cat_min + cat_max) / 2 + + -- If the catalyst is at least the minimum amount, the prod part must be multiplied by + -- the probability that it rolls at least the catalyst amount + if cat_min == 0 then proddable_amount = proddable_amount * (cat_max + 1) / (max - min + 1) end + else + base_amount = product.amount + (product.extra_count_fraction or 0) + proddable_amount = math.max(base_amount - catalyst_amount, 0) + end + + local probability = (product.probability or 1) + local formatted_product = { + name = product.name, + type = product.type, + amount = base_amount * probability, + proddable_amount = proddable_amount * probability + } + + if product.type == "fluid" then + local fluid = prototypes.fluid[product.name] + formatted_product.temperature = product.temperature or fluid.default_temperature + formatted_product.name = product.name .. "-" .. formatted_product.temperature + formatted_product.base_name = product.name + end + + return formatted_product +end + +-- Combines items that occur more than once into one entry +---@param item_list RecipeItem[] +local function combine_identical_products(item_list) + local touched_items = {item = {}, fluid = {}, entity = {}} ---@type ItemList + + for index=#item_list, 1, -1 do + local item = item_list[index] + local touched_item = touched_items[item.type][item.name] + if touched_item ~= nil then + touched_item.amount = touched_item.amount + item.amount + if touched_item.proddable_amount then + touched_item.proddable_amount = touched_item.proddable_amount + item.proddable_amount + end + + -- Using the table.remove function to preserve array formatting + table.remove(item_list, index) + else + touched_items[item.type][item.name] = item + end + end +end + +---@param item_list RecipeItem[] +---@return IndexedItemList +local function create_type_indexed_list(item_list) + local indexed_list = {item = {}, fluid = {}, entity = {}} ---@type IndexedItemList + + for index, item in pairs(item_list) do + indexed_list[item.type][item.name] = {index = index, item = ftable.shallow_copy(item)} + end + + return indexed_list +end + +---@param indexed_items IndexedItemList +---@return ItemTypeCounts +local function determine_item_type_counts(indexed_items) + return { + items = table_size(indexed_items.item), + fluids = table_size(indexed_items.fluid) + } +end + + +-- ** TOP LEVEL ** +-- Formats the products/ingredients of a recipe for more convenient use +---@param recipe_proto FPRecipePrototype +---@param products Product[] +---@param main_product Product? +---@param ingredients Ingredient[] +function generator_util.format_recipe(recipe_proto, products, main_product, ingredients) + local temperature_limit = 3.4e+38 + + for _, base_ingredient in pairs(ingredients) do + if base_ingredient.type == "fluid" then + local min_temp = base_ingredient.minimum_temperature or base_ingredient.temperature + local max_temp = base_ingredient.maximum_temperature or base_ingredient.temperature + base_ingredient.temperature = nil -- remove as to not confuse it with a product + + -- Adjust temperature ranges for easy handling - nil means unlimited + min_temp = (min_temp and min_temp > -temperature_limit) and min_temp or nil + max_temp = (max_temp and max_temp < temperature_limit) and max_temp or nil + + base_ingredient.minimum_temperature = min_temp + base_ingredient.maximum_temperature = max_temp + end + end + + local indexed_ingredients = create_type_indexed_list(ingredients) + recipe_proto.type_counts.ingredients = determine_item_type_counts(indexed_ingredients) + + + local formatted_products = {} ---@type FormattedProduct[] + for _, base_product in pairs(products) do + if base_product.type ~= "research-progress" then + local formatted_product = generate_formatted_product(base_product) + + if formatted_product.amount > 0 then + table.insert(formatted_products, formatted_product) + + -- Update the main product as well, if present + if main_product ~= nil + and formatted_product.type == main_product.type + and formatted_product.name == main_product.name then + recipe_proto.main_product = formatted_product + end + end + end + end + + combine_identical_products(formatted_products) + local indexed_products = create_type_indexed_list(formatted_products) + recipe_proto.type_counts.products = determine_item_type_counts(indexed_products) + + + -- Reduce item amounts for items that are both an ingredient and a product + for _, items_of_type in pairs(indexed_ingredients) do + for _, ingredient in pairs(items_of_type) do + local peer_product = indexed_products[ingredient.item.type][ingredient.item.name] + + if peer_product then + local difference = ingredient.item.amount - peer_product.item.amount + + if difference < 0 then + local item = ftable.shallow_copy(ingredient.item) + item.amount = peer_product.item.amount + difference + recipe_proto.catalysts.ingredients[item.name] = item + + ingredients[ingredient.index].amount = nil + formatted_products[peer_product.index].amount = -difference + elseif difference > 0 then + local item = ftable.shallow_copy(peer_product.item) + item.amount = ingredient.item.amount - difference + recipe_proto.catalysts.products[item.name] = item + + ingredients[ingredient.index].amount = difference + formatted_products[peer_product.index].amount = nil + else + -- Nilled-out items are just shown as ingredient catalysts + local item = ftable.shallow_copy(ingredient.item) + recipe_proto.catalysts.ingredients[item.name] = item + + ingredients[ingredient.index].amount = nil + formatted_products[peer_product.index].amount = nil + end + end + end + end + + -- Remove items after the fact so the iteration above doesn't break + for _, item_table in pairs{ingredients, formatted_products} do + for i = #item_table, 1, -1 do + if item_table[i].amount == nil then table.remove(item_table, i) end + end + end + + recipe_proto.ingredients = ingredients + recipe_proto.products = formatted_products +end + + +-- Active mods table needed for the funtions below +local active_mods = script.active_mods + +-- Determines whether this recipe is a recycling one or not +local recycling_recipe_mods = { + ["base"] = {".*%-recycling$"}, + --[[ ["IndustrialRevolution"] = {"^scrap%-.*"}, + ["space-exploration"] = {"^se%-recycle%-.*"}, + ["angelspetrochem"] = {"^converter%-.*"}, + ["reverse-factory"] = {"^rf%-.*"}, + ["ZRecycling"] = {"^dry411srev%-.*"} ]] +} + +local active_recycling_recipe_mods = {} ---@type string[] +for modname, patterns in pairs(recycling_recipe_mods) do + for _, pattern in pairs(patterns) do + if active_mods[modname] then + table.insert(active_recycling_recipe_mods, pattern) + end + end +end + +---@param proto LuaRecipePrototype +---@return boolean +function generator_util.is_recycling_recipe(proto) + for _, pattern in pairs(active_recycling_recipe_mods) do + if string.match(proto.name, pattern) and proto.hidden then return true end + end + return false +end + + +-- Determines whether the given recipe is a barreling or stacking one +local compacting_recipe_mods = { + ["base"] = {patterns = {"^fill%-.*", "^empty%-.*"}, item = "barrel"}, + ["pycoalprocessing"] = {patterns = {"^fill%-.*%-canister$", "^empty%-.*%-canister$"}} + --[[ ["deadlock-beltboxes-loaders"] = {"^deadlock%-stacks%-.*", "^deadlock%-packrecipe%-.*", + "^deadlock%-unpackrecipe%-.*"}, + ["DeadlockCrating"] = {"^deadlock%-packrecipe%-.*", "^deadlock%-unpackrecipe%-.*"}, + ["IntermodalContainers"] = {"^ic%-load%-.*", "^ic%-unload%-.*"}, + ["space-exploration"] = {"^se%-delivery%-cannon%-pack%-.*"}, + ["Satisfactorio"] = {"^packaged%-.*", "^unpack%-.*"} ]] +} + +---@param proto LuaRecipePrototype +---@return boolean +function generator_util.is_compacting_recipe(proto) + for mod, filter_data in pairs(compacting_recipe_mods) do + if active_mods[mod] then + for _, pattern in pairs(filter_data.patterns) do + if string.match(proto.name, pattern) then + if not filter_data.item then + return true + else + for _, product in pairs(proto.products) do + if product.name == filter_data.item then return true end + end + for _, ingredient in pairs(proto.ingredients) do + if ingredient.name == filter_data.item then return true end + end + end + end + end + end + end + return false +end + + +-- Determines whether this recipe is irrelevant or not and should thus be excluded +local irrelevant_recipe_categories = { + ["Transport_Drones_Meglinge_Fork"] = {"transport-drone-request", "transport-fluid-request"}, + --[[ ["Mining_Drones"] = {"mining-depot"}, + ["Deep_Storage_Unit"] = {"deep-storage-item", "deep-storage-fluid", + "deep-storage-item-big", "deep-storage-fluid-big", + "deep-storage-item-mk2/3", "deep-storage-fluid-mk2/3"}, + ["Satisfactorio"] = {"craft-bench", "equipment", "awesome-shop", + "resource-scanner", "object-scanner", "building", + "hub-progressing", "space-elevator", "mam"} ]] +} + +local irrelevant_recipe_categories_lookup = {} ---@type { [string] : true } +for mod, categories in pairs(irrelevant_recipe_categories) do + for _, category in pairs(categories) do + if active_mods[mod] then + irrelevant_recipe_categories_lookup[category] = true + end + end +end + +---@param recipe LuaRecipePrototype +---@return boolean +function generator_util.is_irrelevant_recipe(recipe) + return irrelevant_recipe_categories_lookup[recipe.category] +end + + +-- Determines whether this machine is irrelevant or not and should thus be excluded +local irrelevant_machine_mods = { + --[[ ["GhostOnWater"] = {"waterGhost%-.*"} ]] +} + +local irrelevant_machines_lookup = {} ---@type string[] +for modname, patterns in pairs(irrelevant_machine_mods) do + for _, pattern in pairs(patterns) do + if active_mods[modname] then + table.insert(irrelevant_machines_lookup, pattern) + end + end +end + +---@param proto LuaEntityPrototype +---@return boolean +function generator_util.is_irrelevant_machine(proto) + for _, pattern in pairs(irrelevant_machines_lookup) do + if string.match(proto.name, pattern) then return true end + end + return false +end + + +-- Finds a sprite for the given entity prototype +---@param proto LuaEntityPrototype +---@return SpritePath | nil +function generator_util.determine_entity_sprite(proto) + local entity_sprite = "entity/" .. proto.name ---@type SpritePath + if helpers.is_valid_sprite_path(entity_sprite) then + return entity_sprite + end + + local items_to_place_this = proto.items_to_place_this + if items_to_place_this and next(items_to_place_this) then + local item_sprite = "item/" .. items_to_place_this[1].name ---@type SpritePath + if helpers.is_valid_sprite_path(item_sprite) then + return item_sprite + end + end + + return nil +end + +-- This is wrong now that silos can have two rockets in progress at once +--[[ +-- Determines how long a rocket takes to launch for the given rocket silo prototype +-- These stages mirror the in-game progression and timing exactly. Most steps take an additional tick (+1) +-- due to how the game code is written. If one stage is completed, you can only progress to the next one +-- in the next tick. No stages can be skipped, meaning a minimal sequence time is around 10 ticks long. +---@param silo_proto LuaEntityPrototype +---@return number? launch_sequence_time +function generator_util.determine_launch_sequence_time(silo_proto) + local rocket_proto = silo_proto.rocket_entity_prototype + if not rocket_proto then return nil end -- meaning this isn't a rocket silo proto + + local rocket_flight_threshold = 0.5 -- hardcoded in the game files + local launch_steps = { + lights_blinking_open = (1 / silo_proto.light_blinking_speed) + 1, + doors_opening = (1 / silo_proto.door_opening_speed) + 1, + doors_opened = silo_proto.rocket_rising_delay + 1, + rocket_rising = (1 / rocket_proto.rising_speed) + 1, + rocket_ready = 14, -- estimate for satellite insertion delay + launch_started = silo_proto.launch_wait_time + 1, + engine_starting = (1 / rocket_proto.engine_starting_speed) + 1, + -- This calculates a fractional amount of ticks. Also, math.log(x) calculates the natural logarithm + rocket_flying = math.log(1 + rocket_flight_threshold * rocket_proto.flying_acceleration + / rocket_proto.flying_speed) / math.log(1 + rocket_proto.flying_acceleration), + lights_blinking_close = (1 / silo_proto.light_blinking_speed) + 1, + doors_closing = (1 / silo_proto.door_opening_speed) + 1 + } + + local total_ticks = 0 + for _, ticks_taken in pairs(launch_steps) do + total_ticks = total_ticks + ticks_taken + end + + return (total_ticks / 60) -- retured value is in seconds +end ]] + + +---@param proto FPMachinePrototype +function generator_util.check_machine_effects(proto) + local any_positives = false + for _, effect in pairs(proto.allowed_effects) do + if effect == true then any_positives = true; break end + end + + if proto.module_limit == 0 or not any_positives then + proto.effect_receiver.uses_module_effects = false + end + if proto.module_limit == 0 then + proto.effect_receiver.uses_beacon_effects = false + end +end + +---@param effects ModuleEffects +---@return ModuleEffects +function generator_util.formatted_effects(effects) + effects = effects or {} + if effects["quality"] then -- fix base game weirdness + effects["quality"] = effects["quality"] / 10 + end + return effects +end + +--- Needs to be weird because ordering of non-integer keys depends on insertion order +---@param proto FPMachinePrototype +function generator_util.sort_machine_burner_categories(proto) + if not proto.burner then return end + + local category_list = {} + for category, _ in pairs(proto.burner.categories) do + table.insert(category_list, category) + end + table.sort(category_list) + + local category_index = {} + for _, category in ipairs(category_list) do + category_index[category] = true + end + proto.burner.categories = category_index +end + + +-- Adds the tooltip for the given recipe +---@param recipe FPRecipePrototype +---@return LocalisedString +function generator_util.recipe_tooltip(recipe) + local tooltip = {"", {"fp.recipe_title", recipe.sprite, recipe.localised_name}} ---@type LocalisedString + local current_table, next_index = tooltip, 3 + + if recipe.energy ~= nil then + local energy_line = {"fp.recipe_crafting_time", recipe.energy} + current_table, next_index = util.build_localised_string(energy_line, current_table, next_index) + end + + local item_protos = storage.prototypes.items + for _, item_type in ipairs{"ingredients", "products"} do + local locale_key = (item_type == "ingredients") and "fp.pu_ingredient" or "fp.pu_product" + local header_line = {"fp.recipe_header", {locale_key, 2}} + current_table, next_index = util.build_localised_string(header_line, current_table, next_index) + if not next(recipe[item_type]) then + current_table, next_index = util.build_localised_string({"fp.recipe_none"}, current_table, next_index) + else + local items = recipe[item_type] + for _, item in ipairs(items) do + local proto = item_protos[item.type].members[item.name] + local item_line = {"fp.recipe_item", proto.sprite, item.amount, proto.localised_name} + current_table, next_index = util.build_localised_string(item_line, current_table, next_index) + end + end + end + + return tooltip +end + +---@class ItemGroup +---@field name string +---@field localised_name LocalisedString +---@field order string +---@field valid boolean + +-- Generates a table imitating LuaGroup to avoid lua-cpp bridging +---@param group LuaGroup +---@return ItemGroup group_table +function generator_util.generate_group_table(group) + return {name=group.name, localised_name=group.localised_name, order=group.order, valid=true} +end + +---@param proto FPItemPrototype | FPRecipePrototype +function generator_util.add_default_groups(proto) + proto.group = generator_util.generate_group_table(prototypes.item_group["other"]) + proto.subgroup = generator_util.generate_group_table(prototypes.item_subgroup["other"]) +end + + +---@param text LocalisedString +---@param color Color +---@return LocalisedString +function generator_util.colored_rich_text(text, color) + return {"", "[color=", color.r, ",", color.g, ",", color.b, "]", text, "[/color]"} +end + +return generator_util diff --git a/modfiles/backend/handlers/loader.lua b/modfiles/backend/handlers/loader.lua new file mode 100644 index 000000000..503edb9bc --- /dev/null +++ b/modfiles/backend/handlers/loader.lua @@ -0,0 +1,263 @@ +-- The loader contains the code that runs on_load, pre-caching some data structures that are needed later +local loader = {} + +---@alias RecipeMap { [ItemCategoryID]: { [ItemID]: { [RecipeID]: true } } } +---@alias ItemCategoryID integer +---@alias ItemID integer +---@alias RecipeID integer + +---@alias TemperatureMap { [string]: FPItemPrototype[] } + +---@alias ModuleMap { [string]: FPModulePrototype } + +-- ** LOCAL UTIL ** +-- Returns a list of recipe groups in their proper order +---@return ItemGroup[] +local function ordered_recipe_groups() + -- Make a dict with all recipe groups + local group_dict = {} ---@type { [string]: ItemGroup } + for _, recipe in pairs(storage.prototypes.recipes) do + if group_dict[recipe.group.name] == nil then + group_dict[recipe.group.name] = recipe.group + end + end + + -- Invert it + local groups = {} ---@type ItemGroup[] + for _, group in pairs(group_dict) do + table.insert(groups, group) + end + + -- Sort it + ---@param a ItemGroup + ---@param b ItemGroup + ---@return boolean + local function sorting_function(a, b) + if a.order < b.order then return true + elseif a.order > b.order then return false end + return false + end + table.sort(groups, sorting_function) + + return groups +end + +-- Maps all items to the recipes that produce or consume them ([item_type][item_name] = {[recipe_id] = true} +---@param item_type "products" | "ingredients" +---@return RecipeMap +local function recipe_map_from(item_type) + local map = {} ---@type RecipeMap + + local function add(item_proto, recipe_id) + map[item_proto.category_id] = map[item_proto.category_id] or {} + map[item_proto.category_id][item_proto.id] = map[item_proto.category_id][item_proto.id] or {} + map[item_proto.category_id][item_proto.id][recipe_id] = true + end + + for _, recipe in pairs(storage.prototypes.recipes) do + for _, item in ipairs(recipe[item_type]) do + if item_type == "ingredients" and item.type == "fluid" then + local min_temp = item.minimum_temperature + local max_temp = item.maximum_temperature + for _, fluid_proto in pairs(TEMPERATURE_MAP[item.name] or {}) do + if (not min_temp or min_temp <= fluid_proto.temperature) and + (not max_temp or max_temp >= fluid_proto.temperature) then + add(fluid_proto, recipe.id) + end + end + else + local item_proto = prototyper.util.find("items", item.name, item.type) ---@cast item_proto -nil + add(item_proto, recipe.id) + end + end + end + + return map +end + + +-- Generates a list of all items, sorted for display in the picker +---@return FPItemPrototype[] +local function sorted_items() + local items = {} + + for _, type in pairs{"item", "fluid", "entity"} do + for _, item in pairs(prototyper.util.find("items", nil, type).members) do + table.insert(items, item) + end + end + + -- Sorts the objects according to their group, subgroup and order + ---@param a FPItemPrototype + ---@param b FPItemPrototype + ---@return boolean + local function sorting_function(a, b) + if a.group.order < b.group.order then return true + elseif a.group.order > b.group.order then return false + elseif a.subgroup.order < b.subgroup.order then return true + elseif a.subgroup.order > b.subgroup.order then return false + elseif a.order < b.order then return true + elseif a.order > b.order then return false end + return false + end + + table.sort(items, sorting_function) + return items +end + + +---@return TemperatureMap +local function temperature_map() + local map = {} ---@type TemperatureMap + + for name, fluid_proto in pairs(PROTOTYPE_MAPS.items.fluid.members) do + if fluid_proto.temperature ~= nil then + local base_name = fluid_proto.base_name + if not map[base_name] then map[base_name] = {} end + table.insert(map[base_name], fluid_proto) + end + end + + ---@param a FPItemPrototype + ---@param b FPItemPrototype + ---@return boolean + local function sorting_function(a, b) + if a.temperature < b.temperature then return true + elseif a.temperature > b.temperature then return false end + return false + end + + for _, list in pairs(map) do + table.sort(list, sorting_function) + end + + return map +end + + +---@alias MappedPrototypes { [string]: T } +---@alias MappedPrototypesWithCategory { [string]: { id: integer, name: string, members: { [string]: T } } } +---@alias MappedCategory { id: integer, name: string, members: { [string]: table } } + +---@class PrototypeMaps: { [DataType]: table } +---@field machines MappedPrototypesWithCategory +---@field recipes MappedPrototypes +---@field items MappedPrototypesWithCategory +---@field fuels MappedPrototypesWithCategory +---@field belts MappedPrototypes +---@field pumps MappedPrototypes +---@field wagons MappedPrototypesWithCategory +---@field modules MappedPrototypesWithCategory +---@field beacons MappedPrototypes +---@field locations MappedPrototypes +---@field qualities MappedPrototypes + +---@param data_types { [DataType]: boolean } +---@return PrototypeMaps +local function prototype_maps(data_types) + local maps = {} ---@type PrototypeMaps + + for data_type, has_categories in pairs(data_types) do + local map = {} + + if not has_categories then + ---@cast map MappedPrototypes + + ---@type IndexedPrototypes + local prototypes = storage.prototypes[data_type] + + for _, prototype in pairs(prototypes) do + map[prototype.name] = prototype + end + else + ---@cast map MappedPrototypesWithCategory + + ---@type IndexedPrototypesWithCategory + local prototypes = storage.prototypes[data_type] + + for _, category in pairs(prototypes) do + map[category.name] = { name=category.name, id=category.id, members={} } + for _, prototype in pairs(category.members) do + map[category.name].members[prototype.name] = prototype + end + end + end + + maps[data_type] = map + end + + return maps +end + + +-- Generates a table mapping modules to their prototype by name +---@return ModuleMap +local function module_name_map() + local map = {} ---@type ModuleMap + + for _, category in pairs(storage.prototypes.modules) do + for _, module in pairs(category.members) do + map[module.name] = module + end + end + + return map +end + + +---@return { [string]: boolean } +local function generate_productivity_recipes() + local productivity_recipes = {} + for _, technology in pairs(prototypes.technology) do + for _, effect in pairs(technology.effects or {}) do + if effect.type == "mining-drill-productivity-bonus" then + productivity_recipes["custom-mining"] = true + elseif effect.type == "change-recipe-productivity" then + if PROTOTYPE_MAPS.recipes[effect.recipe] then + productivity_recipes[effect.recipe] = true + end + end + end + end + return productivity_recipes +end + + +local function generate_object_index() + OBJECT_INDEX = {} ---@type { [integer]: Object} + for _, player_table in pairs(storage.players) do + if not player_table.realm then return end -- migration issue mitigation + player_table.realm:index() -- recursively indexes all objects + end +end + + +-- ** TOP LEVEL ** +---@param skip_check boolean Whether the mod version check is skipped +function loader.run(skip_check) + if not skip_check and script.active_mods["factoryplanner"] ~= storage.installed_mods["factoryplanner"] then + return -- if the mod version changed, the loader will be re-run after migration anyways + end + + util.nth_tick.register_all() + generate_object_index() + + PROTOTYPE_MAPS = prototype_maps(prototyper.data_types) + MODULE_NAME_MAP = module_name_map() + + SORTED_ITEMS = sorted_items() + TEMPERATURE_MAP = temperature_map() + + ORDERED_RECIPE_GROUPS = ordered_recipe_groups() + RECIPE_MAPS = { + produce = recipe_map_from("products"), + consume = recipe_map_from("ingredients") + } + + PRODUCTIVITY_RECIPES = generate_productivity_recipes() + + MULTIPLE_PLANETS = #storage.prototypes.locations > 1 + MULTIPLE_QUALITIES = #storage.prototypes.qualities > 1 +end + +return loader diff --git a/modfiles/backend/handlers/migrator.lua b/modfiles/backend/handlers/migrator.lua new file mode 100644 index 000000000..77615f205 --- /dev/null +++ b/modfiles/backend/handlers/migrator.lua @@ -0,0 +1,124 @@ +-- This code handles the general migration process of the mod's storage table +-- It decides whether and which migrations should be applied, in appropriate order + +local migrator = {} + +---@alias MigrationMasterList { [integer]: { version: VersionString, migration: Migration } } +---@alias Migration { global: function, player_table: function, packed_factory: function? } +---@alias MigrationObject PlayerTable | Factory | PackedFactory + +-- Returns a table containing all existing migrations in order +local migration_masterlist = { ---@type MigrationMasterList + [1] = {version="1.0.6", migration=require("backend.migrations.migration_1_0_6")}, + [2] = {version="1.1.5", migration=require("backend.migrations.migration_1_1_5")}, + [3] = {version="1.1.14", migration=require("backend.migrations.migration_1_1_14")}, + [4] = {version="1.1.27", migration=require("backend.migrations.migration_1_1_27")}, + [5] = {version="1.1.42", migration=require("backend.migrations.migration_1_1_42")}, + [6] = {version="1.1.59", migration=require("backend.migrations.migration_1_1_59")}, + [7] = {version="1.1.61", migration=require("backend.migrations.migration_1_1_61")}, + [8] = {version="1.1.65", migration=require("backend.migrations.migration_1_1_65")}, + [9] = {version="1.1.66", migration=require("backend.migrations.migration_1_1_66")}, + [10] = {version="1.1.67", migration=require("backend.migrations.migration_1_1_67")}, + [11] = {version="1.1.73", migration=require("backend.migrations.migration_1_1_73")}, + [12] = {version="1.2.1", migration=require("backend.migrations.migration_1_2_1")}, + [13] = {version="1.2.2", migration=require("backend.migrations.migration_1_2_2")}, + [14] = {version="1.2.4", migration=require("backend.migrations.migration_1_2_4")}, + [15] = {version="1.2.6", migration=require("backend.migrations.migration_1_2_6")}, + [16] = {version="1.2.8", migration=require("backend.migrations.migration_1_2_8")}, + [17] = {version="1.2.15", migration=require("backend.migrations.migration_1_2_15")}, + [18] = {version="2.0.2", migration=require("backend.migrations.migration_2_0_2")}, + [19] = {version="2.0.6", migration=require("backend.migrations.migration_2_0_6")}, + [20] = {version="2.0.8", migration=require("backend.migrations.migration_2_0_8")}, + [21] = {version="2.0.16", migration=require("backend.migrations.migration_2_0_16")}, + [22] = {version="2.0.21", migration=require("backend.migrations.migration_2_0_21")}, + [23] = {version="2.0.26", migration=require("backend.migrations.migration_2_0_26")}, +} + + +-- Compares two mod versions, returns true if v1 is an earlier version than v2 (v1 < v2) +-- Version numbers have to be of the same structure: same amount of numbers, separated by a '.' +---@param v1 VersionString +---@param v2 VersionString +---@return boolean +local function compare_versions(v1, v2) + local split_v1 = util.split_string(v1, ".") + local split_v2 = util.split_string(v2, ".") + + for i = 1, #split_v1 do + if split_v1[i] == split_v2[i] then + -- continue + elseif split_v1[i] < split_v2[i] then + return true + else + return false + end + end + return false -- return false if both versions are the same +end + +-- Applies given migrations to the object +---@param migrations Migration[] +---@param function_name string +---@param object MigrationObject? +---@param player LuaPlayer? +local function apply_migrations(migrations, function_name, object, player) + for _, migration in ipairs(migrations) do + local migration_function = migration[function_name] + + if migration_function ~= nil then + migration_function(object, player) ---@type string + end + end +end + + +-- Determines whether a migration needs to take place, and if so, returns the appropriate range of the +-- migration_masterlist. If the version changed, but no migrations apply, it returns an empty array. +---@param comparison_version VersionString? +---@return Migration[]? +function migrator.determine_migrations(comparison_version) + local previous_version = storage.installed_mods["factoryplanner"] + + -- 1.1.60 is the first version that can be properly migrated (doesn't apply to export strings) + if not comparison_version and not compare_versions("1.1.59", previous_version) then return nil end + comparison_version = comparison_version or previous_version + + local migrations = {} + local found = false + + for _, migration in ipairs(migration_masterlist) do + if compare_versions(comparison_version, migration.version) then found = true end + if found then table.insert(migrations, migration.migration) end + end + + return migrations +end + + +---@param migrations Migration[] +function migrator.migrate_global(migrations) + apply_migrations(migrations, "global", nil, nil) +end + +---@param migrations Migration[] +function migrator.migrate_player_tables(migrations) + for _, player in pairs(game.players) do + local player_table = util.globals.player_table(player) + apply_migrations(migrations, "player_table", player_table, player) + end +end + + +-- Applies any appropriate migrations to the given export_table's factories +---@param export_table ExportTable +function migrator.migrate_export_table(export_table) + local export_version = export_table.export_modset["factoryplanner"] + export_table.factories = export_table.factories or export_table.subfactories -- migration + local migrations = migrator.determine_migrations(export_version) ---@cast migrations -nil + + for _, packed_factory in pairs(export_table.factories) do + apply_migrations(migrations, "packed_factory", packed_factory, nil) + end +end + +return migrator diff --git a/modfiles/backend/handlers/prototyper.lua b/modfiles/backend/handlers/prototyper.lua new file mode 100644 index 000000000..95fe40d36 --- /dev/null +++ b/modfiles/backend/handlers/prototyper.lua @@ -0,0 +1,239 @@ +local generator = require("backend.handlers.generator") + +prototyper = { + util = {} +} + +-- The purpose of the prototyper is to recreate the storage tables containing all relevant data types. +-- It also handles some other things related to prototypes, such as updating preferred ones, etc. +-- Its purpose is to not lose any data, so if a dataset of a factory doesn't exist anymore +-- in the newly loaded storage tables, it saves the name in string-form instead and makes the +-- concerned factory-dataset invalid. This accomplishes that invalid data is only permanently +-- removed when the user tells the factory to repair itself, giving him a chance to re-add the +-- missing mods. It is also a better separation of responsibilities and avoids some redundant code. + +-- Load order is important here: machines->recipes->items->fuels +-- The boolean indicates whether this prototype has categories or not +---@type { [DataType]: boolean } +prototyper.data_types = {machines = true, recipes = false, items = true, fuels = true, + belts = false, pumps = false, wagons = true, modules = true, + beacons = false, locations = false, qualities = false} + +---@alias DataType "machines" | "recipes" | "items" | "fuels" | "belts" | "pump" | "wagons" | "modules" | "beacons" | "locations" | "qualities" + +---@alias NamedPrototypes { [string]: T } +---@alias NamedPrototypesWithCategory { [string]: { name: string, members: { [string]: T } } } } +---@alias NamedCategory { name: string, members: { [string]: table } } +---@alias AnyNamedPrototypes NamedPrototypes | NamedPrototypesWithCategory + +---@alias IndexedPrototypes { [integer]: T } +---@alias IndexedPrototypesWithCategory { [integer]: { id: integer, name: string, members: { [integer]: T } } } +---@alias IndexedCategory { id: integer, name: string, members: { [integer]: table } } +---@alias AnyIndexedPrototypes IndexedPrototypes | IndexedPrototypesWithCategory + +---@class PrototypeLists: { [DataType]: table } +---@field machines IndexedPrototypesWithCategory +---@field recipes IndexedPrototypes +---@field items IndexedPrototypesWithCategory +---@field fuels IndexedPrototypesWithCategory +---@field belts IndexedPrototypes +---@field pumps IndexedPrototypes +---@field wagons IndexedPrototypesWithCategory +---@field modules IndexedPrototypesWithCategory +---@field beacons IndexedPrototypes +---@field locations IndexedPrototypes +---@field qualities IndexedPrototypes + +---@alias SortingFunction fun(a: table, b: table): boolean + + +-- Converts given prototype list to use ids as keys, and sorts it if desired +---@param data_type DataType +---@param prototype_sorting_function SortingFunction? +---@return AnyIndexedPrototypes +local function convert_and_sort(data_type, prototype_sorting_function) + local final_list = {} + + ---@param list AnyNamedPrototypes[] + ---@param sorting_function SortingFunction? + ---@param category_id integer? + ---@return AnyIndexedPrototypes + local function apply(list, sorting_function, category_id) + local new_list = {} ---@type (IndexedPrototypes | IndexedCategory)[] + + for _, member in pairs(list) do table.insert(new_list, member) end + if sorting_function then table.sort(new_list, sorting_function) end + + for id, member in pairs(new_list) do + member.id = id + member.category_id = category_id -- can be nil + member.data_type = data_type + end + + return new_list + end + + ---@param a NamedCategory + ---@param b NamedCategory + ---@return boolean + local function category_sorting_function(a, b) + if a.name < b.name then return true + elseif a.name > b.name then return false end + return false + end + + if prototyper.data_types[data_type] == false then + final_list = apply(storage.prototypes[data_type], prototype_sorting_function, nil) + ---@cast final_list IndexedPrototypes + else + final_list = apply(storage.prototypes[data_type], category_sorting_function, nil) + ---@cast final_list IndexedPrototypesWithCategory + for id, category in pairs(final_list) do + category.members = apply(category.members, prototype_sorting_function, id) + end + end + + return final_list +end + + +function prototyper.build() + for data_type, _ in pairs(prototyper.data_types) do + ---@type AnyNamedPrototypes + storage.prototypes[data_type] = generator[data_type].generate() + end + + -- Second pass to do some things that can't be done in the first pass due to the strict sequencing + for data_type, _ in pairs(prototyper.data_types) do + local second_pass = generator[data_type].second_pass ---@type fun(prototypes: NamedPrototypes) + if second_pass ~= nil then second_pass(storage.prototypes[data_type]) end + end + + -- Finish up generation by converting lists to use ids as keys, and sort if desired + for data_type, _ in pairs(prototyper.data_types) do + local sorting_function = generator[data_type].sorting_function ---@type SortingFunction? + storage.prototypes[data_type] = convert_and_sort(data_type, sorting_function) ---@type AnyIndexedPrototypes + end +end + + +-- ** UTIL ** +---@param data_type DataType +---@param prototype (integer | string)? +---@param category (integer | string)? +---@return (AnyFPPrototype | NamedCategory)? +function prototyper.util.find(data_type, prototype, category) + local prototypes, prototype_map = storage.prototypes[data_type], PROTOTYPE_MAPS[data_type] + + if util.xor((category ~= nil), (prototype ~= nil)) then -- either category or prototype provided + local identifier = category or prototype + local relevant_map = (type(identifier) == "string") and prototype_map or prototypes + return relevant_map[identifier] -- can be nil + + else -- category and prototype provided + local category_map = (type(category) == "string") and prototype_map or prototypes + local category_table = category_map[category] ---@type MappedCategory + if category_table == nil then return nil end + + if type(prototype) == type(category) then + return category_table.members[prototype] -- can be nil + else -- If types don't match, we need to use the opposite map for the category + if type(prototype) == "string" then + return prototype_map[category_table.name].members[prototype] -- can be nil + else + return prototypes[category_table.id].members[prototype] -- can be nil + end + end + end +end + + +---@class FPPackedPrototype +---@field name string +---@field category string +---@field data_type DataType +---@field simplified boolean + +---@alias CategoryDesignation ("category" | "type" | "combined_category") + +-- Returns a new table that only contains the given prototypes' identifiers +---@param prototype AnyFPPrototype? +---@param category_designation CategoryDesignation? +---@return FPPackedPrototype? +function prototyper.util.simplify_prototype(prototype, category_designation) + if not prototype then return nil end + if prototype.simplified then return prototype end -- failsafe + return {name = prototype.name, category = prototype[category_designation], + data_type = prototype.data_type, simplified = true} +end + +---@param prototypes FPPrototype[] +---@param category_designation CategoryDesignation? +---@return FPPackedPrototype[]? +function prototyper.util.simplify_prototypes(prototypes, category_designation) + if not prototypes then return nil end + + local simplified_prototypes = {} + for index, proto in pairs(prototypes) do + simplified_prototypes[index] = prototyper.util.simplify_prototype(proto, category_designation) + end + return simplified_prototypes +end + + +---@alias AnyPrototype (AnyFPPrototype | FPPackedPrototype) + +-- Validates given object with prototype, which includes trying to find the correct +-- new reference for its prototype, if able. Returns valid-status at the end. +---@param prototype AnyPrototype +---@param category_designation CategoryDesignation? +---@return AnyPrototype +function prototyper.util.validate_prototype_object(prototype, category_designation) + local updated_proto = prototype + + if prototype.simplified then -- try to unsimplify, otherwise it stays that way + ---@cast prototype FPPackedPrototype + if not category_designation or prototype.category then -- avoid broken simplified prototypes (now fixed) + local new_proto = prototyper.util.find(prototype.data_type, prototype.name, prototype.category) + if new_proto then updated_proto = new_proto end + end + else + ---@cast prototype AnyFPPrototype + local category = prototype[category_designation] ---@type string + local new_proto = prototyper.util.find(prototype.data_type, prototype.name, category) + updated_proto = new_proto or prototyper.util.simplify_prototype(prototype, category_designation) + end + + return updated_proto +end + +---@param prototypes AnyPrototype[]? +---@param category_designation CategoryDesignation +---@return AnyPrototype[]? +---@return boolean valid +function prototyper.util.validate_prototype_objects(prototypes, category_designation) + if not prototypes then return nil, true end + + local validated_prototypes, valid = {}, true + for index, proto in pairs(prototypes) do + validated_prototypes[index] = prototyper.util.validate_prototype_object(proto, category_designation) + valid = (not validated_prototypes[index].simplified) and valid + end + return validated_prototypes, valid +end + + +-- Build the necessary RawDictionaries for translation +function prototyper.util.build_translation_dictionaries() + for _, item_category in ipairs(storage.prototypes.items) do + translator.new(item_category.name) + for _, proto in pairs(item_category.members) do + translator.add(item_category.name, proto.name, proto.localised_name) + end + end + + translator.new("recipe") + for _, proto in pairs(storage.prototypes.recipes) do + translator.add("recipe", proto.name, proto.localised_name) + end +end diff --git a/modfiles/backend/handlers/screenshotter.lua b/modfiles/backend/handlers/screenshotter.lua new file mode 100644 index 000000000..cdf28583f --- /dev/null +++ b/modfiles/backend/handlers/screenshotter.lua @@ -0,0 +1,199 @@ +---@diagnostic disable + +-- This file contains functionality to rig the interface for various setups +-- that make for good screenshots. It provides a remote interface that its +-- companion scenario calls to actually take the screenshots. + +-- This code is terrible and uses some functions completely inappropriately, +-- but it needs to do that to manipulate the interface because GUI events +-- can't be raised manually anymore. + +local mod_gui = require("mod-gui") + +local handler_requires = {"ui.base.compact_dialog", "ui.base.modal_dialog", "ui.main.title_bar", + "ui.dialogs.picker_dialog", "ui.dialogs.porter_dialog"} +local handlers = {} -- Need to require these here since it can't be done inside an event +for _, path in pairs(handler_requires) do handlers[path] = require(path) end + +local function return_dimensions(scene, frame) + local dimensions = {actual_size=frame.actual_size, location=frame.location} + -- We do this on teardown so the frame has time to adjust all its sizes + remote.call("screenshotter_output", "return_dimensions", scene, dimensions) +end + +local function open_modal(player, dialog, modal_data) + main_dialog.toggle(player) + util.globals.main_elements(player).main_frame.location = player.display_resolution -- hack city + util.raise.open_dialog(player, {dialog=dialog, modal_data=modal_data, skip_dimmer=true}) +end + +local function modal_teardown(player, scene) + return_dimensions(scene, util.globals.modal_elements(player).modal_frame) + util.raise.close_dialog(player, "cancel") +end + + +local function get_handler(path, index, event, name) + local gui_handlers = handlers[path][index].gui[event] + for _, handler_table in pairs(gui_handlers) do + if handler_table.name == name then return handler_table.handler end + end +end + +local function set_machine_default(player, proto_name, category_name) + local proto = prototyper.util.find("machines", proto_name, category_name) + defaults.set(player, "machines", {prototype=proto.name, quality="normal"}, proto.category_id) +end + + +local actions = { + player_setup = function(player) + local player_table = util.globals.player_table(player) + + -- Factories + local export_string = "eNrtWmlTGk0Q/iup/WrAmdk5dqzKBy9CMBqOiMGURe0uA67uxR4CWv73t4dDycELmIQchR8smenu6e55+hp5MIKo075TSepFobFn4CIucma8NtQwjpKsDbupyoy9B8OxUzUlsBAQdH3Pgc+oiEmR68+2m0XJKPbtMFTJs6jH10aaO5NdT6XG3ucHI7QDLeuzF/TeeJkKdr0wVUmmkqtX9SjKUhCXeYFKXdsHOo5eG2GUaV4Ddhw/V3HihUC29wDSq0nUyd2xjpFzo9zZuuvbqWY5jHwfVrV9sJpFcbvrR1Gi6d97oVqRz1d3yjf28PN+aSxljqExM3MEq09GNgLlg2Wjf8mm54uLJ4qCwELqeip0VSG23durV8dDO4jB0lWt/mwgddZv3kh00rvfP61nlo+HzaDcFajptlo8eVdHpXr/ZFiN89Fu89BUoi8Pq4KNbt+a9aPB6W1d3e87528lOwirTuukZWaoerJ7FF2/Ld2822kcVvYDUS4djM6Sd42oV/Uq5fsD1izv1O5rOzHF6CD1OlV1MBpUZat8LUpHlZ3+Rxm3hmrwqey0LPDrIPU/XKDz89HlWcm9rJ8PT6yb3olrssagVcnNPOf3fdmMeD5Cl4qjo9LOvtsXF6XBG7AW7Ivxze2xtq9WV7sjPCz135ea936zciyc2ik/bjQuDvuN/F4NA8L52zsxYHfm5en5IHnfov7gw30PtfrlXInBbqW+M0rywfta7ejopn5U60asMtxJz6ulVtit3bxr9Afmh9uzc8t29m/D3qBaa5YvysllBZXdXkgjfNBKjuWnZlgJT68vKubZSdTyW0ooJ7xonX/68Gm3l3thtWPfJQ2eVT/azZvd/YPgGF1+NK6+D1AIbIBDFunFKVAWwAO8kXoAD6/rqY6xlyW5ApyMYs2iYaURaAdRHsIB2ALQJKqfe4nqtGerD0ZHdeFGOm1nBEzT5a+4HIi89lSlru2nahorU9xPjp0hfWbR49XLwgysn7GN9yZpbw3yRLlePCb6ES+6dqZ6Olz3DDexu5kX9rTuzyLaUz9PVvTBMz3qEwW074HyTs1EdiKt+9iBIEglrgozuwdLGIGTA9u9nhr3td4gVgWODyoUplQFcx2luxGc1fa9wMuezoeClPuqPS1Kk0/pqjC887JRYcKzXJN5pnlA0mePnY5F/Q9kptq6T5wqiLNRO/V1kdtDX0tqgFVzyfd06tpHDWXb1QX6W9umO0uM6cbtKeGcJdYL3ZnGSnVW9uOYev5Y8mIHkjUdePBk8obz1VyWmeJ21TSjq+zKiSGxPf9vzALk12eBxV3Pl5BCX0GKrB6TK2Jq4SUtAhCxWFFgSixEKDItaRFmUUsKS1KKCRESE4mIZRJkYsI2BjYv0VGReZsuPJO/t4hbHXFLbmoB7jglRYsB8BAmCEuqYUYoAI8Kgjg2JeOUYYo5woxvEHdKsyWeW+jmSWiPgbNNeH8y/Fa6sAUgnGGQcEG5kJwJRpngnOlUxyWzLAoZUDDCLcYot8TmcPidJnILxRe1jOQPbhnXh/uquPgzEH+15DGIfPMYtFaQ2J07G7rmTsH1Ejf3su2Mup1RtzPqT51RVwqxhcnGKn6RbEwC0wVGGHMuucmEIAIxJCy88QYvCqFj+C1ZYztgvKzDW3pjC0BoEV5kJuIwVGBpUsEtJDiCYkctRAljkhNTYKDC8HuDDZ4bxQCCgms7m+7stghcF4FL72oB9kyBaBFZMMSaFpWSYkakhHlWYxCZmJuYcikI3+iEm2ZK+YXYh6taWqj0fxN/Z7+07jQ+r/Cf2Sv9ltli2yttpFdaFlmLnmCRwEXolXRegMYIEgST1OSwYkLVohAjkCEYIpLIjT5BpBmEbcFJVniD/bmJYv0CNfH8T0oS3Vx79juHRL7XKYx3l9W+axV4ru3P3zIr6kd2aIcJklxfMSGMmJxgCiO3ZSFMqcDQqEhMoVvhFI1rwtN0rE99/JVV8+c/VSzDz6pTgyUhMBiBMBACXCSJfjSWQjBTok0WTjAgApuSwtiwZRhw7BTq1hgzvysqnqpn4IW6d+sknu+vqfe/9yaHfw3Q18r40AwWpf4xOTYFRD9kA667Q0ZgOKFMSAmjCcwlSJq/+AnuR76PlcZeRyV6Nrt69S9948z++425evwPtGH0aA==" + util.porter.add_factories(player, export_string) + + local hotness = player_table.realm.first.first.next.next + local trash = hotness.next.next + trash.archived = true + util.context.set(player, hotness) + solver.update(player, hotness) + util.raise.refresh(player, "all") + + -- Preferences + player_table.preferences.display_gui_button = false + player_table.preferences.products_per_row = 5 + player_table.preferences.factory_list_rows = 20 + player_table.preferences.recipe_filters = {disabled = true, hidden = false} + player_table.preferences.ignore_barreling_recipes = true + player_table.preferences.ignore_recycling_recipes = true + + defaults.set(player, "belts", {prototype="fast-transport-belt"}, nil) + set_machine_default(player, "electric-mining-drill", "basic-solid") + set_machine_default(player, "steel-furnace", "smelting") + set_machine_default(player, "assembling-machine-2", "crafting") + set_machine_default(player, "assembling-machine-3", "advanced-crafting") + set_machine_default(player, "assembling-machine-2", "crafting-with-fluid") + + -- Research + player.force.technologies["oil-processing"].researched=true + player.force.technologies["coal-liquefaction"].researched=true + + -- Player inventory + player.insert{name="assembling-machine-3", count=9} + player.insert{name="assembling-machine-2", count=1} + player.insert{name="electric-mining-drill", count=29} + player.insert{name="speed-module-3", count=14} + player.insert{name="speed-module-2", count=1} + player.insert{name="chemical-plant", count=6} + end, + + setup_01_main_interface = function(player) + local translation_progress = mod_gui.get_frame_flow(player)["flib_translation_progress"] + if translation_progress then translation_progress.visible = false end + main_dialog.toggle(player) + end, + teardown_01_main_interface = function(player) + util.globals.preferences(player).factory_list_rows = 30 + main_dialog.rebuild(player, true) -- avoid modal dialogs being squished + + local main_frame = util.globals.main_elements(player).main_frame + return_dimensions("01_main_interface", main_frame) + end, + + setup_02_compact_interface = function(player) + util.globals.main_elements(player).main_frame.location = player.display_resolution -- hack city + local toggle_handler = get_handler("ui.main.title_bar", 1, "on_gui_click", "switch_to_compact_view") + toggle_handler(player, nil, nil) + end, + teardown_02_compact_interface = function(player) + local compact_frame = util.globals.ui_state(player).compact_elements.compact_frame + return_dimensions("02_compact_interface", compact_frame) + local toggle_handler = get_handler("ui.base.compact_dialog", 2, "on_gui_click", "switch_to_main_view") + toggle_handler(player, nil, nil) + end, + + setup_03_item_picker = function(player) + local modal_data = {item_id=nil, item_category="product"} + open_modal(player, "picker", modal_data) + + local modal_elements = util.globals.modal_elements(player) + modal_elements.search_textfield.text = "f" + local search_handler = get_handler("ui.base.modal_dialog", 1, "on_gui_text_changed", "modal_searchfield") + search_handler(player, nil, {text="f"}) + + local group_handler = get_handler("ui.dialogs.picker_dialog", 1, "on_gui_click", "select_picker_item_group") + group_handler(player, {group_id=3}, nil) + + modal_elements.item_choice_button.sprite = "item/raw-fish" + modal_elements.belt_amount_textfield.text = "0.5" + modal_elements.belt_choice_button.elem_value = "fast-transport-belt" + local belt_handler = get_handler("ui.dialogs.picker_dialog", 1, "on_gui_elem_changed", "picker_choose_belt") + belt_handler(player, nil, {element=modal_elements.belt_choice_button, elem_value="fast-transport-belt"}) + + modal_elements.search_textfield.focus() + end, + teardown_03_item_picker = (function(player) modal_teardown(player, "03_item_picker") end), + + setup_04_recipe_picker = function(player) + local product_proto = prototyper.util.find("items", "petroleum-gas", "fluid") + open_modal(player, "recipe", {category_id=product_proto.category_id, + product_id=product_proto.id, production_type="produce"}) + end, + teardown_04_recipe_picker = (function(player) modal_teardown(player, "04_recipe_picker") end), + + setup_05_machine = function(player) + local floor = util.context.get(player, "Floor") + open_modal(player, "machine", {machine_id=floor.first.next.machine.id}) + end, + teardown_05_machine = (function(player) modal_teardown(player, "05_machine") end), + + setup_06_import = function(player) + open_modal(player, "import", nil) + + local export_string = "eNrtWt+P4jYQ/lfcPG+2hG2ra6Q+tJUqVepJp+Ohqu5Q5DiT3Wn9I7UdVIT43ztOzIK4RRDKHnBF4gHb4/HM941n7CSLRJmqmIF1aHSSJ9l9dv/tKLlL4J/GWF/QqAOf5Iuk5A5WAt+TQC2xpPboPqNfaHPhjZ03kmsNdq1qeZe4tuxHEVySf1gkmqug6wOqxx/Qg/oatQPrwU7Ze2O8I3UeFTjBJcl9N7pLtPFhbkIj76ypWtHZZMo/QZB4vqBV+kYhJHdB8lfSS9Kr5s9GShoOXpKsN01RS2Ns0PIbatinrZPZpU3CDGSSZ+vxXzrdy3XHZAXBnHqfAZgokOT1/P/i75rwpneKFKZOIGgBacPFX1P2B/AnNum7vtqNS8reUaAx03rmaCKwqIWtFX/UKfvx7xYtMM5qdE/MG1a2KCs2abCieLO90O8oZZhX8lLOmQaomDI0C2mccVrAKNJrUPsdbFBE03RvQmf0dId/pMChaiTWCFWSe9sCuThvwpSAS4CQK9PSUnn2hvy10HlQFaveRVJBTeRURTmnSbF7a1ZJYVVEk2ouHcQQiMT1y66oWnm0nJ4ypgiTrVgKWWCAuAWBTSf0X7AV3MNjiMI8EZbXHvVjsH2toojo9z2bMf++NyAwQpIzWKmsTLC9g5UUgRWgPX+krmxE0CsunqJz23aTWlClJBPSKJU+DDG6NrRWIVGhf16f8nMroYg5um+5Q4Nzhn6e9nP2W7I5aTNMv1kj9rZT9VIgxZFdoRS9EM8aQTV+XjgZakE+2l5hQt5u5Jq3EfJlCHwuQh371Oc4ssfJuimi4IaHb46E2TWUSg7Gt5PeXHZ8cmDHA4H9KUIRMvjBu9NylNe4FcevvxVfqLOD+Btt8Tc+dGMMIjCUvdR5vM6MeqMxYgNhfYsirVurecfDjcxrJfOFin3j86g6PL7COryOmelRF7XxJxe1QcHHqxmnQ3aVCrSiRX87aN8O2reD9tkP2n2BN5pK/Fk2Zv//VuFPU+GFaQjKVPDyc5f2a+Hx9Bs/O/nGz7ZiJXuVWHEeQKaNJNf2+hyeMJ+zFg+9hmwa/CXvd+cJ0bS0B1yzbxxe6pMS1Rji0aYdmftQKbkjCJ2RWJ2fSYU6pO/KopQDDb+Ue9kL2fvusCP4+Ngj+OlPig9HXwUHPT4gQU8cltzurdNPoND57r3dWQ4XnQGCd/VN+0HmXkaueXgVCvtNaJBwsUaAcyG/7gFnS/pMhAYrbPfOsnvRPdDiy7yLP1xOIrjku/j4mu/i08/+UYR7/ixhyr7sT0Kmy38BcOORig==" + local modal_elements = util.globals.modal_elements(player) + modal_elements.import_textfield.text = export_string + + local textfield_handler = get_handler("ui.dialogs.porter_dialog", 1, "on_gui_text_changed", "import_string") + textfield_handler(player, nil, {element=modal_elements.import_textfield, text=export_string}) + + local import_handler = get_handler("ui.dialogs.porter_dialog", 1, "on_gui_click", "import_factories") + import_handler(player, nil, nil) + + local toggle = true + for _, checkbox in pairs(modal_elements.factory_checkboxes) do + checkbox.state = toggle + toggle = not toggle + end + modal_elements.master_checkbox.state = false + end, + teardown_06_import = (function(player) modal_teardown(player, "06_import") end), + + setup_07_utility = function(player) + open_modal(player, "utility", nil) + end, + teardown_07_utility = (function(player) modal_teardown(player, "07_utility") end), + + setup_08_preferences = function(player) + open_modal(player, "preferences", nil) + end, + teardown_08_preferences = (function(player) modal_teardown(player, "08_preferences") end) +} + +local function initial_setup() + DEV_ACTIVE = false -- desync city, but it's fiiine. Avoids any accidental artifacts. +end + +local function execute_action(player_index, action_name) + local player = game.get_player(player_index) + actions[action_name](player) +end + +if not remote.interfaces["screenshotter_input"] then + remote.add_interface("screenshotter_input", { + initial_setup = initial_setup, + execute_action = execute_action + }) +end diff --git a/modfiles/backend/init.lua b/modfiles/backend/init.lua new file mode 100755 index 000000000..498967929 --- /dev/null +++ b/modfiles/backend/init.lua @@ -0,0 +1,314 @@ +local Realm = require("backend.data.Realm") + +local loader = require("backend.handlers.loader") +local migrator = require("backend.handlers.migrator") +require("backend.handlers.prototyper") +require("backend.handlers.defaults") +require("backend.handlers.screenshotter") + +require("backend.calculation.solver") + + +---@class PreferencesTable +---@field timescale Timescale +---@field pause_on_interface boolean +---@field utility_scopes { components: "Factory" | "Floor" } +---@field recipe_filters { disabled: boolean, hidden: boolean } +---@field compact_ingredients boolean +---@field fold_out_subfloors boolean +---@field products_per_row integer +---@field factory_list_rows integer +---@field compact_width_percentage integer +---@field show_gui_button boolean +---@field attach_factory_products boolean +---@field skip_factory_naming boolean +---@field prefer_matrix_solver boolean +---@field show_floor_items boolean +---@field ingredient_satisfaction boolean +---@field ignore_barreling_recipes boolean +---@field ignore_recycling_recipes boolean +---@field done_column boolean +---@field percentage_column boolean +---@field line_comment_column boolean +---@field item_views ItemViewPreferences +---@field belts_or_lanes "belts" | "lanes" +---@field default_machines PrototypeDefaultWithCategory +---@field default_fuels PrototypeDefaultWithCategory +---@field default_beacons DefaultPrototype +---@field default_belts DefaultPrototype +---@field default_wagons PrototypeDefaultWithCategory + +---@alias Timescale 1 | 60 + +---@param player_table PlayerTable +function reload_preferences(player_table) + -- Reloads the user preferences, incorporating previous preferences if possible + local player_preferences = player_table.preferences or {} + local updated_prefs = {} + + local function reload(name, default) + -- Needs to be longform-if because true is a valid default + if player_preferences[name] == nil then + updated_prefs[name] = default + else + updated_prefs[name] = player_preferences[name] + end + end + + reload("timescale", 60) + reload("pause_on_interface", false) + reload("utility_scopes", {components = "Factory"}) + reload("recipe_filters", {disabled = false, hidden = false}) + reload("compact_ingredients", false) + reload("fold_out_subfloors", false) + + reload("products_per_row", 6) + reload("factory_list_rows", 30) + reload("compact_width_percentage", 26) + + reload("show_gui_button", true) + reload("skip_factory_naming", true) + reload("attach_factory_products", false) + reload("prefer_matrix_solver", false) + reload("show_floor_items", true) + reload("ingredient_satisfaction", false) + reload("ignore_barreling_recipes", false) + reload("ignore_recycling_recipes", false) + + reload("done_column", true) + reload("percentage_column", false) + reload("line_comment_column", false) + + reload("item_views", item_views.default_preferences()) + + reload("belts_or_lanes", "belts") + + reload("default_machines", defaults.get_fallback("machines")) + reload("default_fuels", defaults.get_fallback("fuels")) + reload("default_beacons", defaults.get_fallback("beacons")) + reload("default_belts", defaults.get_fallback("belts")) + reload("default_pumps", defaults.get_fallback("pumps")) + reload("default_wagons", defaults.get_fallback("wagons")) + + player_table.preferences = updated_prefs +end + + +---@class UIStateTable +---@field main_dialog_dimensions DisplayResolution? +---@field last_action LastAction? +---@field views_data ItemViewsData? +---@field messages PlayerMessage[] +---@field main_elements table +---@field compact_elements table +---@field calculator_elements table +---@field last_selected_picker_group integer? +---@field tooltips table +---@field modal_dialog_type ModalDialogType? +---@field modal_data table? +---@field context_menu LuaGuiElement? +---@field selection_mode boolean +---@field compact_view boolean +---@field districts_view boolean +---@field recalculate_on_factory_change boolean + +---@class LastAction +---@field action_name string +---@field tick Tick + +---@param player_table PlayerTable +local function reset_ui_state(player_table) + -- The UI table gets replaced because the whole interface is reset + player_table.ui_state = { + main_dialog_dimensions = nil, + last_action = nil, + views_data = nil, + messages = {}, + main_elements = {}, + compact_elements = {}, + calculator_elements = {}, + last_selected_picker_group = nil, + tooltips = {}, + + modal_dialog_type = nil, + modal_data = nil, + context_menu = nil, + + selection_mode = false, + compact_view = false, + districts_view = false, + recalculate_on_factory_change = false + } +end + + +---@class PlayerTable +---@field preferences PreferencesTable +---@field ui_state UIStateTable +---@field realm Realm +---@field context ContextTable +---@field translation_tables { [string]: TranslatedDictionary }? +---@field clipboard ClipboardEntry? + +---@param player LuaPlayer +local function player_init(player) + storage.players[player.index] = {} --[[@as table]] + local player_table = storage.players[player.index] + + player_table.realm = Realm.init() + util.context.init(player_table) + util.context.set(player, player_table.realm.first) + + reload_preferences(player_table) + reset_ui_state(player_table) + + -- Set default fuel to coal because anything else is awkward + defaults.set_all(player, "fuels", {prototype="coal"}) + + util.gui.toggle_mod_gui(player) + + if DEV_ACTIVE then + util.porter.add_factories(player, DEV_EXPORT_STRING) + + player.force.research_all_technologies() + player.clear_recipe_notifications() + player.cheat_mode = true + end +end + +---@param player LuaPlayer +local function refresh_player_table(player) + local player_table = storage.players[player.index] + + defaults.migrate(player_table) + + reload_preferences(player_table) + reset_ui_state(player_table) + + util.context.validate(player) + + player_table.translation_tables = nil + player_table.clipboard = nil + + player_table.realm:validate() +end + +---@class GlobalTable +---@field players { [PlayerIndex]: PlayerTable } +---@field prototypes PrototypeLists +---@field next_object_ID integer +---@field nth_tick_events { [Tick]: NthTickEvent } +---@field installed_mods ModToVersion +storage = {} -- just for the type checker, doesn't do anything + +local function global_init() + -- Set up a new save for development if necessary + local freeplay = remote.interfaces["freeplay"] + if DEV_ACTIVE and freeplay then -- Disable freeplay popup-message + if freeplay["set_skip_intro"] then remote.call("freeplay", "set_skip_intro", true) end + if freeplay["set_disable_crashsite"] then remote.call("freeplay", "set_disable_crashsite", true) end + end + + storage.players = {} -- Table containing all player-specific data + storage.next_object_ID = 1 -- Counter used for assigning incrementing IDs to all objects + storage.nth_tick_events = {} -- Save metadata about currently registered on_nth_tick events + + storage.prototypes = {} -- Table containing all relevant prototypes indexed by ID + prototyper.build() -- Generate all relevant prototypes and save them in storage + loader.run(true) -- Run loader which creates useful indexes of prototype data + + storage.installed_mods = script.active_mods -- Retain current modset to detect mod changes for invalid factories + + translator.on_init() -- Initialize flib's translation module + prototyper.util.build_translation_dictionaries() + + for _, player in pairs(game.players) do player_init(player) end +end + +-- Prompts migrations, a GUI and prototype reload, and a validity check on all factories +local function handle_configuration_change() + local migrations = migrator.determine_migrations() + + if not migrations then -- implies this save can't be migrated anymore + for _, player in pairs(game.players) do util.gui.reset_player(player) end + storage = {}; global_init() + game.print{"fp.mod_reset"}; + return + end + + storage.prototypes = {} + prototyper.build() + loader.run(true) + + migrator.migrate_global(migrations) + migrator.migrate_player_tables(migrations) + + for index, player in pairs(game.players) do + refresh_player_table(player) -- part of migration cleanup + + util.gui.reset_player(player) -- Destroys all existing GUI's + util.gui.toggle_mod_gui(player) -- Recreates the mod-GUI if necessary + + -- Update calculations in case prototypes changed in a relevant way + for district in storage.players[index].realm:iterator() do + for factory in district:iterator() do solver.update(player, factory) end + end + end + + storage.installed_mods = script.active_mods + + translator.on_configuration_changed() + prototyper.util.build_translation_dictionaries() +end + + +-- ** TOP LEVEL ** +script.on_init(global_init) + +script.on_configuration_changed(handle_configuration_change) + +script.on_load(loader.run) + + +-- ** PLAYER DATA ** +script.on_event(defines.events.on_player_created, function(event) + local player = game.get_player(event.player_index) ---@cast player -nil + player_init(player) +end) + +script.on_event(defines.events.on_player_removed, function(event) + storage.players[event.player_index] = nil +end) + + +-- ** TRANSLATION ** +-- Required by flib's translation module +script.on_event(defines.events.on_tick, translator.on_tick) +script.on_event(defines.events.on_player_joined_game, translator.on_player_joined_game) +script.on_event(defines.events.on_string_translated, translator.on_string_translated) + +---@param event GuiEvent +local function dictionaries_ready(event) + local player = game.get_player(event.player_index) ---@cast player -nil + local player_table = util.globals.player_table(player) + + player_table.translation_tables = translator.get_all(event.player_index) + modal_dialog.set_searchfield_state(player) -- enables searchfields if possible +end + +-- Save translations once they are complete +script.on_event(translator.on_player_dictionaries_ready, dictionaries_ready) + + +-- ** COMMANDS ** +if not commands.commands['fp-restart-translation'] then + commands.add_command("fp-restart-translation", {"command-help.fp_restart_translation"}, function() + translator.on_init() + prototyper.util.build_translation_dictionaries() + end) +end +if not commands.commands['fp-reload-prototypes'] then + commands.add_command("fp-shrinkwrap-interface", {"command-help.fp_shrinkwrap_interface"}, function(command) + if command.player_index then main_dialog.shrinkwrap_interface(game.get_player(command.player_index)) end + end) +end diff --git a/modfiles/backend/migrations/masterlist.json b/modfiles/backend/migrations/masterlist.json new file mode 100644 index 000000000..1165a7386 --- /dev/null +++ b/modfiles/backend/migrations/masterlist.json @@ -0,0 +1,25 @@ +[ + "1.0.6", + "1.1.5", + "1.1.14", + "1.1.27", + "1.1.42", + "1.1.59", + "1.1.61", + "1.1.65", + "1.1.66", + "1.1.67", + "1.1.73", + "1.2.1", + "1.2.2", + "1.2.4", + "1.2.6", + "1.2.8", + "1.2.15", + "2.0.2", + "2.0.6", + "2.0.8", + "2.0.16", + "2.0.21", + "2.0.26" +] \ No newline at end of file diff --git a/modfiles/backend/migrations/migration_0_0_0.lua b/modfiles/backend/migrations/migration_0_0_0.lua new file mode 100644 index 000000000..47944fc1c --- /dev/null +++ b/modfiles/backend/migrations/migration_0_0_0.lua @@ -0,0 +1,14 @@ +---@diagnostic disable + +local migration = {} + +function migration.global() +end + +function migration.player_table(player_table) +end + +function migration.packed_factory(packed_factory) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_0_6.lua b/modfiles/backend/migrations/migration_1_0_6.lua new file mode 100644 index 000000000..4536046c7 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_0_6.lua @@ -0,0 +1,11 @@ +---@diagnostic disable + +local migration = {} + +function migration.packed_factory(packed_subfactory) + if packed_subfactory.icon and packed_subfactory.icon.type == "virtual-signal" then + packed_subfactory.icon.type = "virtual" + end +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_14.lua b/modfiles/backend/migrations/migration_1_1_14.lua new file mode 100644 index 000000000..bafc4eb7c --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_14.lua @@ -0,0 +1,21 @@ +---@diagnostic disable + +local migration = {} + +function migration.packed_factory(packed_subfactory) + local function update_lines(floor) + for _, packed_line in ipairs(floor.Line.objects) do + if packed_line.subfloor then + update_lines(packed_line.subfloor) + else + packed_line.done = false + + packed_line.machine.force_limit = packed_line.machine.hard_limit + packed_line.machine.hard_limit = nil + end + end + end + update_lines(packed_subfactory.top_floor) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_27.lua b/modfiles/backend/migrations/migration_1_1_27.lua new file mode 100644 index 000000000..210c4d88d --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_27.lua @@ -0,0 +1,20 @@ +---@diagnostic disable + +local migration = {} + +function migration.packed_factory(packed_subfactory) + local function update_lines(floor) + for _, packed_line in ipairs(floor.Line.objects) do + if packed_line.subfloor then + update_lines(packed_line.subfloor) + elseif packed_line.beacon and packed_line.beacon.module then + local beacon = packed_line.beacon + beacon.Module = {objects={beacon.module}, class="Collection"} + beacon.module_count = module.amount + end + end + end + update_lines(packed_subfactory.top_floor) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_42.lua b/modfiles/backend/migrations/migration_1_1_42.lua new file mode 100644 index 000000000..d8dafaa37 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_42.lua @@ -0,0 +1,36 @@ +---@diagnostic disable + +local migration = {} + +local function migrate_packed_modules(packed_object) + local module_set = { + modules = packed_object.Module, + module_count = packed_object.module_count, + empty_slots = 0, -- updated later + class = "ModuleSet" + } + packed_object.Module = nil + packed_object.module_set = module_set +end + +function migration.packed_factory(packed_subfactory) + if packed_subfactory.icon then + local icon_path = packed_subfactory.icon.type .. "/" .. packed_subfactory.icon.name + packed_subfactory.name = "[img=" .. icon_path .. "] " .. packed_subfactory.name + packed_subfactory.icon = nil + end + + local function update_lines(floor) + for _, packed_line in ipairs(floor.Line.objects) do + if packed_line.subfloor then + update_lines(packed_line.subfloor) + else + migrate_packed_modules(packed_line.machine) + if packed_line.beacon then migrate_packed_modules(packed_line.beacon) end + end + end + end + update_lines(packed_subfactory.top_floor) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_5.lua b/modfiles/backend/migrations/migration_1_1_5.lua new file mode 100644 index 000000000..21048bdcb --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_5.lua @@ -0,0 +1,18 @@ +---@diagnostic disable + +local migration = {} + +function migration.packed_factory(packed_subfactory) + local function update_lines(floor) + for _, packed_line in ipairs(floor.Line.objects) do + if packed_line.subfloor then + update_lines(packed_line.subfloor) + else + packed_line.active = true + end + end + end + update_lines(packed_subfactory.top_floor) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_59.lua b/modfiles/backend/migrations/migration_1_1_59.lua new file mode 100644 index 000000000..81f91007a --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_59.lua @@ -0,0 +1,18 @@ +---@diagnostic disable + +local migration = {} + +function migration.packed_factory(packed_subfactory) + local function update_lines(floor) + for _, packed_line in ipairs(floor.Line.objects) do + if packed_line.subfloor then + update_lines(packed_line.subfloor) + else + packed_line.Product = {objects={}, class="Collection"} + end + end + end + update_lines(packed_subfactory.top_floor) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_61.lua b/modfiles/backend/migrations/migration_1_1_61.lua new file mode 100644 index 000000000..0032e1f91 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_61.lua @@ -0,0 +1,13 @@ +---@diagnostic disable + +local migration = {} + +function migration.global() + for _, event_data in pairs(storage.nth_tick_events) do + if event_data.handler_name == "adjust_interface_dimensions" then + event_data.handler_name = "shrinkwrap_interface" + end + end +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_65.lua b/modfiles/backend/migrations/migration_1_1_65.lua new file mode 100644 index 000000000..017ad79ab --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_65.lua @@ -0,0 +1,17 @@ +---@diagnostic disable + +local migration = {} + +function migration.player_table(player_table) + for _, factory in pairs({"factory", "archive"}) do + for _, subfactory in pairs(player_table[factory].Subfactory.datasets) do + subfactory.blueprints = {} + end + end +end + +function migration.packed_factory(packed_subfactory) + packed_subfactory.blueprints = {} +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_66.lua b/modfiles/backend/migrations/migration_1_1_66.lua new file mode 100644 index 000000000..383fcaf44 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_66.lua @@ -0,0 +1,16 @@ +---@diagnostic disable + +local migration = {} + +function migration.player_table(player_table) + local Subfactory = player_table.archive.Subfactory + Subfactory.count = table_size(Subfactory.datasets) + + local gui_position = 1 + for _, subfactory in pairs(Subfactory.datasets) do + subfactory.gui_position = gui_position + gui_position = gui_position + 1 + end +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_67.lua b/modfiles/backend/migrations/migration_1_1_67.lua new file mode 100644 index 000000000..47e6a02cc --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_67.lua @@ -0,0 +1,183 @@ +---@diagnostic disable + +local migration = {} + +function migration.global() + storage.tutorial_factory_validity = nil + + local data_types = {"machines", "recipes", "items", "fuels", "belts", "wagons", "modules", "beacons"} + for _, data_type in pairs(data_types) do storage["all_" .. data_type] = nil end +end + +function migration.player_table(player_table) + local default_prototypes = player_table.preferences.default_prototypes + default_prototypes["machines"] = default_prototypes["machines"].prototypes + default_prototypes["fuels"] = default_prototypes["fuels"].prototypes + default_prototypes["belts"] = default_prototypes["belts"].prototype + default_prototypes["wagons"] = default_prototypes["wagons"].prototypes + default_prototypes["beacons"] = default_prototypes["beacons"].prototype + + for _, factory in pairs({"factory", "archive"}) do + for _, subfactory in pairs(player_table[factory].Subfactory.datasets) do + for _, product in pairs(subfactory.Product.datasets) do + if product.proto.simplified then + product.proto = {name=product.proto.name, category=product.proto.type, data_type="items", simplified=true} + else + product.proto.data_type = "items" + end + local belt_proto = product.required_amount.belt_proto + if belt_proto then + if belt_proto.simplified then + product.required_amount.belt_proto = {name=belt_proto.name, data_type="belts", simplified=true} + else + product.required_amount.belt_proto.data_type = "belts" + end + end + end + + for index, _ in pairs(subfactory.matrix_free_items or {}) do + local item_proto = subfactory.matrix_free_items[index] + if item_proto.simplified then + subfactory.matrix_free_items[index] = + {name=item_proto.name, category=item_proto.type, data_type="items", simplified=true} + else + item_proto.data_type = "items" + end + end + + for _, floor in pairs(subfactory.Floor.datasets) do + for _, line in pairs(floor.Line.datasets) do + if line.subfloor then goto skip end + local recipe_proto = line.recipe.proto + if recipe_proto.simplified then + line.recipe.proto = {name=recipe_proto.name, data_type="recipes", simplified=true} + else + recipe_proto.data_type = "recipes" + end + local machine_proto = line.machine.proto + if machine_proto.simplified then + line.machine.proto = + {name=machine_proto.name, category=machine_proto.category, data_type="machines", simplified=true} + else + machine_proto.data_type = "machines" + end + local machine_module_set = line.machine.module_set + for _, module in pairs(machine_module_set.modules.datasets) do + if module.proto.simplified then + module.proto = {name=module.proto.name, category=module.proto.category, + data_type="modules", simplified=true} + else + module.proto.data_type = "modules" + end + end + if line.machine.fuel then + local fuel_proto = line.machine.fuel.proto + if fuel_proto.simplified then + line.machine.fuel.proto = + {name=fuel_proto.name, category=fuel_proto.category, data_type="fuels", simplified=true} + else + fuel_proto.data_type = "fuels" + end + end + if line.beacon then + local beacon_proto = line.beacon.proto + if beacon_proto.simplified then + line.beacon.proto = {name=beacon_proto.name, data_type="beacons", simplified=true} + else + beacon_proto.data_type = "beacons" + end + local beacon_module_set = line.beacon.module_set + for _, module in pairs(beacon_module_set.modules.datasets) do + if module.proto.simplified then + module.proto = {name=module.proto.name, category=module.proto.category, + data_type="modules", simplified=true} + else + module.proto.data_type = "modules" + end + end + end + if line.priority_product_proto then + local priority_product_proto = line.priority_product_proto + if priority_product_proto.simplified then + line.priority_product_proto = {name=priority_product_proto.name, + category=priority_product_proto.type, data_type="items", simplified=true} + else + priority_product_proto.data_type = "items" + end + end + for _, product in pairs(line.Product.datasets) do + if product.proto.simplified then + product.proto = + {name=product.proto.name, category=product.proto.type, data_type="items", simplified=true} + else + product.proto.data_type = "items" + end + end + ::skip:: + end + end + end + end +end + +function migration.packed_factory(packed_subfactory) + for _, product in pairs(packed_subfactory.Product.objects) do + product.proto = {name=product.proto.name, category=product.proto.type, data_type="items", simplified=true} + if product.required_amount.belt_proto then + local belt_proto = product.required_amount.belt_proto + product.required_amount.belt_proto = {name=belt_proto.name, data_type="belts", simplified=true} + end + end + + if packed_subfactory.matrix_free_items then + for index, proto in pairs(packed_subfactory.matrix_free_items) do + packed_subfactory.matrix_free_items[index] = + {name=proto.name, category=proto.type, data_type="items", simplified=true} + end + end + + local function update_lines(floor) + for _, packed_line in ipairs(floor.Line.objects) do + if packed_line.subfloor then + update_lines(packed_line.subfloor) + else + local recipe_proto = packed_line.recipe.proto + packed_line.recipe.proto = {name=recipe_proto.name, data_type="recipes", simplified=true} + local machine_proto = packed_line.machine.proto + packed_line.machine.proto = + {name=machine_proto.name, category=machine_proto.category, data_type="machines", simplified=true} + local module_set = packed_line.machine.module_set + for _, module in pairs(module_set.modules.objects) do + module.proto = + {name=module.proto.name, category=module.proto.category, data_type="modules", simplified=true} + end + if packed_line.machine.fuel then + local fuel_proto = packed_line.machine.fuel.proto + packed_line.machine.fuel.proto = + {name=fuel_proto.name, category=fuel_proto.category, data_type="fuels", simplified=true} + end + if packed_line.beacon then + local beacon_proto = packed_line.beacon.proto + packed_line.beacon.proto = {name=beacon_proto.name, data_type="beacons", simplified=true} + local module_set = packed_line.beacon.module_set + for _, module in pairs(module_set.modules.objects) do + module.proto = {name=module.proto.name, category=module.proto.category, + data_type="modules", simplified=true} + end + end + if packed_line.priority_product_proto then + local priority_product_proto = packed_line.priority_product_proto + packed_line.priority_product_proto = {name=priority_product_proto.name, + category=priority_product_proto.type, data_type="items", simplified=true} + end + for _, product in pairs(packed_line.Product.objects) do + product.proto = + {name=product.proto.name, category=product.proto.type, data_type="items", simplified=true} + end + end + end + end + update_lines(packed_subfactory.top_floor) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_1_73.lua b/modfiles/backend/migrations/migration_1_1_73.lua new file mode 100644 index 000000000..e04772df7 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_1_73.lua @@ -0,0 +1,221 @@ +---@diagnostic disable + +-- This directly uses class methods, which is inherently fragile, but there's no +-- good way around it. The minimum migration version will need to be moved past +-- this is there ever is a large change related to this again. +local District = require("backend.data.District") +local Factory = require("backend.data.Factory") +local Product = require("backend.data.Product") +local Floor = require("backend.data.Floor") +local Line = require("backend.data.Line") +local Machine = require("backend.data.Machine") +local Fuel = require("backend.data.Fuel") +local Beacon = require("backend.data.Beacon") +local ModuleSet = require("backend.data.ModuleSet") +local Module = require("backend.data.Module") + +local migration = {} + +function migration.global() + storage.next_object_ID = 1 + storage.mod_version = nil + storage.tutorial_subfactory = nil + + for _, event_data in pairs(storage.nth_tick_events) do + if event_data.handler_name == "scale_subfactory_by_ingredient_amount" then + event_data.handler_name = "scale_factory_by_product_amount" + end + -- No need to migrate "delete_subfactory_for_good" since those factories will be deleted + end +end + +function migration.player_table(player_table) + player_table.preferences.default_machines = player_table.preferences.default_prototypes.machines + player_table.preferences.default_fuels = player_table.preferences.default_prototypes.fuels + player_table.preferences.default_belts = player_table.preferences.default_prototypes.belts + player_table.preferences.default_wagons = player_table.preferences.default_prototypes.wagons + player_table.preferences.default_beacons = player_table.preferences.default_prototypes.beacons + player_table.preferences.default_prototypes = nil + + player_table.preferences.products_per_row = player_table.settings.products_per_row + player_table.preferences.factory_list_rows = player_table.settings.subfactory_list_rows + player_table.preferences.default_timescale = player_table.settings.default_timescale + player_table.preferences.show_gui_button = player_table.settings.show_gui_button + player_table.preferences.prefer_product_picker = player_table.settings.prefer_product_picker + player_table.preferences.prefer_matrix_solver = player_table.settings.prefer_matrix_solver + player_table.preferences.belts_or_lanes = player_table.settings.belts_or_lanes + player_table.settings = nil + + player_table.preferences.attach_factory_products = player_table.preferences.attach_subfactory_products + local scopes = player_table.preferences.utility_scopes + if scopes.components == "Subfactory" then scopes.components = "Factory" end + + local district = District.init() + for _, factory_name in pairs({"factory", "archive"}) do + for _, subfactory in pairs(player_table[factory_name].Subfactory.datasets) do + -- Delete it now so nth_tick doesn't need to be linked up + if subfactory.tick_of_deletion then + if subfactory.item_request_proxy then + subfactory.item_request_proxy.destroy{raise_destroy=false} + end + util.nth_tick.cancel(subfactory.tick_of_deletion) + goto skip + end + + local factory = Factory.init(subfactory.name, subfactory.timescale) + factory.archived = (factory_name == "archive") + + factory.mining_productivity = subfactory.mining_productivity + factory.matrix_free_items = subfactory.matrix_free_items + factory.blueprints = subfactory.blueprints + factory.notes = subfactory.notes + + factory.tick_of_deletion = subfactory.tick_of_deletion + factory.item_request_proxy = subfactory.item_request_proxy + factory.last_valid_modset = subfactory.last_valid_modset + + for _, product in pairs(subfactory.Product.datasets) do + local new_product = Product.init(product.proto) + new_product.defined_by = product.required_amount.defined_by + new_product.required_amount = product.required_amount.amount + new_product.belt_proto = product.required_amount.belt_proto + factory:insert(new_product) + end + + local function convert_floor(floor, parent) + local new_floor = Floor.init(floor.level) + new_floor.parent = parent + for _, line in pairs(floor.Line.datasets) do + if line.subfloor then + local subfloor = convert_floor(line.subfloor, new_floor) + new_floor:insert(subfloor) + else + local new_line = Line.init(line.recipe.proto, line.recipe.production_type) + new_line.done = line.done + new_line.active = line.active + new_line.percentage = line.percentage + + local new_machine = Machine.init(line.machine.proto, new_line) + new_machine.limit = line.machine.limit + new_machine.force_limit = line.machine.force_limit + if line.machine.fuel then + new_machine.fuel = Fuel.init(line.machine.fuel.proto, new_machine) + end + local new_module_set = ModuleSet.init(new_machine) + for _, module in pairs(line.machine.module_set.modules.datasets) do + local new_module = Module.init(module.proto, module.amount) + new_module_set:insert(new_module) + end + new_machine.module_set = new_module_set + new_line.machine = new_machine + + if line.beacon then + local new_beacon = Beacon.init(line.beacon.proto, new_line) + new_beacon.amount = line.beacon.amount + new_beacon.total_amount = line.beacon.total_amount + local new_module_set = ModuleSet.init(new_beacon) + for _, module in pairs(line.beacon.module_set.modules.datasets) do + local new_module = Module.init(module.proto, module.amount) + new_module_set:insert(new_module) + end + new_beacon.module_set = new_module_set + new_line.beacon = new_beacon + end + + new_line.priority_product = line.priority_product_proto + new_line.comment = line.comment + + new_floor:insert(new_line) + end + end + return new_floor + end + factory.top_floor = convert_floor(subfactory.Floor.datasets[1], factory) + + district:insert(factory) + ::skip:: + end + end + + player_table.district = district + player_table.context = { + object_id = (district.first) and district.first.top_floor.id or 1, + cache = {main = nil, archive = nil, factory = {}} + } + + player_table.index = nil + player_table.mod_version = nil + player_table.factory = nil + player_table.archive = nil +end + +function migration.packed_factory(packed_subfactory) + -- Most things just carry over as-is here, only the structure changes + + packed_subfactory.products = {} + for _, product in pairs(packed_subfactory.Product.objects) do + table.insert(packed_subfactory.products, { + proto = product.proto, + defined_by = product.required_amount.defined_by, + required_amount = product.required_amount.amount, + belt_proto = product.required_amount.belt_proto, + class = "Product" + }) + end + + local function convert_module_set(module_set) + local modules = {} + + for _, module in pairs(module_set.modules.objects) do + table.insert(modules, { + proto = module.proto, + amount = module.amount, + class = "Module" + }) + end + + return { + modules = modules, + class = "ModuleSet" + } + end + + local function convert_floor(packed_floor) + local new_floor = {level = packed_floor.level, lines = {}, class = "Floor"} + for _, line in pairs(packed_floor.Line.objects) do + if line.subfloor then + table.insert(new_floor.lines, convert_floor(line.subfloor)) + else + table.insert(new_floor.lines, { + recipe_proto = line.recipe.proto, + production_type = line.recipe.production_type, + done = line.done, + active = line.active, + percentage = line.percentage, + machine = { + proto = line.machine.proto, + limit = line.machine.limit, + force_limit = line.machine.force_limit, + fuel = line.machine.fuel, + module_set = convert_module_set(line.machine.module_set), + class = "Machine" + }, + beacon = line.beacon and { + proto = line.beacon.proto, + amount = line.beacon.amount, + total_amount = line.beacon.total_amount, + module_set = convert_module_set(line.beacon.module_set), + class = "Beacon" + }, + priority_product = line.priority_product_proto, + comment = line.comment, + class = "Line" + }) + end + end + return new_floor + end + packed_subfactory.top_floor = convert_floor(packed_subfactory.top_floor) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_2_1.lua b/modfiles/backend/migrations/migration_1_2_1.lua new file mode 100644 index 000000000..edfe30b92 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_2_1.lua @@ -0,0 +1,42 @@ +---@diagnostic disable + +local migration = {} + +function migration.player_table(player_table) + for factory in player_table.district:iterator() do + if factory.item_request_proxy and factory.item_request_proxy.valid then + factory.item_request_proxy.destroy{raise_destroy=false} + end + factory.item_request_proxy = nil + + factory.mining_productivity = nil + + local function iterate_floor(floor) + for line in floor:iterator() do + if line.class == "Floor" then + iterate_floor(line) + elseif line.beacon then + line.beacon.amount = math.ceil(line.beacon.amount) + end + end + end + iterate_floor(factory.top_floor) + end +end + +function migration.packed_factory(packed_factory) + packed_factory.mining_productivity = nil + + local function iterate_floor(packed_floor) + for _, packed_line in pairs(packed_floor.lines) do + if packed_line.class == "Floor" then + iterate_floor(packed_line) + elseif packed_line.beacon then + packed_line.beacon.amount = math.ceil(packed_line.beacon.amount) + end + end + end + iterate_floor(packed_factory.top_floor) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_2_15.lua b/modfiles/backend/migrations/migration_1_2_15.lua new file mode 100644 index 000000000..9513fb48e --- /dev/null +++ b/modfiles/backend/migrations/migration_1_2_15.lua @@ -0,0 +1,17 @@ +---@diagnostic disable + +local migration = {} + +function migration.player_table(player_table) + local item_views = player_table.preferences.item_views + if item_views == nil then return end + + local preferences = { views = {}, selected_index = nil } + for index, view in pairs(item_views) do + if view.selected then preferences.selected_index = index end + table.insert(preferences.views, {name=view.name, enabled=view.enabled}) + end + player_table.preferences.item_views = preferences +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_2_2.lua b/modfiles/backend/migrations/migration_1_2_2.lua new file mode 100644 index 000000000..c3e3946c2 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_2_2.lua @@ -0,0 +1,31 @@ +---@diagnostic disable + +local Realm = require("backend.data.Realm") + +local migration = {} + +function migration.player_table(player_table) + for factory in player_table.district:iterator() do + for product in factory:iterator() do + product.required_amount = product.required_amount / factory.timescale + end + end + + player_table.district.name = "New District" + player_table.district.location_proto = {name = "nauvis", data_type = "locations", simplified = true} + player_table.district.power = 0 + player_table.district.emissions = {} + + player_table.realm = Realm.init(player_table.district) + player_table.district = nil + + util.context.init(player_table) -- resets context +end + +function migration.packed_factory(packed_factory) + for _, product in pairs(packed_factory.products) do + product.required_amount = product.required_amount / packed_factory.timescale + end +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_2_4.lua b/modfiles/backend/migrations/migration_1_2_4.lua new file mode 100644 index 000000000..f0f608862 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_2_4.lua @@ -0,0 +1,61 @@ +---@diagnostic disable + +local migration = {} + +local function normal_quality_proto() + return {name = "normal", data_type = "qualities", simplified = true} +end + +function migration.player_table(player_table) + local function update_modules(module_set) + for module in module_set:iterator() do + module.quality_proto = normal_quality_proto() + end + end + + for district in player_table.realm:iterator() do + for factory in district:iterator() do + local function iterate_floor(floor) + for line in floor:iterator() do + if line.class == "Floor" then + iterate_floor(line) + else + line.machine.quality_proto = normal_quality_proto() + update_modules(line.machine.module_set) + if line.beacon then + line.beacon.quality_proto = normal_quality_proto() + update_modules(line.beacon.module_set) + end + end + end + end + iterate_floor(factory.top_floor) + end + end +end + +function migration.packed_factory(packed_factory) + local function update_modules(module_set) + for _, module in pairs(module_set.modules) do + module.quality_proto = normal_quality_proto() + end + end + + local function iterate_floor(packed_floor) + for _, packed_line in pairs(packed_floor.lines) do + if packed_line.class == "Floor" then + iterate_floor(packed_line) + else + packed_line.machine.quality_proto = normal_quality_proto() + update_modules(packed_line.machine.module_set) + if packed_line.beacon then + packed_line.beacon.quality_proto = normal_quality_proto() + update_modules(packed_line.beacon.module_set) + end + end + end + end + iterate_floor(packed_factory.top_floor) +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_2_6.lua b/modfiles/backend/migrations/migration_1_2_6.lua new file mode 100644 index 000000000..8f96ab4f5 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_2_6.lua @@ -0,0 +1,28 @@ +---@diagnostic disable + +local migration = {} + +function migration.player_table(player_table) + -- Reset all defaults tables since I don't want to deal with migrating them + player_table.preferences.default_machines = nil + player_table.preferences.default_fuels = nil + player_table.preferences.default_beacons = nil + player_table.preferences.default_belts = nil + player_table.preferences.default_wagons = nil + + -- Reset these since the permitted values changed + player_table.preferences.products_per_row = 6 + player_table.preferences.factory_list_rows = 28 + + for district in player_table.realm:iterator() do + for factory in district:iterator() do + factory.productivity_boni = {} + end + end +end + +function migration.packed_factory(packed_factory) + packed_factory.productivity_boni = {} +end + +return migration diff --git a/modfiles/backend/migrations/migration_1_2_8.lua b/modfiles/backend/migrations/migration_1_2_8.lua new file mode 100644 index 000000000..761ef9244 --- /dev/null +++ b/modfiles/backend/migrations/migration_1_2_8.lua @@ -0,0 +1,10 @@ +---@diagnostic disable + +local migration = {} + +function migration.global() + storage.tutorial_factory = nil + storage.productivity_recipes = nil +end + +return migration diff --git a/modfiles/backend/migrations/migration_2_0_16.lua b/modfiles/backend/migrations/migration_2_0_16.lua new file mode 100644 index 000000000..01c717572 --- /dev/null +++ b/modfiles/backend/migrations/migration_2_0_16.lua @@ -0,0 +1,23 @@ +---@diagnostic disable + +local migration = {} + +function migration.player_table(player_table) + local item_views = player_table.preferences.item_views + if item_views then + local found_index, enabled = nil, nil + for index, view in pairs(item_views.views) do + if view.name == "belts_or_lanes" then + found_index = index + enabled = view.enabled + end + end + if found_index then + item_views.views[found_index] = {name="throughput", enabled=enabled} + else + table.insert(item_views.views, {name="throughput", enabled=false}) + end + end +end + +return migration diff --git a/modfiles/backend/migrations/migration_2_0_2.lua b/modfiles/backend/migrations/migration_2_0_2.lua new file mode 100644 index 000000000..0a7929ba8 --- /dev/null +++ b/modfiles/backend/migrations/migration_2_0_2.lua @@ -0,0 +1,10 @@ +---@diagnostic disable + +local migration = {} + +function migration.global() + for tick, _ in pairs(storage.nth_tick_events) do script.on_nth_tick(tick, nil) end + storage.nth_tick_events = {} -- reset because some bad data ended up in there +end + +return migration diff --git a/modfiles/backend/migrations/migration_2_0_21.lua b/modfiles/backend/migrations/migration_2_0_21.lua new file mode 100644 index 000000000..ea984ccdd --- /dev/null +++ b/modfiles/backend/migrations/migration_2_0_21.lua @@ -0,0 +1,13 @@ +---@diagnostic disable + +local migration = {} + +function migration.player_table(player_table) + if player_table.preferences.default_pumps then + player_table.preferences.default_pumps.quality = "normal" + end + player_table.preferences.default_wagons[1].quality = "normal" + player_table.preferences.default_wagons[2].quality = "normal" +end + +return migration diff --git a/modfiles/backend/migrations/migration_2_0_26.lua b/modfiles/backend/migrations/migration_2_0_26.lua new file mode 100644 index 000000000..6f6e871ca --- /dev/null +++ b/modfiles/backend/migrations/migration_2_0_26.lua @@ -0,0 +1,32 @@ +---@diagnostic disable + +local migration = {} + +function migration.player_table(player_table) + for district in player_table.realm:iterator() do + for factory in district:iterator() do + for _, product in pairs(factory:as_list()) do + if product.proto.type == "fluid" then + local map = TEMPERATURE_MAP[product.proto.name] + product.proto = prototyper.util.find("items", map[1].name, "fluid") + end + end + end + end +end + +function migration.packed_factory(packed_factory) + local products = {} + for _, product in pairs(packed_factory.products) do + local map = TEMPERATURE_MAP[product.proto.name] + if not map then -- means it's not a fluid + table.insert(products, product) + else + product.proto = prototyper.util.find("items", map[1].name, "fluid") + table.insert(products, product) + end + end + packed_factory.products = products +end + +return migration diff --git a/modfiles/backend/migrations/migration_2_0_6.lua b/modfiles/backend/migrations/migration_2_0_6.lua new file mode 100644 index 000000000..8ea95d509 --- /dev/null +++ b/modfiles/backend/migrations/migration_2_0_6.lua @@ -0,0 +1,34 @@ +---@diagnostic disable + +local migration = {} + +function migration.player_table(player_table) + for district in player_table.realm:iterator() do + district.products = nil + district.byproducts = nil + district.ingredients = nil + + -- Used to migrate to Object, but that changed so this is weird + district.product_set = {} + district.ingredient_set = {} + + for factory in district:iterator() do + local function iterate_floor(floor) + for line in floor:iterator() do + line.products = {} + line.byproducts = {} + line.ingredients = {} + + if line.class == "Floor" then + iterate_floor(line) + else + line.machine.total_effects = nil + end + end + end + iterate_floor(factory.top_floor) + end + end +end + +return migration diff --git a/modfiles/backend/migrations/migration_2_0_8.lua b/modfiles/backend/migrations/migration_2_0_8.lua new file mode 100644 index 000000000..656451565 --- /dev/null +++ b/modfiles/backend/migrations/migration_2_0_8.lua @@ -0,0 +1,15 @@ +---@diagnostic disable + +local DistrictItemSet = require("backend.data.DistrictItemSet") + +local migration = {} + +function migration.player_table(player_table) + for district in player_table.realm:iterator() do + district.product_set = nil + district.ingredient_set = nil + district.item_set = DistrictItemSet.init() + end +end + +return migration diff --git a/modfiles/changelog.txt b/modfiles/changelog.txt new file mode 100644 index 000000000..d5dcfb6d6 --- /dev/null +++ b/modfiles/changelog.txt @@ -0,0 +1,2356 @@ +--------------------------------------------------------------------------------------------------- +Version: 0.00.00 +Date: 00. 00. 0000 + Features: + - + Changes: + - + Bugfixes: + - + +--------------------------------------------------------------------------------------------------- +Version: 2.0.31 +Date: 18. 07. 2025 + Changes: + - Allowed adding recipes via the top item buttons on subfloors if the 'Show floor items' preference is enabled (#570) + - Added smaller values for the comapct dialog width setting, supporting ultrawide monitors better (#540) (Thanks NIronwolf!) + Bugfixes: + - Fixed an issue where productivity wasn't being applied correctly for recipes involving randomized product amounts (#323) (Thanks kalenedrael!) + - Fixed burnt fuel items always being categorized as a byproduct (#579) (Thanks scottmsul!) + - Fixed pump throughput view showing a pump requirement 60 times too high (#544) + - Fixed that the checkbox to mark recipes as done was duplicated when using the 'Fold out subfloors' preference (#553) + - Fixed that recipe buttons wouldn't show their total module effects in their tooltips + - Fixed a crash when trying to copy/paste a recipe line + +--------------------------------------------------------------------------------------------------- +Version: 2.0.30 +Date: 08. 07. 2025 + Bugfixes: + - Fixed an issue where recipes with fuel would not satisfy demand in some cases + +--------------------------------------------------------------------------------------------------- +Version: 2.0.29 +Date: 07. 07. 2025 + Bugfixes: + - Fixed a crash when using the ingredient satisfaction preference (#560) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.28 +Date: 07. 07. 2025 + Bugfixes: + - Fixed a crash when loading the compact factory view (#559) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.27 +Date: 07. 07. 2025 + Bugfixes: + - Fixed that you couldn't add fluid byproduct-consuming recipes to the matrix solver anymore + - Fixed a crash when loading a game with specific mods + +--------------------------------------------------------------------------------------------------- +Version: 2.0.26 +Date: 06. 07. 2025 + Features: + - *Finally* added proper support for fluids with temperatures. The mod, including both solvers, now distinguishes between fluids at different temperatures. If a recipe ingredient could be fulfilled by more than one temperature, you'll be able to pick the correct one. Note that this is a partially breaking change, as any factory product that has more than one possible temperature will be set as the lowest option to resolve the ambiguity (#95) + Bugfixes: + - Fixed an issue where self-fueling recipes that weren't sustainable were not detected properly (#554) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.25 +Date: 01. 07. 2025 + Bugfixes: + - Fixed that the matrix solver didn't support fuel's burnt results (#348) (Thanks scottmsul!) + - Fixed that machine fuel wouldn't show as a top level ingredient when using the matrix solver (Thanks scottmsul!) + - Fixed that machines producing their own fuel would not account for this in their production + +--------------------------------------------------------------------------------------------------- +Version: 2.0.24 +Date: 20. 06. 2025 + Bugfixes: + - Fixed a crash when loading certain saves that use the matrix solver + +--------------------------------------------------------------------------------------------------- +Version: 2.0.23 +Date: 20. 06. 2025 + Changes: + - Split up the rocket launch recipe into a launch and a rocket part recipe, fixing the issue of productivity being applied to the satellite cargo (#509) + Bugfixes: + - Fixed the issue that the matrix solver would sometimes return negative results (#127) (Thanks numberZero!) + - Fixed a crash when trying to put certain beacons into the cursor (#530) + - Fixed that some machines weren't shown as consuming fluid fuel (#511) + - Fixed that requesting missing machines for a factory via bots didn't set the amount correctly (#522) (Thanks marfenij!) + - Fixed that spoiling recipes weren't allowed on space platforms (#524) + - Fixed that pasting the same module with different quality would not work (#535) + - Fixed a migration issue when coming from a 1.1 save file + +--------------------------------------------------------------------------------------------------- +Version: 2.0.22 +Date: 17. 06. 2025 + Features: + - Added the ability to collapse a district's items visually to save space (#516) + Bugfixes: + - Fixed some compatibility issues with mods adding more surface properties (#534) + - Fixed that beacons could be added to burner mining drills + +--------------------------------------------------------------------------------------------------- +Version: 2.0.21 +Date: 13. 06. 2025 + Features: + - Added a universal District location, which accepts any recipe/machine regardless of surface restrictions (#501) + - Added support for picking a quality on preference pumps and wagons + - Added a quality indication to all machine, beacon, and module buttons (#375) + Changes: + - Bumped the required Factorio version to 2.0.49 + Bugfixes: + - Fixed an issue where certain rocket launch recipes would not be detected properly + +--------------------------------------------------------------------------------------------------- +Version: 2.0.20 +Date: 06. 05. 2025 + Features: + - Added support for opening recipes, items, and entities in Factoriopedia (#378) + Changes: + - Started filtering out internal recipes of the Transport Drones mod + - Bumped the required Factorio version to 2.0.44 + +--------------------------------------------------------------------------------------------------- +Version: 2.0.19 +Date: 17. 03. 2025 + Bugfixes: + - Fixed a crash when trying to put a beacon into the cursor (#490) + - Fixed a crash when trying to put a compact dialog top level ingredient into the cursor (#495) + - Fixed an issue where machines were not being shown when they should be (#456) + - Fixed an issue when trying to import a factory from Factorio 1.1 containing offshore pumps (#452) + - Fixed that copy/pasting machines with modules didn't verify their compatibility (#469) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.18 +Date: 14. 03. 2025 + Bugfixes: + - Fixed a crash when toggling the ingredient satisfaction preference while an invalid factory exists (#453) + - Fixed a crash when trying to put certain Pyanodon's machines into the cursor (#488) + - Fixed that some rocket silo recipes didn't take maximum productivity into account + - Fixed that Factory Planner would accidentally unpause the game when using remote view while in editor mode + - Fixed that only the last District would show tooltips on its items + - Fixed several UI inconsistencies + +--------------------------------------------------------------------------------------------------- +Version: 2.0.17 +Date: 23. 02. 2025 + Bugfixes: + - Fixed a migration crash (#433) + - Fixed putting offshore pumps into the cursor would crash (#438) + - Fixed a crash when hovering a compact dialog ingredient while on a subfloor (#439) + - Fixed that machine base quality effects were ten times too high (#442) + - Fixed a crash on startup when using the Cerys mod (#458) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.16 +Date: 26. 11. 2024 + Bugfixes: + - Fixed a startup crash (#428) + - Fixed a migration issue with the previous release (#431) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.15 +Date: 26. 11. 2024 + Features: + - Added a toggle to view factory ingredients in the compact view (#180) + - The belt throughput display option now shows pump throughput for fluids (#411) + Changes: + - Several actions now close the District view when it's open, for more intuitive use of the UI (#379) + - Moved the 'fold out subfloors' preference to a toggle button in the main UI (#299) + - Machines/fuels/beacons are now guaranteed to have sensible, low-level defaults + Bugfixes: + - Fixed that copy/pasting a machine didn't bring along its fuel and modules (#424) + - Fixed a crash when trying to put an item into the cursor (#426) + - Fixed that catalysts weren't being shown in the compact dialog + +--------------------------------------------------------------------------------------------------- +Version: 2.0.14 +Date: 23. 11. 2024 + Features: + - Added a preference to change the width of the compact dialog + - Added context menu actions to shift factory products left and right + - Added a context menu action on fuels to edit them + Changes: + - Putting simple machines into the cursor now makes them a ghost instead of a blueprint (#387) + - All item lists (like on recipes) are now sorted by item type and amount + Bugfixes: + - Fixed an issue where machines would not be migrated correctly when their category changed (#423) + - Fixed an issue where repairing machines didn't configure their fuel correctly (#423) + - Fixed that putting certain entities into the cursor wouldn't include its modules + - Fixed a migration crash when trying to load a save that had all factories deleted + +--------------------------------------------------------------------------------------------------- +Version: 2.0.13 +Date: 22. 11. 2024 + Bugfixes: + - Fixed a crash when pasting a module (#417) + - Fixed a crash when trying to paste a line that uses fuel in certain modded situations + +--------------------------------------------------------------------------------------------------- +Version: 2.0.12 +Date: 20. 11. 2024 + Bugfixes: + - Fixed a crash when trying to edit a machine that uses fuel (#412) + - Fixed a crash when entering a negative beacon amount (#415) + - Fixed a crash when trying to filter a fluid on an inserter + - Fixed a fuel migration issue, marking it as invalid when it wasn't + +--------------------------------------------------------------------------------------------------- +Version: 2.0.11 +Date: 19. 11. 2024 + Features: + - Activating a 'Put into cursor' action on any item button while holding an inserter now whitelists that item + Bugfixes: + - Fixed various migration and repair issues (spicy!) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.10 +Date: 17. 11. 2024 + Bugfixes: + - Fixed that the beacon dialog can't be confirmed (#409) + - Fixed that hidden planets were still shown (#410) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.9 +Date: 17. 11. 2024 + Changes: + - Added a warning message when trying to use a recipe in an incompatible location (#393) + Bugfixes: + - Fixed that adding the same module, but of different quality would just drop one of them (#392) + - Fixed that pasting a machine onto another one could leave it in a broken state (#383) + - Fixed that pasting modules would not handle their quality correctly + - Fixed a crash when trying to open the utility dialog without an associated player character + - Fixed some modded beacons being over-permissive with what modules they support + - Fixed some modded machines not allowing beacons even though they should + - Fixed that Pyanodon's empty planter box recipe was detected as a barreling recipe (#406) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.8 +Date: 15. 11. 2024 + Features: + - Added copy and put-into-cursor actions to district items + Bugfixes: + - Fixed a crash in the solver code (#405) + - Fixed a crash when opening the utility dialog while in map view (#401) + - Fixed a crash when trying to import recipe productivity recipes without a selected factory (#404) + - Fixed a crash when trying to paste a recipe line on a subfloor (#403) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.7 +Date: 14. 11. 2024 + Bugfixes: + - Fixed a couple of crashes when repairing very broken factories (#216) + - Fixed that invalid modules weren't being filtered out properly (#320) + - Fixed a crash when trying to open the calculator dialog + - Fixed a crash when trying to copy/paste items + - Fixed that 0-ed items were still being shown on Districts + - Fixed beacon profiles not being applied correctly in all cases + - Fixed that putting items into a combinator would always add them as per second instead of per current timescale + +--------------------------------------------------------------------------------------------------- +Version: 2.0.6 +Date: 13. 11. 2024 + Features: + - Redid the District items view, splitting them into products and ingredients only, and indicating where a surplus or deficit is present + Bugfixes: + - Fixed a couple of migration issues when migrating from 1.1 + +--------------------------------------------------------------------------------------------------- +Version: 2.0.5 +Date: 12. 11. 2024 + Bugfixes: + - Tried fixing a common crash related to multiplayer latency (#219) + - Fixed that satellite science recipes didn't include a satellite ingredient (#397) + - Fixed a crash when trying to confirm a decimal beacon amount by way of a mathematical expression + - Fixed a crash when changing to a machine that uses fluid fuels + - Fixed that items in inventory weren't detected in the utility dialog + - Fixed that the utility dialog machine and module section didn't take quality into account + - Fixed that the custom rocket recipes didn't consider rocket part recipe productivity + - Fixed an issue where copying fuels didn't properly check for compatibility + - Fixed a migration issue that erroniously marked fuel as invalid + - Fixed an issue where cancelling the machine dialog would reset fuel amounts + +--------------------------------------------------------------------------------------------------- +Version: 2.0.4 +Date: 28. 10. 2024 + Bugfixes: + - Fixed a startup crash when using certain very powerful fuels + - Fixed an issue where certain fuel categories would be duplicated internally + - Fixed a crash when using mods to hide content until you've discovered it (#377) + - Fixed a crash in multiplayer when closing a modal dialog (#360) + +--------------------------------------------------------------------------------------------------- +Version: 2.0.3 +Date: 27. 10. 2024 + Bugfixes: + - Fixed that manually setting a recipe productivity of 0 would not work (#364) (Thanks Dutch0903!) + - Fixed a crash when opening the districts view on a migrated save (#369) + - Fixed a crash when trying to reset a machine while the matrix solver is active (#374) + - Fixed a crash when setting a machine limit on recipes that don't calculate a machine amount (#354) + - Fixed an issue where deleting the last recipe on a subfloor would show recipes from the floor above (#368) + - Fixed a crash when opening the machine dialog for a machine that uses fluid fuels + - Fixed that entering a decimal beacon amount would crash + - Fixed a couple of migration-related crashes + - Fixed that compatible module detection would not always work properly + +--------------------------------------------------------------------------------------------------- +Version: 2.0.2 +Date: 22. 10. 2024 + Changes: + - Changed the button in the top left corner to show again by default + Bugfixes: + - Fixed a crash caused by migration (#352) + - Fixed a crash after deleting a District that had trashed factories inside it (#353) + - Fixed a crash when trying to open the product picker with Pyanodon's active + +--------------------------------------------------------------------------------------------------- +Version: 2.0.1 +Date: 21. 10. 2024 + Features: + - Updated the mod for Factorio 2.0 and the Space Age expansion. This means adoption to changes in existing systems and support for new systems, the most significant of which will be listed here. The mod will still work just fine without Space Age, with automatic adjustements to the interface. + - This release is considered [Experimental]. While it was beta-tested with a small group for a few months, the many changes will have introduced some bugs and crashes. Please let me know about anything you encounter on Github. + - Lastly: This update was a huge amount of effort over many months, and puts the mod in a much better place for the future. It's a big step forward, I hope you enjoy it, and thanks for the support so far! + - Implemented Districts, which are a collection of factories, summing up their products and ingredients. They also allow for specification of a planet, which determines compatible machines and the relevant type of emissions. + - Added the ability to set a quality level on machines, beacons, and modules + - Added support for the new machines and recipes, like the agriculture tower and intentional spoiling + - Added support for new module mechanics such as beacon profiles, machine base productivity, new productivity researches, maximum recipe productivity, and more + - Added the ability to set custom productivity boni for relevant recipes + - Added a right-click context menu to all buttons with complex interactions to make them more easily accessible (#61) + - Added indication of cyclic/catalyst ingredients in recipes, making it easier to plan their logistics (#91) + - Added support for arithmetic operations in relevant textfields (#62) + - Added stacks/timescale and rockets/timescale item rate views (#71) + - Added configuration options for item rate views in preferences (#72) + - Added a simple pocket calculator utility + Changes: + - Updated the layout of the main interface to better accommodate new functionality and improve its semantics + - Moved the global machine, fuel, beacon and module defaults from the preferences dialog into the machine/beacon dialogs (#84) + - Enabled proper configuration of default fuels for machines with multiple fuel categories (#83) + - Changed the timescale setting to be global instead of per factory, while dropping the per hour option + - Changed the utility dialog function to request missing item delivery to create a new logistic section instead of having construction bots fulfill the order + - Changed it so disabling the matrix solver doesn't remove byproduct recipes anymore, and instead disables them (#142) + - Tried addressing various crashes related to multiplayer latency (#291) + - Dropped Recipe Book support in favor of Factoriopedia, the new built-in recipe browser + - Dropped support for boiler recipes and specifying amounts by ingredients, for now + - Dropped the tutorial dialog including the example factory, as context menus are a better introduction to the feature set + - Fixed and improved various parts of the interface + - Updated the Factorio Library dependency to 0.15.0 + Bugfixes: + - Fixed various bugs and crashes, both incidentally and on purpose + +--------------------------------------------------------------------------------------------------- +Version: 1.1.79 +Date: 26. 08. 2024 + Features: + - Added support for burnt items being output as burner fuel is used up (Thanks falsedrow!) + Bugfixes: + - Fixed that beacon module defaults would reset themselves when the active mods changed + - Fixed recipes being moved above the top recipe in a subfloor (#312) (Thanks mattnotmitt!) + +--------------------------------------------------------------------------------------------------- +Version: 1.1.78 +Date: 09. 12. 2023 + Changes: + - Improved visual presentation of the matrix solver choices (#200) + - Improved preference names and descriptions slightly + Bugfixes: + - Fixed that items/s/machine didn't divide by the machine amount at all (#177) + - Fixed that the viewstates didn't update when the timescale for a factory is changed (#209) + - Fixed pasted modules not updating their effects properly (#187) + - Fixed that beacons with incompatible modules could be pasted onto machines (#156) + +--------------------------------------------------------------------------------------------------- +Version: 1.1.77 +Date: 05. 12. 2023 + Bugfixes: + - Fixed a crash with the matrix solver when there are no active recipes (#172) + - Fixed a crash when repairing specific migrated setups (#181) + - Fixed an issue where deleting a non-selected factory would still delete the currently selected one (#203) + - Potentially fixed a crashing issue when opening modal dialogs in multiplayer (#201) + +--------------------------------------------------------------------------------------------------- +Version: 1.1.76 +Date: 29. 11. 2023 + Bugfixes: + - Fixed various related crashes when deleting all the lines on a subfloor (#182) + - Fixed a migration crash that would show up in certain setups (#173) + - Fixed an embarrasing migration issue that would overwrite beacon modules with machine ones (#169) + - Fixed a crash when trying to migrate from a mod version of 1.1.64 or earlier (#190) (Thanks francoiscampbell!) + - Fixed an issue when trying to open top level byproducts and ingredients in Recipe Book (#191) + +--------------------------------------------------------------------------------------------------- +Version: 1.1.75 +Date: 22. 11. 2023 + Bugfixes: + - Fix crash when clicking on any line product (#167) + - Fix crash when opening the tutorial dialog (#176) + - Fixed a crash when trying to create a combinator containing modules (#174) + - Fixed an icon not being shown properly in languages other than English + +--------------------------------------------------------------------------------------------------- +Version: 1.1.74 +Date: 19. 11. 2023 + Bugfixes: + - Fixed an issue that could cause several different crashes after a save/load cycle + - Fixed a crash when trying to set an amount on top level or recipe ingredients + - Fixed a crash when trying to put certain items into a combinator + - Fixed a crash when deleting a factory in certain situations (potentially) + +--------------------------------------------------------------------------------------------------- +Version: 1.1.73 +Date: 17. 11. 2023 + Changes: + - Rewrote basically the entire data storage backend of the mod. This is not a feature in itself, but lays the groundwork for future ones, like undo/redo, multiplayer support, and more. What it also means is a period of lower stability, as thousands of lines of code were changed. The mod was beta-tested, so the worst issues should be fixed, but there will still be some bugs. + - Due to this rewrite, the minimum migratable version of the mod is 1.1.60 from here on out. This means any save from before 1.1.60 can not be loaded with any new versions of the mod any more. Old saves can be brought forward by first loading them with 1.1.60, saving them, then loading them with a recent version. Export strings are not affected by this, so they'll continue to work as expected. + - Replaced the matrix configuration dialog with an in-line solution, allowing you to set up recipes without the constant nagging, then doing the final configuration only once. + - Moved standard mod settings to the in-mod preferences dialog, unifying all adjustements in one place. + - Improved performance of the main interface substantially. This is due to both fixing a big bug introduced a few versions ago, and some other targeted optimizations. + - Made the utility dialog only show components for lines that have not been marked as 'done' yet (Thanks microlith57!) + - Allowed the various 'put into combinator' actions to also work on fluids + - Added the option to disable the percentage column in the main production table, and made it hidden by default + - Made the tutorial example validity detection more fine-grained + - Removed the written tutorials since they were outdated and bad + Bugfixes: + - Fixed a crash that would happen a certain time after deleting a factory + - Fixed a crash when trying to copy modules in certain configurations + - Fixed that an empty frame could stay around in the top left corner in certain situations + - Fixed that you could not enter beacon amounts between 0 and 1 + +--------------------------------------------------------------------------------------------------- +Version: 1.1.72 +Date: 27. 07. 2023 + Bugfixes: + - Potentially fixed a crash caused by a migration issue + +--------------------------------------------------------------------------------------------------- +Version: 1.1.71 +Date: 25. 07. 2023 + Changes: + - Changed the mod shortcut and mod-gui icons to a proper icon instead of the 'FP' text + Bugfixes: + - Fixed that copy-pasting a machine could make the target have the wrong recipe category + - Fixed a crash when loading a machine that doesn't have a sprite defined + - Fixed utility dialog handcrafting trying to craft the wrong items + - Fixed utility dialog handcrafting allowing the player to craft even if they didn't have the right permission + +--------------------------------------------------------------------------------------------------- +Version: 1.1.70 +Date: 12. 06. 2023 + Bugfixes: + - Fixed a crash when trying to delete a recipe in certain situations with the foldout subfloors-option enabled + +--------------------------------------------------------------------------------------------------- +Version: 1.1.69 +Date: 08. 06. 2023 + Bugfixes: + - Fixed that recipes would not correctly find the proper machine to use in some cases + +--------------------------------------------------------------------------------------------------- +Version: 1.1.68 +Date: 06. 06. 2023 + Bugfixes: + - Fixed that trying to pick a recipe for fuel would add an unrelated recipe + - Fixed a crash when shift-clicking the add subfactory button with no existing subfactories + - Fixed crash when trying to edit certain machines due to a missing migration of machine&beacon defaults + - Fixed a crash when the matrix dialog would automatically open + +--------------------------------------------------------------------------------------------------- +Version: 1.1.67 +Date: 05. 06. 2023 + Changes: + - Changed the blueprint storage to support blueprint books as well + - Moved the info/warning/error messages from the top to the bottom + - Changed locale to use the controller-compatible button names versions where possible + - Bumped the required Factorio version to 1.1.82 + Bugfixes: + - Fixed that deleting the last recipe on a folded out subfloor would not remove said subfloor + - Fixed some crashes and weirdness around storing blueprints + +--------------------------------------------------------------------------------------------------- +Version: 1.1.66 +Date: 10. 05. 2023 + Changes: + - Bumped the required Factorio version to 1.1.79 + Bugfixes: + - Fixed recipe move buttons not being enabled correctly when subfloors are folded out + - Fixed beacons with the overload mechanic being forced to 0 beacons instead of 1 + - (Hopefully) fixed a crash due to migration issues for subfactories in archive + +--------------------------------------------------------------------------------------------------- +Version: 1.1.65 +Date: 10. 05. 2023 + Features: + - Added a section to the Utility dialog where relevant blueprints can be stored on each subfactory + - Added a live-updating list of module effects to the machine and beacon dialogs + - Made it so all items of the same kind are highlighted when hovering over one in the compact dialog + - Added the console command '/fp-shrinkwrap-interface' to reduce the size of the interface until it fits the screen + Changes: + - Changed the main interface shrinkwrap to now apply when resolution or scale changes + - Extended the overload beacon detection to a couple more mods (Thanks ctgPi!) + - Changed the matrix settings dialog to show items in natural sort order (Thanks ctgPi!) + - Moved both preferences from the production header to the preferences dialog + - Removed the background dimmer and click-blocker around the main interface + - Simplified indentation visuals for folded out subfloors + - Updated the GUI in a few other minor ways + Bugfixes: + - Fixed a rare bug when repairing subfactories with invalid subfloors + +--------------------------------------------------------------------------------------------------- +Version: 1.1.64 +Date: 30. 04. 2023 + Features: + - Added the ability to shift subfactories and recipes by 5 spots + Changes: + - Limited beacon amount to 1 when using the mod with Space Exploration + - Changed exact machine limits to default to being enabled + Bugfixes: + - Fixed space science not showing up at all + +--------------------------------------------------------------------------------------------------- +Version: 1.1.63 +Date: 29. 04. 2023 + Features: + - Subfactories can now be duplicated from the archive, placing them into the main list + Changes: + - Redesigned the subfactory info box slightly + - Made the game pause button more obvious in the UI + - Removed the amount indication on machines and fuels in the machine dialog + Bugfixes: + - Fixed an issue where items were showing the wrong recipes to produce them sometimes, leading to crashes + - Fixed a bug where module effects were not updated straight away when changing a machine or beacon type + - Fixed that putting top level ingredients into the cursor would not respect the 'show floor items' preference + - Fixed that machine energy drain was generating pollution when it shouldn't be + - Fixed that a recipe's priority product was not always retained properly + - Fixed that certain Exotic Industries recipes would not be usable until researched + - Fixed duplicate machines being detected in Industrial Revolution 3 + +--------------------------------------------------------------------------------------------------- +Version: 1.1.62 +Date: 27. 04. 2023 + Bugfixes: + - Fixed that specifying amounts on top level ingredients was disabled when the matrix solver was enabled + - Fixed a crash when selecting a specific Nexelit recipe from PyMods + - Fixed that the tutorial mode toggle was always re-activated during migration + - Fixed that repairing a machine with invalid fuel would not work properly + - Fixed that a couple of strings could not be localised + +--------------------------------------------------------------------------------------------------- +Version: 1.1.61 +Date: 05. 04. 2023 + Features: + - Added hotkeys to go up a floor (Alt + Up) and to the top floor (Shift + Alt + Up) + - Implemented paste functionality for machine fuel + Changes: + - Added Ukranian translation (Thanks Met en Bouldry!) + - Changed the ingredient combinator button to use a sprite instead of text + Bugfixes: + - Fixed that absurdly high module effects were not being capped correctly at the engine limit of +32767% + - Fixed a few inconsistencies around raw ores and made them more visually distinct + - Fixed an issue where the centering of modal dialogs didn't work in all cases anymore + +--------------------------------------------------------------------------------------------------- +Version: 1.1.60 +Date: 31. 12. 2022 + Changes: + - Updated to the new flib translation library for faster translations, requiring a bump of the minimum flib version to 0.9.2 and a minimum Factorio version of 1.1.74 + - Filtered out dummy entities from the 'Ghost on Water' mod + Bugfixes: + - Fixed a crash when deleting the last subfactory in the list + - Fixed a migration crash when adding the example subfactory with Industrial Revolution loaded + - Fixed a crash when clicking the ingredient combinator button without any ingredients present + - Fixed a crash caused by a mod adding trains with a capacity of zero + - Fixed an issue where pasting a larger amount of modules onto a smaller amount of available slots would not calculate the effects correctly + - Fixed an issue where copy/pasting a machine would not respect module compatibility + - Fixed an issue where exporting ingredients to combinator would leave out items with amounts below 1 + - Fixed that lines with subfloor would not be grayed out as desired when checked off in the compact dialog + +--------------------------------------------------------------------------------------------------- +Version: 1.1.59 +Date: 19. 09. 2022 + Features: + - Added a button to unfold subfloors, meaning they will be shown on the top level without needing to go down into them + - Added a preference that, when enabled, adds a subfactory's products as icons to its name in the subfactory list + - Added the ability to paste whole lines as top level products, setting product amounts and copying the line(s) over + Changes: + - Made rounding around machine amounts more consistent + Bugfixes: + - Fixed an issue where moving subfactories to the top on subfloors did not work correctly + - Fixed a crash when changing line percentage via item amount when percentage is 0 + - Fixed an issue where pasting a module could make the total number of modules exceed the allowed amount + +--------------------------------------------------------------------------------------------------- +Version: 1.1.58 +Date: 03. 09. 2022 + Bugfixes: + - Fixed a bug where some line products would be shown with an amount of -1 + - Fixed a crash when trying to paste a line with subfloor as a top level product + - Fixed a rare crash when cycling through production views + +--------------------------------------------------------------------------------------------------- +Version: 1.1.57 +Date: 30. 08. 2022 + Changes: + - Added custom support for Space Exploration Arcosphere recipes + - Added a hotkey to cycle backwards through the different production views (Shift + Tab) + - Changed the hotkey that toggles between the main and compact dialogs to open the compact view when used while no dialog is open, instead of silently switching between them in the background + - Removed the floor up hotkey + Bugfixes: + - Fixed a crash with the subfactory to combinator functionality when there are only fluid ingredients + +--------------------------------------------------------------------------------------------------- +Version: 1.1.56 +Date: 29. 08. 2022 + Bugfixes: + - Fixed a crash when switching to the items/s/machine view in some cases + +--------------------------------------------------------------------------------------------------- +Version: 1.1.55 +Date: 28. 08. 2022 + Bugfixes: + - Fixed a crash when trying to convert fluid ingredients to a combinator + - Fixed a rare bug where total subfactory ingredients were not being summed up correctly + +--------------------------------------------------------------------------------------------------- +Version: 1.1.54 +Date: 26. 08. 2022 + Bugfixes: + - Fixed a crash when using the ingredient satisfaction preference + +--------------------------------------------------------------------------------------------------- +Version: 1.1.53 +Date: 26. 08. 2022 + Bugfixes: + - Fixed a crash related to removing the empty mod-gui frame in some situations + +--------------------------------------------------------------------------------------------------- +Version: 1.1.52 +Date: 26. 08. 2022 + Features: + - Added a button to show floor item totals when on subfloors instead of subfactory totals + - Added a button to export all subfactory ingredients to a blueprint containing constant combinators + - Added the ability to put items into the cursor as a constant combinator by alt-clicking them + - Added rocket part recipes, which are useful in certain modded situations + - Added Recipe Book shortcuts to the compact subfactory dialog + Changes: + - Re-added the '+' button to machines which can accept modules but currently don't have any + - Recipes that don't produce anything now show their products and ingredients with 0 amounts instead of not at all + - The mod now remembers which category was previously selected when re-opening the item picker dialog + - The mod now cleans up the empty mod-gui frame when disabling the FP button would leave it empty + Bugfixes: + - Removed info icons from mod settings names as the game now generates them on its own + - Fixed that tutorial tooltips would still show in some places even if disabled + - Fixed a crash that could happen when duplicating a subfactory that had previously had a product pasted into it + - Fixed a bug where a disabled slider to set module amounts would sometimes not actually be disabled + +--------------------------------------------------------------------------------------------------- +Version: 1.1.51 +Date: 16. 07. 2022 + Changes: + - Changed the key combo for putting machines/beacons into the cursor in the main interface to Alt+Left-click to avoid accidental activation. issues. + - Marking recipes as done in the compact subfactory view now grays them out + - Duplicating a subfactory now inserts it right below the cloned subfactory instead of at the end + - Improved performance of opening dialogs (Thanks curiosity!) + - Improved the wording around the utility dialog component buttons + Bugfixes: + - Fixed an issue where pasting onto a module could make it exceed the total module capacity of the machine + - Fixed that the machine dialog would show the limit options even when the matrix solver was active + - Fixed a crash when loading certain old saves + - Fixed a very rare issue where the game could desync in multiplayer + +--------------------------------------------------------------------------------------------------- +Version: 1.1.50 +Date: 05. 06. 2022 + Changes: + - Added the console command '/fp-restart-translation' to restart translation in case it hangs + - Changed it so the selected subfactory is retained during migrations if possible + Bugfixes: + - Fixed a few issues and crashes related to editing modules on machines and beacons + - Fixed a couple of migration issues when trying to load certain old saves + - Fixed a crash when loading a save where a now-invalid subfactory was selected + - Fixed a crash when trying to put machines into the cursor that can't be blueprinted + - Fixed a missing locale string + +--------------------------------------------------------------------------------------------------- +Version: 1.1.49 +Date: 27. 05. 2022 + Bugfixes: + - Changes a keyboard shortcut that was interferring with game functionality + - Fixed fuel not being shown in the compact view + - Fixed a few minor UI issues related to the production table + - Fixed a minor rounding issue with compact view machines + - Fixed an issue where disabling lines on subfloors wouldn't work + - Fixed an issue where the module slider wouldn't be properly draggable + +--------------------------------------------------------------------------------------------------- +Version: 1.1.48 +Date: 24. 05. 2022 + Changes: + - Re-added the 'put into cursor' action to the main interface (bound to a simple left click on machines and beacons) + Bugfixes: + - Fixed a migration issue under certain circumstances + +--------------------------------------------------------------------------------------------------- +Version: 1.1.47 +Date: 23. 05. 2022 + Changes: + - The preference to hide crating recipes now also works for the 'Deadlock's Crating Machine' mod + +--------------------------------------------------------------------------------------------------- +Version: 1.1.46 +Date: 22. 05. 2022 + Bugfixes: + - Fixed a crash when opening the main interface in some circumstances + +--------------------------------------------------------------------------------------------------- +Version: 1.1.45 +Date: 22. 05. 2022 + Bugfixes: + - Fixed a couple of locale issues + - Re-fixed a crash related to item search rate limiting + +--------------------------------------------------------------------------------------------------- +Version: 1.1.44 +Date: 21. 05. 2022 + Changes: + - Updated Korean localisation (Thanks x2605!) + - Added partial Portuguese localisation (Thanks ctgPi!) + +--------------------------------------------------------------------------------------------------- +Version: 1.1.43 +Date: 21. 05. 2022 + Bugfixes: + - Fixed a crash when loading a previous save in very specific circumstances + - Fixed a crash related to item search rate limiting + - Fixed a crash when pasting a machine + - Fixed a crash when right-clicking the subfactory dialog rich text buttons + - Fixed that machines could not always be pasted even when they should have been allowed to + +--------------------------------------------------------------------------------------------------- +Version: 1.1.42 +Date: 21. 05. 2022 + Features: + - Added a compact view of the interface that's useful when actually building your factory after the planning phase + - Added copy/paste functionality, allowing you to copy machines, beacons, modules, recipe lines and items + - Implemented localised search for items and recipes, meaning search will find results in the language the game is configured to + - Added rich text support to subfactory names, so they are not restricted to a single icon anymore if desired + - Added dedicated buttons to move subfactories and recipes up and down, making the interaction more discoverable and easier to use + - Added a setting for those that want the 'add subfactory'-button to go to the product picker straight away + - Added support for barrelled fluids to the belts/lanes production view (Thanks ZorbaTHut!) + Changes: + - Reordered the subfactory list action buttons to make more sense by grouping the archive-related ones + - Combined the various machine dialogs (entity picker, limits, modules, fuel) into one + - Unified the interactions and appearance of modules for machines and beacons + - Moved the utility dialog button to the production toolbar + - Streamlined the modifier-clicks for various actions to be more consistent across the mod + - Made the 'reset machine' action also set modules and beacons to their configured defaults if possible + - Pressing 'E' with an open dialog now tries to confirm instead of closing, mirroring vanilla behavior + - Adding a subfactory by selecting a product now sets the name to both the icon and name of that product + - Improved consistency and visual appeal of various tooltips + - Removed mod interactions with FNEI and WIIRUF, meaning only Recipe Book remains supported (with deeper integration than before) + - Removed the dedicated 'toggle recipe'-button and integrated its core functionality into the recipe button itself + - Added a ko-fi patronage 'link' to the preferences pane + - Updated to flib 0.9.2 + Bugfixes: + - Fixed that the first recipe on subfloors could be moved down in certain circumstances + - Fixed that shift-click adding a subfactory and cancelling the picker dialog would create an empty subfactory + - Fixed that deleting a subfactory through the 'edit subfactory'-dialog would not put it into the archive + +--------------------------------------------------------------------------------------------------- +Version: 1.1.41 +Date: 14. 05. 2022 + Bugfixes: + - Fixed a crash when opening the preferences dialog with certain sets of mods + +--------------------------------------------------------------------------------------------------- +Version: 1.1.40 +Date: 13. 05. 2022 + Bugfixes: + - Improve compatibility with the 248k mod + - Fixed an issue where the main interface could become uninteractable in some situations + +--------------------------------------------------------------------------------------------------- +Version: 1.1.39 +Date: 08. 05. 2022 + Bugfixes: + - Fixed a migration issue with Satisfactorio + +--------------------------------------------------------------------------------------------------- +Version: 1.1.38 +Date: 14. 04. 2022 + Changes: + - Minor UI improvements, such as a better explanation of the matrix solver + +--------------------------------------------------------------------------------------------------- +Version: 1.1.37 +Date: 23. 03. 2022 + Bugfixes: + - Actually actually fixed the stupid scaling issue, maybe + +--------------------------------------------------------------------------------------------------- +Version: 1.1.36 +Date: 21. 03. 2022 + Bugfixes: + - Actually fixed the image scaling issue with the matrix solver, as the previous fix resulted in a crash instead + +--------------------------------------------------------------------------------------------------- +Version: 1.1.35 +Date: 21. 03. 2022 + Bugfixes: + - Potentially fixed an image scaling issue with the matrix solver dialog + +--------------------------------------------------------------------------------------------------- +Version: 1.1.34 +Date: 16. 02. 2022 + Bugfixes: + - Fixed some duplication with the Chinese localisation, which caused a crash on startup + +--------------------------------------------------------------------------------------------------- +Version: 1.1.33 +Date: 14. 02. 2022 + Bugfixes: + - Fixed a crash on load with Nullius. Sorry about that! + +--------------------------------------------------------------------------------------------------- +Version: 1.1.32 +Date: 13. 02. 2022 + Changes: + - Refined the machine detection algorithm to pick up certain machines that should have been picked up + Bugfixes: + - Potentially fixed a crash when trying to put a machine into the cursor from Factory Planner + +--------------------------------------------------------------------------------------------------- +Version: 1.1.31 +Date: 22. 12. 2021 + Bugfixes: + - Fixed an issue where the mod would sometimes offer the incorrect machines for a recipe + - Fixed that the mod would create a small box in the top left corner of the screen in certain multiplayer situations + +--------------------------------------------------------------------------------------------------- +Version: 1.1.30 +Date: 09. 12. 2021 + Features: + - Added the ability to specify an exact amount for top level ingredients, which adjusts the configured products accordingly. This is a band-aid way of enabling calculations by input rather than output. (Thanks curiosity!) + Changes: + - Improved matrix solver algorithm to provide more stable results (Thanks scottmsul!) + +--------------------------------------------------------------------------------------------------- +Version: 1.1.29 +Date: 04. 11. 2021 + Bugfixes: + - Fixed that the priority product selection would be dropped when disabling a recipe line + - Fixed that recipes would show products with amount equal to 0 when they have a subfloor + - Fixed that the utility dialog combinator blueprint was limited to two machines per combinator + - Fixed that the buttons to check off line recipes would show a tooltip when it shouldn't + +--------------------------------------------------------------------------------------------------- +Version: 1.1.28 +Date: 29. 10. 2021 + Bugfixes: + - Fixed a crash when trying to pipette a beacon from the Factory Planner interface + +--------------------------------------------------------------------------------------------------- +Version: 1.1.27 +Date: 24. 10. 2021 + Features: + - Added the ability for beacons to have more than one type of module at once (huge thanks to curiosity!) + Bugfixes: + - Fixed a crash related to ingredient satisfaction and disabled recipes + - Fixed a crash when trying to add beacons while having the Satisfactorio mod active + - Fixed that the item picker would offer disabled/hidden belts + - Fixed a crash when opening the main interface while using the comfy scenario + - Potentially fixed a crash on startup in certain modded situations + +--------------------------------------------------------------------------------------------------- +Version: 1.1.26 +Date: 17. 10. 2021 + Changes: + - Improved performance of the matrix solver determining valid free items (Thanks RSBat!) + Bugfixes: + - Fixed a persistent crash on save load with the Nullius mod, sorry about that one + - Fixed that certain machines, fuels, belts and train wagons that should be hidden were shown + - Fixed that the player character would show up as a valid mining machine + - Fixed a crash when using the utility dialog blueprint functionality with a large subfactory + - Fixed that the auto-pause logic would touch the pause state in multiplayer when it really shouldn't + +--------------------------------------------------------------------------------------------------- +Version: 1.1.25 +Date: 25. 08. 2021 + Changes: + - Added better support for the Satisfactorio mod (Thanks PFQNiet!) + Bugfixes: + - Fixed a crash with temporarily archived subfactories being deleted + +--------------------------------------------------------------------------------------------------- +Version: 1.1.24 +Date: 24. 07. 2021 + Changes: + - Updated the internal detection of recycling and barreling/stacking recipes to the current set of popular mods + Bugfixes: + - Fixed a crash with the utility dialog + - Fixed a bug that prevent irrelevant free item choices being filtered out correctly + +--------------------------------------------------------------------------------------------------- +Version: 1.1.23 +Date: 11. 07. 2021 + Features: + - Added a button to the utility dialog to export the missing machines and modules as a series of constant combinators that can be hooked up to a requester chest + Changes: + - The mod now filters out crafting categories in the preferences dialog that don't have any recipes using them (removes the 'basic-crafting' category from vanilla maps) + Bugfixes: + - Fixed several matrix-solver related crashes (hopefully) that were introduced in the last version + +--------------------------------------------------------------------------------------------------- +Version: 1.1.22 +Date: 10. 07. 2021 + Changes: + - The mod now makes sure that the main interface fits onto the player's screen when first creating or joining a game + - The margin for showing items with very small amounts now scales with the subfactory timescale + - Improved performance when adding a product with only one recipe a bit + Bugfixes: + - Fixed a crash and some other issues related to the background dimming when paused + - Fixed an issue where the modal interface dimmer would not layer correctly when loading a save in some cases + - Fixed a missing tooltip string on the dialog submit button + +--------------------------------------------------------------------------------------------------- +Version: 1.1.21 +Date: 04. 07. 2021 + Features: + - Implemented the ability to add recipes from top level ingredients + Changes: + - Changed certain modal dialogs to use an 'X-to-close' button in the top right corner instead of a 'Back' button in the bottom left corner + - The name and icon of the cargo and fluid wagons are now shown on the 'Wagons/m' view state button tooltip + - Line percentages now default to 100 instead of 0 when left blank + Bugfixes: + - Fixed an issue where Request Depots from Klonan's Transport Drones could not be selected a product (Thanks MCP!) + - Fixed that the Deep Storage Unit mod's internal recipes were not being hidden + - Fixed an issue where the number of subfactories in the archive could have been reported incorrectly + - Fixed that the pause mode would not work correctly while using the in-game editor + - Fixed a rare crash when searching recipes or items + - Fixed a few minor graphical inconsistencies + +--------------------------------------------------------------------------------------------------- +Version: 1.1.20 +Date: 11. 04. 2021 + Bugfixes: + - Fixed frequent desync on loading saves. I'm truly sorry about me taking so long to fix this. + - Fixed that the dimming overlay would be stuck in front of the main window, blocking any interaction with it + - Fixed that the background dimmer would be incorrectly sized on display scales other than 100% + - Fixed that disabling the mod-gui button in the top left corner could leave behind an empty border if no other buttons are up there + +--------------------------------------------------------------------------------------------------- +Version: 1.1.19 +Date: 10. 02. 2021 + Changes: + - Made it so the 'to the top' button can be used starting at level 2 floors, instead of level 3 + Bugfixes: + - Fixed a couple of issues where the mod would not properly select a machine for a recipe in some situations + - Fixed an issue where some modal dialogs might not work correctly after reloading a save + - Fixed that the last valid modset data would not be retained when importing an invalid subfactory + - Fixed that alt-clicking a default machine in preferences could crash in certain cases + - Fixed that trying to handcraft a module or beacon in the utilities dialog would crash + - Fixed a crash when attempting to craft using the utility dialog while not having a character associated to yourself + - Fixed that attempting to handcraft in the utility dialog would sometimes incorrectly show a warning about there not being enough resources while simultaneously queueing some crafting + - Fixed that the background dimming when the game is paused would duplicate itself when alt-tabbing or resizing the window + +--------------------------------------------------------------------------------------------------- +Version: 1.1.18 +Date: 01. 02. 2021 + Changes: + - Moved the matrix solver toggle to the subfactory info box + - Added proper error messages to handcrafting machines and modules in the utility dialog + - Reworded some of the mod setting names as they were awful + Bugfixes: + - Fixed a crash when typing into or confirming the export dialog textfield + - Fixed a crash when using the utility dialog to request items while having no character associated to oneself + - Potentially fixed a crash related to the matrix solver and fuel + +--------------------------------------------------------------------------------------------------- +Version: 1.1.17 +Date: 22. 01. 2021 + Features: + - You can now queue handcrafting machines and modules from the utility dialog + Changes: + - Made the utility dialog more consistent when checking whether logistic robotics are researched in modded situations + Bugfixes: + - Fixed a crash when changing beacon types in the beacon dialog + +--------------------------------------------------------------------------------------------------- +Version: 1.1.16 +Date: 20. 01. 2021 + Changes: + - Swapped the import/export button around and made their tooltips clearer + Bugfixes: + - Fixed a crash with fuel migration + - Potentially fixed a crash with certain mods that add custom beacons + - Potentially fixed a crash when deleting subfactories on a multiplayer server with high latency + +--------------------------------------------------------------------------------------------------- +Version: 1.1.15 +Date: 04. 01. 2021 + Bugfixes: + - Fixed an issue where the matrix solver wouldn't always show its dialog when it should have + - Fixed that removing the last line in a subfactory with the matrix solver active would leave some top level products behind + +--------------------------------------------------------------------------------------------------- +Version: 1.1.14 +Date: 03. 01. 2021 + Features: + - Added an optional production column that allows you to check off a recipe line after you're done with it, in the style of a to-do list (Thanks Fiona!) + - Added an additional production view 'Wagons/timescale', allowing you to see how many cargo or fluid wagons are being filled up by your production (Thanks talshorer!) + Changes: + - Fixed various inconsistencies with the UI (Thanks Raiguard!) + - Updated the Russian translation (Thanks METAKOT!) + - Updated the Chinese translation (Thanks Ph.X!) + - Changed the hotkey to reset a machine from alt-right-click to control-right-click, as this is more consistent with the control-right-click action deleting things + - Renamed the 'hard limit' option on machines to 'force limit' + Bugfixes: + - Fixed a crash when deleting subfactories in your archive + - Fixed that the fuel picker would sometimes show more than one kind of fuel as the currently selected one + +--------------------------------------------------------------------------------------------------- +Version: 1.1.13 +Date: 24. 12. 2020 + Bugfixes: + - Fixed that the subfactory name would not be shown if there was both an icon and a name + +--------------------------------------------------------------------------------------------------- +Version: 1.1.12 +Date: 23. 12. 2020 + Features: + - Subfactories you delete will now remain in the archive for 15 minutes in case you change your mind about deleting them + - Added a tooltip to invalid subfactories listing the modset changes that could have lead to it becoming invalid + - You can now alt-click the recipe line toggle switch (which is an optional column in preferences) to toggle all the other recipe lines, meaning all the ones you did not click on (Thanks METAKOT!) + Bugfixes: + - Fixed that when in the items/s/machine-view, the values shown were totally wrong + - Fixed that you could enable/disable the matrix solver while a subfactory is in archive + - Fixed that the indicated timescale-view button would not update correctly when switching between subfactories + - Fixed that setting the subfactory icon to '?' would crash (you can't use the question mark as an icon now) + +--------------------------------------------------------------------------------------------------- +Version: 1.1.11 +Date: 19. 12. 2020 + Bugfixes: + - Fixed machine count totals on lines with subfloors with the matrix solver + - Fixed an issue with the matrix solver determining the number of rows and columns in its matrix + +--------------------------------------------------------------------------------------------------- +Version: 1.1.10 +Date: 17. 12. 2020 + Changes: + - The matrix solver now tells you which recipes it has detected as linearly dependant, letting you know the ones that cause it to not be able to solve + Bugfixes: + - Fixed an issue with the matrix solver that would lead to an infinite loop. Whew, there's a first time for everything! + - Fixed that alt-adding a subfactory would crash if you had no other subfactories + - Fixed that you couldn't view subfloors for subfactories that are in the archive + - Fixed a crash when changing the window size while a modal dialog is open + - Tried fixing a crash with the matrix solver in rare circumstances + +--------------------------------------------------------------------------------------------------- +Version: 1.1.9 +Date: 15. 12. 2020 + Features: + - Alt-clicking the 'add subfactory'-button now takes you directly to the product picker + - Middle-clicking the grab handle on any dialog now re-centers it + Changes: + - The matrix solver now allows you to select products are free items as well, which comes in handy when you subfactory has very few recipes + - Added a message when deactivating the matrix solver informing you that byproduct recipes have been deleted + Bugfixes: + - Fixed that the matrix solver dialog would not pop up sometimes after adding a recipe + +--------------------------------------------------------------------------------------------------- +Version: 1.1.8 +Date: 15. 12. 2020 + Changes: + - Removed a couple of mistakes with the tutorial writing; It's super old and due for a rewrite anyways + - Renamed 'Energy' to 'Power' where appropriate + Bugfixes: + - Fixed that opening another mod's interface while FP is open could crash + - Fixed that requesting machines and modules would crash in certain modded situations + - Fixed the beacon selector not working properly when the game is paused + +--------------------------------------------------------------------------------------------------- +Version: 1.1.7 +Date: 13. 12. 2020 + Bugfixes: + - Fixed a crash when opening the interface with no subfactories that was introduced in the last version + +--------------------------------------------------------------------------------------------------- +Version: 1.1.6 +Date: 13. 12. 2020 + Changes: + - The message that your current subfactory is linearly dependant is now shown more consistently + Bugfixes: + - Fixed a frequent crash when manipulating a subfactory that has linearly dependant recipes + - Fixed that the example subfactory was improperly cached, leading to several issues all over the UI + - Fixed that hitting certain Factory Planer hotkeys before opening the UI for the first time would crash + - Fixed that the matrix solver calculations would be incorrect when productivity modules were involved + - Fixed a crash on migration + +--------------------------------------------------------------------------------------------------- +Version: 1.1.5 +Date: 11. 12. 2020 + Features: + - Redesigned the main interface. This means that some things are in different spots than they used to be, and some niche features got lost along the way. I'm sorry about those few that went missing, but I needed to get this done in a reasonable amount of time. If there is anything specific you're dearly missing, please let me know on the mod portal or on Discord. + - Added a matrix solver. It can be individually enabled for every subfactory and helps to automatically balance out certain advanced recipe setups like oil processing and cracking, with support for voiding byproducts. It can also solve looping recipe chains by asking you for some input from your side. This whole feature is a beta still, so if you run into any problems, please let us know. Huge thanks to scottmsul for taking the charge in implementing this one! + - Added an optional production column to quickly disable certain recipes by checkbox + - Added a search bar to the recipe picker + - You can now alt-click any machine or beacon in your production line to put it into your cursor as a blueprint for easy stamping + - You can now also alt-click a machine when changing it to implicitly set it to the preferred one for its category + Changes: + - The calculation model now considers the base energy drain that most electrical machines have + - The calculation model now also takes the different launch sequence times for modded rocket silos into account (Thanks curiosity!) + - Setting recipe item amounts directly now behaves more intuitively + - Hitting the 'E' keyboard shortcut now confirms the dialog that is open if possible, mirroring the vanilla behavior introduced with 1.1 + - The tutorial subfactory can now be imported in a wider range of modded situations (as long as it is compatible) + - Removed the 'automatic cleanup' functionality that would delete lines that became useless after you removed a top level product. This functionality had been broken in several ways that are hard to fix, so I decided it was not worth keeping around. + - Increased the maximum length of subfactory names to 256 characters, while accomodating for longer names in the UI + - Added some dimming to the interface to indicate that a modal dialog is open and/or the game is paused while the main interface is open + - Various minor UI improvements unrelated to the main interface redesign + - Removed migrations for versions before 0.18 since the game doesn't load those saves anymore + - Updated to a newer version of flib (v0.6.0) + Bugfixes: + - Fixed that changing the limit on a machine would reset the 'hard limit'-switch + - Finally found a workaround for the issue that clicks would go 'through' the main dialog if a modal one was open + - Fixed the beacon tooltip in preferences displaying incorrect energy consumption values + - Fixed that the tooltip on mining machines would not include mining productivity bonuses + - Fixed that opening another GUI while in beacon selection mode would leave the modal dialog stuck open + +--------------------------------------------------------------------------------------------------- +Version: 1.1.4 +Date: 01. 12. 2020 + Bugfixes: + - Fixed a crash in certain modded situations + - Fixed a horrible styling issue that made the pro tips boxes not align properly + +--------------------------------------------------------------------------------------------------- +Version: 1.1.3 +Date: 27. 11. 2020 + Bugfixes: + - Fixed a crash when trying to submit the beacon dialog + +--------------------------------------------------------------------------------------------------- +Version: 1.1.2 +Date: 26. 11. 2020 + Bugfixes: + - Fixed various crashes caused by textbox behavior having changed with 1.1 + +--------------------------------------------------------------------------------------------------- +Version: 1.1.1 +Date: 23. 11. 2020 + Changes: + - Updated for Factorio 1.1 + +--------------------------------------------------------------------------------------------------- +Version: 1.0.13 +Date: 16. 11. 2020 + Bugfixes: + - Fixed a crash with Space Exploration + +--------------------------------------------------------------------------------------------------- +Version: 1.0.12 +Date: 16. 11. 2020 + Bugfixes: + - Added a couple missing locale strings + - Fixed an issue where the beacon total would not be shown when editing a beacon + - Fixed an issue where the total machine count for a subfloor would be incorrect if that subfloor had itself a subfloor + +--------------------------------------------------------------------------------------------------- +Version: 1.0.11 +Date: 09. 10. 2020 + Bugfixes: + - Fixed a crash when trying to load a map with the 'Nauvis Melange' mod + +--------------------------------------------------------------------------------------------------- +Version: 1.0.10 +Date: 05. 10. 2020 + Features: + - Added a secondary default module that will try to insert itself if the primary one is not compatible with the new machine/recipe (Thanks scottmsul!) + +--------------------------------------------------------------------------------------------------- +Version: 1.0.9 +Date: 01. 10. 2020 + Bugfixes: + - Fixed a crash related to the beacon dialog in certain modded situations + +--------------------------------------------------------------------------------------------------- +Version: 1.0.8 +Date: 28. 09. 2020 + Bugfixes: + - Fixed that the preference for the default beacon amount would not be saved + +--------------------------------------------------------------------------------------------------- +Version: 1.0.7 +Date: 16. 09. 2020 + Bugfixes: + - Hotfixed a crash on load with the newest version of Space Exploration + +--------------------------------------------------------------------------------------------------- +Version: 1.0.6 +Date: 07. 09. 2020 + Bugfixes: + - Fixed some rare crashes that could occur after saving/loading while a modal dialog is open + - Fixed that having signal-type icons on your subfactories could lead to crashes + - Fixed that you couldn't remove subfactory icons + - Fixed that heavily beaconed machine amounts were not calculated accurately (Thanks talshorer!) + +--------------------------------------------------------------------------------------------------- +Version: 1.0.5 +Date: 27. 08. 2020 + Bugfixes: + - Fixed a crash when attempting to change fuels in certain modded situations + +--------------------------------------------------------------------------------------------------- +Version: 1.0.4 +Date: 17. 08. 2020 + Features: + - Added a 'request items'-button to the utility dialog that requests all the items needed to build the current subfactory/floor. The buttons indicating what is in your inventory are now also updated dynamically when your inventory content changes. + Changes: + - Re-added the keyboard shortcut to confirm modal dialogs by pressing 'Enter' + - Properly enabled rate limiting again to avoid duplicate GUI actions + - Improved performance when opening the preferences dialog + Bugfixes: + - Fixed that the product picker would sometimes show a scrollbar unnecessarily + +--------------------------------------------------------------------------------------------------- +Version: 1.0.3 +Date: 15. 08. 2020 + Bugfixes: + - Fixed a potential crash on-load when playing on older maps + +--------------------------------------------------------------------------------------------------- +Version: 1.0.2 +Date: 15. 08. 2020 + Changes: + - Adjusted styling in some places + - Updated the screenshots to reflect the recent redesigns + Bugfixes: + - Fixed a crash when trying to edit a subfactory without an icon + +--------------------------------------------------------------------------------------------------- +Version: 1.0.1 +Date: 14. 08. 2020 + Changes: + - Updated for Factorio 1.0; Happy release day everyone!🥳 It's been a great time working on this mod and interacting with all of you. There's a write-up about these past many months in the mod portal discussions and on Discord, if you're interested in reading about some numbers and feelings. + +--------------------------------------------------------------------------------------------------- +Version: 0.18.52 +Date: 13. 08. 2020 + Changes: + - Redesigned the subfactory, module, beacon and product picker dialogs + - Made it so recipes for fuels can also be inserted directly below by shift-left-clicking them + - Added some info about why a dialog can not be submitted + - Adjusted styling in numerous ways + Bugfixes: + - Fixed the issue that opening a vanilla interface while a Factory Planner modal dialog is open would not close the main FP interface window + - Properly fixed scrolling in the recipe picker + - Fixed recipe picker category icons leaving their designated spot sometimes + +--------------------------------------------------------------------------------------------------- +Version: 0.18.51 +Date: 10. 08. 2020 + Bugfixes: + - Fixed that the recipe picker did not have a scroll pane when necessary. Its height is still screwy, it'll be fixed with the next big update. + +--------------------------------------------------------------------------------------------------- +Version: 0.18.50 +Date: 04. 08. 2020 + Changes: + - Redesigned the recipe picker dialog + Bugfixes: + - Fixed that setting a limit on a machine would not work properly + - Fixed a crash when selecting certain fuels + +--------------------------------------------------------------------------------------------------- +Version: 0.18.49 +Date: 03. 08. 2020 + Features: + - Added the ability to add a recipe right below the ingredient that it should produce by shift-left-clicking the ingredient + Changes: + - Removed the ability to manually order top level ingredients and byproducts, the same goes for any recipe line items. This was done for two reasons: Firstly, it was not very useful as they shifted around all the time anyways when new recipes are added or removed. Secondly, this frees up some key combinations for more useful actions, like the one above. + - Redesigned the preferences dialog + Bugfixes: + - Fixed that the dialog to change machine would sometimes highlight the wrong machine as being selected + - Fixed that downgrading a machine wouldn't always show an error message when unsuccessful + - Fixed that modded rocket silos were not picked up on correctly + +--------------------------------------------------------------------------------------------------- +Version: 0.18.48 +Date: 02. 08. 2020 + Changes: + - Redesigned the utility dialog + Bugfixes: + - Fixed a crash on migration that happened under very specific circumstances + +--------------------------------------------------------------------------------------------------- +Version: 0.18.47 +Date: 01. 08. 2020 + Changes: + - Factory Planner now has Factorio Library (flib) as a dependency, to help with the new styling + - Redesigned the tutorial dialog to be in line with the post-0.17 styling sensibilities + - Also gave the options- and chooser-dialog types a do-over + +--------------------------------------------------------------------------------------------------- +Version: 0.18.46 +Date: 29. 07. 2020 + Bugfixes: + - Fixed a migration crash + - Fixed an issue when importing into your archive + - Fixed a crash where you could submit the import dialog without having imported a string + +--------------------------------------------------------------------------------------------------- +Version: 0.18.45 +Date: 29. 07. 2020 + Changes: + - Re-implement the ingredient satisfaction algorithm, solving some weird edge cases and doubling performance + - The importer now selects all subfactories for import by default, instead of selecting none + - Added some warning messages when trying to open items/recipes in FNEI/WIIRUF/RB fails + - Changed the unit that pollution is displayed in from P/second to P/minute + - Changed how deleting subfactories and recipe lines work, removing the confirmation step + - Changing machines on recipes now also opens up a dialog instead of presenting the choices in-line; An alternative to this is shift/ctrl-clicking the machine to up/downgrade it + - Removed the 'Indicate rounding' mod setting + - Removed the actionbar delete-button; ctrl-right-click or the delete button when editing the subfactory still work + Bugfixes: + - Fixed an issue where exporting subfactories would not preserve line order properly + - Fixed some minor graphical issues with the new import/export dialogs + - Fixed that pollution calculation was off by a factor of 60 + - Fixed an issue where a recipe line wasn't properly deleted under certain circumstances + +--------------------------------------------------------------------------------------------------- +Version: 0.18.44 +Date: 27. 07. 2020 + Features: + - This update (finally) brings with it the ability to import/export your subfactories! It works similarly to exporting blueprints, which means that it gives you a 'factory exchange string' that you can share with others or use yourself to import your plans in a different save. + +--------------------------------------------------------------------------------------------------- +Version: 0.18.43 +Date: 26. 07. 2020 + Bugfixes: + - Implement potential fix for a crash when duplicating certain subfactories + - Fixed that moving list items to the bottom/top did not work correctly + +--------------------------------------------------------------------------------------------------- +Version: 0.18.42 +Date: 21. 07. 2020 + Features: + - [Warning: Experimental! This update contains some more data backend changes. They should not cause any issues though, as they are pretty minimal and un-invasive] + - Added a button to exactly duplicate existing subfactories. This comes as a nice side-benefit of implementing import/export of subfactories, and as it is ready, I decided to release it now. + Bugfixes: + - Fixed some problems with migration + +--------------------------------------------------------------------------------------------------- +Version: 0.18.41 +Date: 20. 07. 2020 + Bugfixes: + - Fixed an issue where fuels were not being migrated correctly + +--------------------------------------------------------------------------------------------------- +Version: 0.18.40 +Date: 20. 07. 2020 + Changes: + - Updated to the new version of the Recipe Book remote interface (now requires Recipe Book v2.0.1 and up) + Bugfixes: + - Fixed a crash on startup when adding Factory Planner to an existing save + +--------------------------------------------------------------------------------------------------- +Version: 0.18.39 +Date: 20. 07. 2020 + Bugfixes: + - Fixed a migration issue that would cause the save to crash when loading. Sorry about that one. + - Fixed a crash when switching to the floor-view in the utilities dialog under certain circumstances + - Fixed an issue where the subfloor machine total was not added up correctly + - Fixed a crash when using the beacon-selector + +--------------------------------------------------------------------------------------------------- +Version: 0.18.38 +Date: 17. 07. 2020 + Features: + - [Warning: Experimental! This update contains a complete rework of the data structures and migrations that this mods needs. You are somewhat likely to experience a crash or two until I can fix all of them. If you run into any of them, please let me know; I'll try to address them as quickly as possible. Including a save file with your report would help a lot, so please include one if you can.] + - The goal of these reworks was to get everything in shape to add new features more quickly and easily in the future. Chief among those is the import and export of subfactories into other saves. + - Other than those reworks, I changed how recipes with subfloors are handled. The lines that have subfloors attached to them now don't show the machine, modules and beacons from the first line of their subfloor anymore. Instead, they only show the total amount of machines needed on that subfloor, and no modules or beacons. I believe that this is more intuitive for new users especially. It also vastly simplifies the code, leading to less surface for bugs and better performance. + Changes: + - Lifted some restrictions on the name that your subfactory can have. I hope nothing breaks. + Bugfixes: + - Fixed an issue causing save-file sizes to be inflated unnecessarily + - Fixed a problem where fluid-powered machines would still show as consuming electrical energy in some cases + - Fixed a crash on startup that would happen with a forthcoming version of Recipe Book + +--------------------------------------------------------------------------------------------------- +Version: 0.18.37 +Date: 09. 07. 2020 + Changes: + - Improved loading performance a bit + - Made sure that in certain very rare cases, the appropriate recipes would be shown (most notably for omnicompression) + - Added the console command '/fp-reset-prototypes' to re-run the prototypes generation code. This should not be needed for any situation. + +--------------------------------------------------------------------------------------------------- +Version: 0.18.36 +Date: 04. 07. 2020 + Features: + - Added keyboard combination for moving subfactories and recipe lines to the very start/end of their list + Changes: + - Made it so the content of textboxes in the module/beacon picker get selected automatically under certain circumstances + Bugfixes: + - Fixed that setting exact item amounts on a recipe would not calculate the percentage correctly in some cases + - Fixed that negative fluid temperatures would lead to a crash when starting/loading a map + - Fixed productivity applying to the rocket silo satellite even though it shouldn't + - Fixed that certain modded recipes would not show up in the recipe picker + - Fixed that some recipes showed up even though the research for them was disabled + +--------------------------------------------------------------------------------------------------- +Version: 0.18.35 +Date: 18. 06. 2020 + Bugfixes: + - Fixed a bug where setting recipe percentages by defining item amounts would not apply correctly to lines with subfloors + +--------------------------------------------------------------------------------------------------- +Version: 0.18.34 +Date: 18. 06. 2020 + Changes: + - Made the item-percentage-utility textfield wider + - This update includes a lot of behind-the-scenes changes, so there might be a crash or two that I haven't found yet + Bugfixes: + - Fixed a crash when trying to set an empty product/ingredient amount on a recipe + - Added a missing locale key for the new pause-on-interface keyboard shortcut + +--------------------------------------------------------------------------------------------------- +Version: 0.18.33 +Date: 16. 06. 2020 + Features: + - Added the ability to set recipe product/byproduct/ingredient-amounts to specific values. You can do this by right-clicking any of them on a specific recipe line and entering an amount. The percentage of that line will then automatically be adjusted to produce that amount exactly. + Changes: + - The mod-setting for pausing the game when the main interface is open is now a button in the top right of it + - The numbers shown on item/machine/etc-buttons are now rounded somewhat correctly, instead of being truncated + Bugfixes: + - Fixed that beacon selection would not work then the game was being paused on open FP-interface + +--------------------------------------------------------------------------------------------------- +Version: 0.18.32 +Date: 15. 06. 2020 + Changes: + - Improved some things about how product definitions by belt/lane are handled. Changing timescales now also better preserves the precision of the numbers you enter. + Bugfixes: + - Fixed several problems leading to incorrect results when using productivity modules + - Fixed that the top level products wouldn't update their numbers when in belts/lanes-view and changing the preferred belt + +--------------------------------------------------------------------------------------------------- +Version: 0.18.31 +Date: 12. 06. 2020 + Features: + - You can now alt-click a machine in preferences to set that exact machine in every other category that has it. + Bugfixes: + - Fixed a problem where productivity modules would not apply correctly to some modded ingredients + +--------------------------------------------------------------------------------------------------- +Version: 0.18.30 +Date: 12. 06. 2020 + Bugfixes: + - Fixed that crafting machines producing more than 1 item/tick would show incorrect results, especially when productivity was involved. Thanks a lot to curiosity for helping me figure this one out. + - Fixed a bug where using productivity on certain recipes (like koravex enrichment) would not produce correct results + - Fixed a bug where byproducts produced on a subfloor were not incorporated correctly on their parent floor + - Fixed a bug where a recipe line's byproducts were not consumed by the fuel that that line needed + +--------------------------------------------------------------------------------------------------- +Version: 0.18.29 +Date: 09. 06. 2020 + Features: + - Added an indication to the machines/modules in the utility dialog that shows whether you have the needed machines/modules in your inventory + Changes: + - The product picker now reflects your preference for thinking in terms of belts or lanes + - You can now change fuels (up/downgrade) of a recipe line by shift/ctrl-clicking them + - Added some warnings when automatically adding your preferred machine or beacon does not work for compatibility reasons. Also added some warning messages when up/downgrading machines and beacons. + +--------------------------------------------------------------------------------------------------- +Version: 0.18.28 +Date: 07. 06. 2020 + Bugfixes: + - Fixed a bad mistake on my part that screwed up all the things + - Added a migration so your preferred belts, beacons and machines are not lost. Fuels will still be reset though. + +--------------------------------------------------------------------------------------------------- +Version: 0.18.27 +Date: 07. 06. 2020 + Features: + - Now supports any kind of item-fuel, which includes things like mining drills from Pyanodon's and others. Might reset some of the fuels you selected to their default. I'm sorry about this, I tried to migrate it where I could, but there were large background changes that made it so it doesn't work in every case. + - Also added support for some fluid-fuels. Machines like Bob's steam assembler are still not supported, as the way they work is complicated and requires more background work on my part. + Changes: + - The belt/fuel/beacon/machine-choices in preferences are now sorted by their most important attributes. Also added some new attributes to the tooltips. + - Changed the alt-action preference to be a mod setting instead + Bugfixes: + - Fixed a crash when attempting to repair certain subfactories + - Fixed that once set, module-preferences could not be unset anymore + - Fixed an issue where the product picker would show two scroll bars in some cases + - Potentially fixed a crash when trying to open a modal dialog while another one is open + +--------------------------------------------------------------------------------------------------- +Version: 0.18.26 +Date: 03. 06. 2020 + Bugfixes: + - Fixed a bug where mining fluid calculations were off by a factor of 10 + - Fixed that you could define a fluid-product amount by belts, which is nonsensical + - Fixed some minor graphical inconsistencies + +--------------------------------------------------------------------------------------------------- +Version: 0.18.25 +Date: 02. 06. 2020 + Bugfixes: + - Fixed a crash when changing a machine through a popup-dialog + - Fixed a series of crashes related to loading a save + +--------------------------------------------------------------------------------------------------- +Version: 0.18.24 +Date: 01. 06. 2020 + Changes: + - This update contains an unusually large amount of background changes, so there might be some instability (ie. crashes) for a day or two, until I can sort them all out + Bugfixes: + - Fixed a crash on load in certain modded situations + +--------------------------------------------------------------------------------------------------- +Version: 0.18.23 +Date: 30. 05. 2020 + Changes: + - Added support for any modded rocket silo recipes, instead of only vanilla space science + - Combined the two rocket building recipes into one, making it so you don't have to produce rocket parts anymore, you just select the end product (most of the time, this will be space science), and it'll show the ingredients directly, removing an unnecessary step + +--------------------------------------------------------------------------------------------------- +Version: 0.18.22 +Date: 30. 05. 2020 + Changes: + - Allowed products to have an amount of 0 again + Bugfixes: + - Fixed the clicking the 'floor total'-button would crash + +--------------------------------------------------------------------------------------------------- +Version: 0.18.21 +Date: 29. 05. 2020 + Bugfixes: + - Fixed that you could set an amount of 0 on a product, leading to it disappearing + +--------------------------------------------------------------------------------------------------- +Version: 0.18.20 +Date: 29. 05. 2020 + Features: + - Added a way to specify product amounts by multiples of belts + Changes: + - Changing the timescale of a subfactory now adjusts the desired products accordingly + Bugfixes: + - Fixed a crash with certain mods when loading a map + - Now displays power consumption and emissions correctly for machines with a void power source + - Fixed an issue where a recipe line would sometimes show byproducts that shouldn't exist + +--------------------------------------------------------------------------------------------------- +Version: 0.18.19 +Date: 27. 05. 2020 + Changes: + - Adjusted some interface styles due to changes that came with Factorio 0.18.27 + +--------------------------------------------------------------------------------------------------- +Version: 0.18.18 +Date: 25. 05. 2020 + Features: + - Added a preference where you can specify a default module for both machines and beacons, as well as a default beacon count. These preferences cause newly created recipe lines to automatically be maxed out with the specified modules and beacons. This makes it less work to create a fully beacon-ed setup. This functionality is pretty bare-bones and not very smart, but it's a good start. + Bugfixes: + - Fixed a crash when loading a map that used certain kinds of recipes + - Fixed the rocket-part item not being visible anymore in the product picker + +--------------------------------------------------------------------------------------------------- +Version: 0.18.17 +Date: 23. 05. 2020 + Bugfixes: + - Fixed a crash in certain modded situations + +--------------------------------------------------------------------------------------------------- +Version: 0.18.16 +Date: 22. 05. 2020 + Changes: + - The 'items/s/machine'-view now also works for fluids + - The interface now remains open when alt-tabbing or resizing the Factorio window + - Now allows SI prefixes to be localised + Bugfixes: + - Fixed how the mod detects which machines can produce certain recipes in the proper way + - Fixed that setting hard limits on rocket silo recipes would not work correctly + - Fixed that migration caused any subfactory that contained fuels to always need repair + - Fixed that desired products that are also ingredients didn't show up as such at the top level + - Fixed that fuel would sometimes show up on the top floor as an ingredient when it's already being produced on a subfloor + - Fixed that recipes with certain complicated product definitions would not be useable + +--------------------------------------------------------------------------------------------------- +Version: 0.18.15 +Date: 20. 05. 2020 + Bugfixes: + - Fixed a bug where the recipe picker didn't show the appropriate recipes in some cases + - Fixed that adding recipes which used hidden items would crash + - Fixed that a recipe would sometimes show as being able to use fluids as ingredients/products, while actually not being able to do so + - Fixed a crash when alt-tabbing or changing resolution while a popup dialog was open + - (Probably) fixed a crash when the space science-item is being removed by another mod + +--------------------------------------------------------------------------------------------------- +Version: 0.18.14 +Date: 16. 05. 2020 + Bugfixes: + - Fixed that the game could be paused unexpectedly + - Fixed a crash when selecting a product from the item picker + - Fixed a crash when opening certain dialogs + +--------------------------------------------------------------------------------------------------- +Version: 0.18.13 +Date: 16. 05. 2020 + Features: + - You can now alt-click the machine of any recipe to reset it to default, removing any configured limits + Changes: + - Removed the functionality to shift-click a machine choice to set it as the default + - Added an exception so that Klonan's Transport/Mining Drone pseudo-recipes won't show up in the recipe picker + - Reworded the tooltip for the production percentage-column to be more correct + - Added the mod name and description to the locale file, so it can be localised to other languages + Bugfixes: + - Added a temporary fix for some dialogs showing up behind the main interface. I'll have to wait on the reaction of the devs for a proper fix. For now, opening the product picker dialog will be slower. Sorry about that. + - Fixed that rocket silo recipes would be calculated incorrectly, which had lead to inaccurate machine counts and wacky numbers when using the items/s/machine view + - Fixed that machines with negative pollution values would display as having 0 pollution + - Fixed that you could be stranded on a non-existant subfloor after deleting its main recipe + - Fixed that alt-tabbing or changing resolution while FP is pausing the game could cause it to be stuck in the paused state + - Fixed that deleting subfloors could sometimes leave behind remnants that lead to a crash when clicked on + - Fixed that energy consumption for machines in tooltips was off by a factor of 60; The calculations were still correct + +--------------------------------------------------------------------------------------------------- +Version: 0.18.12 +Date: 30. 04. 2020 + Bugfixes: + - Fixed the usage of an outdated format for hotkeys that was no longer supported with Factorio 0.18.22 + +--------------------------------------------------------------------------------------------------- +Version: 0.18.11 +Date: 07. 04. 2020 + Changes: + - Added an indication to the tooltip of 'raw ore'-items, differentiating them from mined ore + Bugfixes: + - Fixed a crash with infinite values in certain modded situations + - Fixed that a change to preferences would not always refresh the main interface + +--------------------------------------------------------------------------------------------------- +Version: 0.18.10 +Date: 04. 04. 2020 + Features: + - Shift-clicking when changing the machine for a recipe now sets that machine as the default + Changes: + - Updated the remote interface for Recipe Book to support its new version + +--------------------------------------------------------------------------------------------------- +Version: 0.18.9 +Date: 29. 03. 2020 + Changes: + - Added exact numbers to the utility dialog machine/module tooltips + Bugfixes: + - Fixed a calculation issue where byproducts where not being used up by recipes on subfloors + +--------------------------------------------------------------------------------------------------- +Version: 0.18.8 +Date: 16. 03. 2020 + Bugfixes: + - Fixed a crash when left-clicking a top level byproduct + +--------------------------------------------------------------------------------------------------- +Version: 0.18.7 +Date: 10. 03. 2020 + Features: + - Added a preference that allows you to enable additional production table columns, such as pollution info and comment fields + - Added a preference that allows you to select an action when alt-clicking on recipes or items. You can choose between opening them in FNEI, WIIRUF or Recipe Book. + - Added rate limiting to most interface interactions, improving performance and avoiding crashes in high-latency multiplayer situations + - Added a keyboard shortcut to refocus the product picker searchfield + - Added some more warning messages when an action couldn't be executed + Changes: + - Created a centralized localisation project. If you want to contribute, take a look at this mod's Github page. + - Changed some mod settings to be preferences + - Removed Performance Mode, as it no longer did anything + - The utility dialog now automatically saves changes to your notes + - Improved the warning message when there is no (enabled) recipe to craft the given product + Bugfixes: + - Fixed the recipe dialog dimensions to be less jumpy + - Fixed a crash that was caused by the mod assuming that the steam prototype will always exist + - Fixed a problem where byproducts and ingredients where added up incorrectly in some cases + - Fixed a problem where fluid buttons didn't show up when certain views were active + +--------------------------------------------------------------------------------------------------- +Version: 0.18.6 +Date: 29. 02. 2020 + Bugfixes: + - Removed superfluous logging that lead to crashes + +--------------------------------------------------------------------------------------------------- +Version: 0.18.5 +Date: 29. 02. 2020 + Bugfixes: + - Fixed energy consumption and pollution not being added up correctly if your subfactory contains subfloors + - Hide recipes that can't ever be researched, which led Factory Planner to crash + +--------------------------------------------------------------------------------------------------- +Version: 0.18.4 +Date: 26. 02. 2020 + Bugfixes: + - Fixed a crash when opening preferences when using modded items with infinite fuel values + - Fixed a crash when submitting the product picker without a product selected + - Fixed several crashes related to invalid inputs in dialog windows + - Mitigated all crashes caused by mod-created hidden modules/beacons + +--------------------------------------------------------------------------------------------------- +Version: 0.18.3 +Date: 21. 02. 2020 + Features: + - Added pollution information to machine tooltips + - Added a tooltip explaining what the percentage-textfield on the recipe-lines does + Bugfixes: + - Fixed that the recipe dialog didn't have the correct size sometimes + - Added exception for the mod 'Deep Mine' so the preferences dialog doesn't crash when it is installed + - Fixed a crash when using the beacon selection tool + - Fixed a crash when adding a new subfactory while viewing an invalid one + - Fixed the main interface not refreshing after changing some preferences + +--------------------------------------------------------------------------------------------------- +Version: 0.18.2 +Date: 20. 02. 2020 + Changes: + - Improved responsiveness of the subfactory name-entry in multiplayer + Bugfixes: + - Fixed the ingredients and byproducts not showing up at the subfactory level + - Fixed some wonkiness related to the 'Floor Total' view + +--------------------------------------------------------------------------------------------------- +Version: 0.18.1 +Date: 21. 01. 2020 + Features: + - Updated for 0.18. It also contains some smaller features and a lot of background optimizations. There are still quite a few bugs that have been reported and not fixed, I wanted to get the 0.18 patch out quickly. Thanks to everyone that reported things, it's very much appreciated. I kind of fell off the development-train in December because of exams and the festivities, and haven't gotten back on the horse really. I'll try to address the outstanding bugs soon, and hopefully get back to feature development at some point. + Changes: + - Reworked the recipe picker to be easier to use. Most recipes now use the fancy new tooltips. + - Various graphical and performance improvements + Bugfixes: + - Fixed an infinite loop when trying to add a recipe that has no machine it can use + +--------------------------------------------------------------------------------------------------- +Version: 0.17.73 +Date: 03. 12. 2019 + Bugfixes: + - Fixed that you couldn't add recipes for steam that doesn't specify a temperature + - Added a missing font + - Fixed a mixup in the tutorial text + +--------------------------------------------------------------------------------------------------- +Version: 0.17.72 +Date: 27. 11. 2019 + Bugfixes: + - Added an info-label when no machines/modules are needed in the utility dialog + - Fixed a crash when opening the utility dialog under certain circumstances + +--------------------------------------------------------------------------------------------------- +Version: 0.17.71 +Date: 27. 11. 2019 + Features: + - Converted the notes dialog into a utility dialog. It now contains the subfactory notes as well as an overview of how many machines/beacons/modules you'll need to build your current subfactory/floor. In the future, there will be more functionality added to this dialog. + Bugfixes: + - Fixed the previous fix to the calculation model + +--------------------------------------------------------------------------------------------------- +Version: 0.17.70 +Date: 26. 11. 2019 + Bugfixes: + - Fixed a bug where byproducts where sometimes not used as ingredients + - Added a missing locale key + - Fixed a couple migration crashes + +--------------------------------------------------------------------------------------------------- +Version: 0.17.69 +Date: 23. 11. 2019 + Bugfixes: + - Fix migration crash + +--------------------------------------------------------------------------------------------------- +Version: 0.17.68 +Date: 21. 11. 2019 + Features: + - Added support for fluid temperatures (mainly relevant for steam) + Changes: + - Re-added left-click on the module and beacon buttons + +--------------------------------------------------------------------------------------------------- +Version: 0.17.67 +Date: 21. 11. 2019 + Bugfixes: + - Fixed a crash when using keyboard shortcuts before opening the main dialog once + +--------------------------------------------------------------------------------------------------- +Version: 0.17.66 +Date: 20. 11. 2019 + Changes: + - Made the control scheme more consistent by changing around some shortcuts. Take a look at the 'Interface'-part of the tutorial for more information. + - Improved and extended the Tutorial and Pro Tips + - Changed the example subfactory + Bugfixes: + - Fixed the selected floor not being conserved when changing subfactories + +--------------------------------------------------------------------------------------------------- +Version: 0.17.65 +Date: 19. 11. 2019 + Features: + - You can now set a hard limit on the amount of machines on a recipe line + - You can now also set an absolute number of beacons for every recipe, which includes them in energy calculations. There's also a selection tool to count already placed beacons. + - Added a performance mode setting that disables certain performance-intensive features + - Added a hotkey to go up a floor in a subfactory (SHIFT+R by default) + Changes: + - Improved performance of the calculation model + - Right-clicking on beacons now removes them, which is in line with the rest of the UI + - Made the localisation more compatible with other mods. Everything had to be moved around, so translation mods will have to do a bit of work to be compatible again. If you're having trouble, join the Discord and I'll try to help. + Bugfixes: + - Fixed a calculation error regarding productivity on certain types of recipes + - Fixed the 'Machine rounding precision' setting not working correctly + - Fixed that the current beacon wasn't being selected when editing + - Fixed SI-unit localisations not actually being shown + - Made time-shorthands (s, m, h) properly localisable + +--------------------------------------------------------------------------------------------------- +Version: 0.17.64 +Date: 12. 11. 2019 + Bugfixes: + - Fixed a crash caused by the most recent update + +--------------------------------------------------------------------------------------------------- +Version: 0.17.63 +Date: 12. 11. 2019 + Changes: + - Slightly improved calculation performance by using new API capabilities + Bugfixes: + - Improved compatibility with PyVeganism + - Fixed the main interface refreshing unnecessarily sometimes + - Fixed the horrible performance of the 'Floor Total'-mode + +--------------------------------------------------------------------------------------------------- +Version: 0.17.62 +Date: 04. 11. 2019 + Changes: + - Made the 'Tutorial Mode'-tooltips a bit more consistent + - Made units like Watt or Joule localisable + +--------------------------------------------------------------------------------------------------- +Version: 0.17.61 +Date: 24. 10. 2019 + Features: + - Added pollution calculation (shown in the tooltip of the energy consumption of every recipe line) + Changes: + - Changed around the preferences and mod-settings a bit + - Reworded several of the mod-setting descriptions + - Increased the subfactory name length-limit from 16 to 24 + Bugfixes: + - Fixed changelog + +--------------------------------------------------------------------------------------------------- +Version: 0.17.60 +Date: 24. 10. 2019 + Bugfixes: + - Fixed a crash when rotating a machine + +--------------------------------------------------------------------------------------------------- +Version: 0.17.59 +Date: 23. 10. 2019 + Features: + - A preview of your notes is now displayed in the tooltip of the 'View notes'-button + Changes: + - The 'round button numbers' mod setting now defaults to false so it's less confusing to new users + Bugfixes: + - Fixed multiple subfactory-repair crashes + - Fixed the calculations not updating properly after migration + - Fixed the subfactory bar not linebreaking correctly when it contains many small elements + - Fixed several small graphical inconsistencies + +--------------------------------------------------------------------------------------------------- +Version: 0.17.58 +Date: 23. 10. 2019 + Bugfixes: + - Fixed a migration crash related to fuels + - Fixed a crash on the calculation model + +--------------------------------------------------------------------------------------------------- +Version: 0.17.57 +Date: 19. 10. 2019 + Bugfixes: + - Fixed another migration crash + +--------------------------------------------------------------------------------------------------- +Version: 0.17.56 +Date: 18. 10. 2019 + Bugfixes: + - Fixed a migration crash related to the new line satisfaction feature + +--------------------------------------------------------------------------------------------------- +Version: 0.17.55 +Date: 18. 10. 2019 + Features: + - [Warning: Experimental!] This version is a complete rewrite of the calculation backbone of the mod, so it's likely to contain some crashes and bugs. I'd love it if you reported them to me on the mod portal. + - This rewrite was done to allow me to implement some much requested features and to fix some bugs that wouldn't be fixable otherwise. The ability to add recipes that consume byproducts didn't make it for this version, but will get done at some point, hopefully soon-ish! + - Added the ability to select which product is the main product of a recipe, which allows you to have more control over how many times this recipe is run. Left-click any product of a recipe line with multiple products to activate this feature. + - Added the ability to set a cap/limit to how many machines a recipe line uses. Right-click the machine button to set it. + - Added a mod setting ('Line satisfaction') that, when enabled, shows whether the ingredients of a recipe line are produced by the recipes below them or not. This allows you to more easily discern which recipes you might still need to add. + Bugfixes: + - Fixed that fuels that originate on subfloors weren't always shown correctly on their parent floors + - Fixed that fuels didn't always show up as the last ingredients in a recipe line, and that changing them in bulk didn't behave correctly + - Fixed crash with PyVeganism + - Fixed a migration crash + +--------------------------------------------------------------------------------------------------- +Version: 0.17.54 +Date: 30. 09. 2019 + Changes: + - Converted several checkboxes to switches, because that's prettier + - Percentage textfields are now re-focused after confirmation + Bugfixes: + - Fixed crash when loading a save with tooltips that are too long + - Fixed the game being unpaused in /editor-mode when it shouldn't be + - Fixed a couple of migration issues + +--------------------------------------------------------------------------------------------------- +Version: 0.17.53 +Date: 27. 09. 2019 + Changes: + - Improved item picker search performance significantly + - Improved the tooltips in several places + Bugfixes: + - Fixed the 'tooltip too long'-crash for real this time + +--------------------------------------------------------------------------------------------------- +Version: 0.17.52 +Date: 25. 09. 2019 + Bugfixes: + - Fixed crash with mods that add recipes with a lot of ingredients and/or products + +--------------------------------------------------------------------------------------------------- +Version: 0.17.51 +Date: 22. 09. 2019 + Features: + - Added a subfactory archive. You can use it to keep around old plans that you might want to refer back to at a later point. They can't be edited once they are archived, although you can un-archive them again, should you need to. + - Added a quickbar shortcut to open the main interface + Changes: + - Improved compatibility/detection of mods that add loaders, recyclers or stackers + +--------------------------------------------------------------------------------------------------- +Version: 0.17.50 +Date: 18. 09. 2019 + Features: + - Recipes that don't produce anything are now removed after a product has been deleted + - Added a setting to specify whether machine and belt numbers should be rounded up or not + Changes: + - The recipe picker now only shows recipes that actually produce a net positive amount of the desired item or fluid + - Removed the 'Show hints' setting, as I don't think it's really necessary + Bugfixes: + - Fixed that the launch sequence time wasn't considered for rocket production calculations + - Fixed productivity not applying correctly to mining recipes in certain modded situations + - Fixed an issue where productivity bonuses were applied incorrectly on catalyst recipes + +--------------------------------------------------------------------------------------------------- +Version: 0.17.49 +Date: 08. 09. 2019 + Features: + - Added a mod setting to specify the default timescale for your subfactories + Changes: + - Changed the view that helps estimate how many inserters you'll need from items/s to items/s/machine, which just makes way more sense + - Made all mod settings localizable + Bugfixes: + - Fixed that module limitations (related to productivity modules) didn't apply to many recipes + - Fixed mining recipes not being detected correctly for some mods + - Fixed that you couldn't enter a decimal amount of beacons in some cases + +--------------------------------------------------------------------------------------------------- +Version: 0.17.48 +Date: 26. 08. 2019 + Changes: + - Made several strings easier to localize by splitting up singular and plural words + - Added (un)stacking recipes to the 'show barreling recipes'-preference + Bugfixes: + - Fixed mining productivity being handled incorrectly + - Fixed certain items being detected as fuel when they shouldn't be + - Fixed the machine speed not being capped at -80%, leading to crashes + +--------------------------------------------------------------------------------------------------- +Version: 0.17.47 +Date: 21. 08. 2019 + Bugfixes: + - Fix startup crash + +--------------------------------------------------------------------------------------------------- +Version: 0.17.46 +Date: 20. 08. 2019 + Changes: + - This is the first non-beta version of Factory Planner 💯 + - Updates to descriptions and screenshots + +--------------------------------------------------------------------------------------------------- +Version: 0.17.45 +Date: 19. 08. 2019 + Bugfixes: + - Fixed that you could add a product (or beacon) with an amount of 0 + - Fixed a crash when removing beacons under certain conditions + - Fixed that you couldn't select an subfactory that's become invalid + - Fixed product picker not always centering correctly + - Fixed a couple of migration crashes + +--------------------------------------------------------------------------------------------------- +Version: 0.17.44 +Date: 14. 08. 2019 + Changes: + - Beacon amounts can now be decimal + Bugfixes: + - Fixed layout glitch + +--------------------------------------------------------------------------------------------------- +Version: 0.17.43 +Date: 13. 08. 2019 + Bugfixes: + - Fixed a crash when refreshing the production table + +--------------------------------------------------------------------------------------------------- +Version: 0.17.42 +Date: 13. 08. 2019 + Features: + - Added a button to manually refresh the production table + Changes: + - Vastly improved the performance of the item and recipe picker-dialogs (mostly relevant for heavily modded games) + Initial opening sped up fivefold, subsequent openings sped up twelvefold + Bugfixes: + - Fixed bug where item/recipe categories overlapped sometimes + - Fixed mining productivity not applying to mining with fluids + - Fixed a crash on the beacons dialog in a particular situation + +--------------------------------------------------------------------------------------------------- +Version: 0.17.41 +Date: 12. 08. 2019 + Features: + - Added a preference to ignore recycling recipes (Currently works with Reverse Factory and Deadlock's Industrial Revolution) + Changes: + - Added hint when there are no modules to put into a particular machine/beacon + Bugfixes: + - Fixed detection of which modules are valid for certain machines/beacons + - Fixed the window layering issue properly + +--------------------------------------------------------------------------------------------------- +Version: 0.17.40 +Date: 09. 08. 2019 + Changes: + - Modal dialogs now adapt their size to the overall interface height setting + - The recipe- and item-picker dialogs now remember the location you drag them to + Bugfixes: + - Fixed a crash when changing the selected beacon for a recipe + - Duct-taped over windows not layering correctly in some cases + +--------------------------------------------------------------------------------------------------- +Version: 0.17.39 +Date: 09. 08. 2019 + Bugfixes: + - Fixed an issue where the recipe picker filters were being set incorrectly in some cases + +--------------------------------------------------------------------------------------------------- +Version: 0.17.38 +Date: 08. 08. 2019 + Features: + - You can now use 'Enter' to confirm dialogs and textfields + - All windows can be moved, and modal dialogs are 'true' pop-up windows now + - Mining productivity research is now respected, and can optionally be set to a custom value + - Re-enabled the FNEI interactions, now that it has been updated + - Added offshore pump recipes (Water, Angel's seafloor-pump, etc.) + - You can now toggle whether you want the ingredients/products/byproducts/energy up top to show the subfactory or floor totals + Changes: + - Textfields behave more intuitively now + - Made it easier and prettier to change the subfactory timescale + - Added some more context to various choices through tooltips + - Changed phrasing in some places to improve clarity + - Removed the subfactory/floor machine-changing actions (They will return at a later point) + - Lots of internal changes to improve performance and stability in the long run + Bugfixes: + - Fixed a problem where certain machines could not be configured to use beacons, even when they should + - Fixed certain textfields not being selected properly anymore after Factorio v0.17.59 + - Fixed changelog + +--------------------------------------------------------------------------------------------------- +Version: 0.17.37 +Date: 27. 07. 2019 + Bugfixes: + - Improved compatibility with the 'More Mining Productivity' mod + +--------------------------------------------------------------------------------------------------- +Version: 0.17.36 +Date: 22. 07. 2019 + Changes: + - Cleaned up the GUI in several places + Bugfixes: + - Fixed a bug where the ingredient limit on machines was sometimes calculated incorrectly + - Fixed a rare case where numbers could be displayed in scientific notation + +--------------------------------------------------------------------------------------------------- +Version: 0.17.35 +Date: 20. 07. 2019 + Changes: + - Improved focus selection for the beacon-dialog + Bugfixes: + - Fixed/improved the beacon-dialog validation process + - Fixed a rare crash when another mod changes its mod settings + +--------------------------------------------------------------------------------------------------- +Version: 0.17.34 +Date: 18. 07. 2019 + Bugfixes: + - Fixed a crash when adding steam recipes + +--------------------------------------------------------------------------------------------------- +Version: 0.17.33 +Date: 18. 07. 2019 + Bugfixes: + - Fixed item amounts not refreshing correctly when changing views + +--------------------------------------------------------------------------------------------------- +Version: 0.17.32 +Date: 15. 07. 2019 + Bugfixes: + - Updated to incorporate the new 'base_productivity'-field on crafting machines + - Fixed a rare rounding error on machine tooltips + +--------------------------------------------------------------------------------------------------- +Version: 0.17.31 +Date: 12. 07. 2019 + Bugfixes: + - Fixed a crash on launch when using Py's Raw Ores, but not Py's Petro Handling + +--------------------------------------------------------------------------------------------------- +Version: 0.17.30 +Date: 09. 07. 2019 + Bugfixes: + - Fixed migration again (wheee) + +--------------------------------------------------------------------------------------------------- +Version: 0.17.29 +Date: 09. 07. 2019 + Bugfixes: + - Fixed migration crash for real this time + - Fixed changelog formatting + +--------------------------------------------------------------------------------------------------- +Version: 0.17.28 +Date: 09. 07. 2019 + Bugfixes: + - Fixed a crash when migrating a save + +--------------------------------------------------------------------------------------------------- +Version: 0.17.27 +Date: 06. 07. 2019 + Features: + - Added support for modules and beacons! (might cause crashes) + - Added a tutorial mode, which informs you of all the possible interactions in the + tooltips of various buttons. You can toggle it in the tutorial-window + Changes: + - Changing the view (items/s, belts, etc) now applies to top-level-items too + - Improved the tooltips in various places + Bugfixes: + - Fixed a crash when opening the machines or fuel chooser dialog + - Fixed a crash that happened when adding a recipe with no compatible machines + +--------------------------------------------------------------------------------------------------- +Version: 0.17.26 +Date: 01. 07. 2019 + Bugfixes: + - Fixed a crash when loading a save with some missing data + +--------------------------------------------------------------------------------------------------- +Version: 0.17.25 +Date: 27. 06. 2019 + Bugfixes: + - Fixed the game crashing when rocket tech is researched (again) + +--------------------------------------------------------------------------------------------------- +Version: 0.17.24 +Date: 27. 06. 2019 + Changes: + - 'To the top' now only shows when you are on level 3 or deeper + Bugfixes: + - Fixed a crash when adding Factory Planner to a save that has the rocket tech already researched + - Fixed the hint indicating you added an unresearched recipe showing up too often + +--------------------------------------------------------------------------------------------------- +Version: 0.17.23 +Date: 26. 06. 2019 + Bugfixes: + - Fixed crash when loading a save + - Fixed machine changing not working correctly + +--------------------------------------------------------------------------------------------------- +Version: 0.17.22 +Date: 26. 06. 2019 + Bugfixes: + - Fix crash when loading an existing save (related to view states) + +--------------------------------------------------------------------------------------------------- +Version: 0.17.21 +Date: 26. 06. 2019 + Changes: + - (This is an experimental release. It will probably break some stuff) + - Made migration when adding/removing/updating mods more solid + - Top level ingredients/products/byproducts now display their numbers according to the current view + Bugfixes: + - Fixed a crash when adding Factory Planner to an existing save + +--------------------------------------------------------------------------------------------------- +Version: 0.17.20 +Date: 20. 06. 2019 + Features: + - Added setting to pause the game when the Factory Planner interface is open (Singleplayer only) + Changes: + - Improved how the machine rounding indicators work + Bugfixes: + - Fixed certain probabilistic recipes not being calculated correctly + - Fixed the top level item tooltips not displaying + +--------------------------------------------------------------------------------------------------- +Version: 0.17.19 +Date: 19. 06. 2019 + Features: + - Now has a setting that lets you indicate whether a machine number is rounded up or not + Bugfixes: + - Fixed a couple of crashes related to mining recipes and ores + +--------------------------------------------------------------------------------------------------- +Version: 0.17.18 +Date: 18. 06. 2019 + Bugfixes: + - Fixed a crash when changing machines with the chooser dialog + +--------------------------------------------------------------------------------------------------- +Version: 0.17.17 +Date: 17. 06. 2019 + Features: + - Now has a preference to enable comments for every recipe line + Bugfixes: + - Fixed mining recipes not showing up + - Fixed a crash when formatting very small/big numbers for display + +--------------------------------------------------------------------------------------------------- +Version: 0.17.16 +Date: 16. 06. 2019 + Bugfixes: + - Fixed a crash when adding Factory Planner to an existing save + +--------------------------------------------------------------------------------------------------- +Version: 0.17.15 +Date: 16. 06. 2019 + Changes: + - You can now change the fuel of all subfloors by changing it on the parent line + - Improved the number formatting yet again + Bugfixes: + - Fixed a crash when setting the fuel type on a recipe to wood + +--------------------------------------------------------------------------------------------------- +Version: 0.17.14 +Date: 16. 06. 2019 + Bugfixes: + - Corrected the number-of-belts/lanes-calculation + - Fixed a couple of crashes when going between floors + +--------------------------------------------------------------------------------------------------- +Version: 0.17.13 +Date: 15. 06. 2019 + Features: + - (This release is experimental. It also loses your preferences, my apologies) + - Now calculates the fuel consumed by burner machines for you + The type of fuel is adjutable in preferences or per line (right-click it) + - Now links to FNEI. Alt-click on any item or recipe to see it in FNEI + - Ingredient limits on assemblers are now respected + - Now hides all items that don't have a recipe + - Added a preference to ignore barreling recipes + - Added a setting to change the height of the main interface + Changes: + - Improved number formatting in several places + +--------------------------------------------------------------------------------------------------- +Version: 0.17.12 +Date: 13. 06. 2019 + Bugfixes: + - Fixed Factory Planner accidentally deleting the mod buttons of other mods in the top right corner + +--------------------------------------------------------------------------------------------------- +Version: 0.17.11 +Date: 12. 06. 2019 + Bugfixes: + - Fixed a crash when on the belts/lanes view while displaying a fluid + +--------------------------------------------------------------------------------------------------- +Version: 0.17.10 +Date: 11. 06. 2019 + Changes: + - Now hides all recipes that don't have a machine to produce them + Bugfixes: + - Fixed probabilistic recipes being handled incorrectly + - Fixed a crash when going between subfactory floors + - Now works around item buttons not rounding correctly sometimes + +--------------------------------------------------------------------------------------------------- +Version: 0.17.9 +Date: 09. 06. 2019 + Changes: + - The percentage field on a recipe now properly supports decimals + Bugfixes: + - Fixed a crash if there was any mining recipe with multiple products + +--------------------------------------------------------------------------------------------------- +Version: 0.17.8 +Date: 08. 06. 2019 + Bugfixes: + - Fixed a crash on launch + - Fixed a crash when entering certain characters into the item search field + +--------------------------------------------------------------------------------------------------- +Version: 0.17.7 +Date: 08. 06. 2019 + Changes: + - Removed the 'show disabled recipe' setting + Bugfixes: + - Fixed crash when hitting TAB before you opened the interface for the first time + - Fixed belt icon being oversized on the views selection + - Fixed crash related to machines that produce steam + +--------------------------------------------------------------------------------------------------- +Version: 0.17.6 +Date: 06. 06. 2019 + Bugfixes: + - Fixed a couple of crashes that 0.17.5 caused. Sorry about that. + +--------------------------------------------------------------------------------------------------- +Version: 0.17.5 +Date: 06. 06. 2019 + Features: + - Added support for 3 different production views: Items/timescale, Belts or Lanes, and Items/s + - Added recipes for producing steam + +--------------------------------------------------------------------------------------------------- +Version: 0.17.4 +Date: 04. 06. 2019 + Features: + - Now remembers your recipe-filter preferences + Changes: + - The setting 'Show disabled recipes' is now unchecked by default + Bugfixes: + - Fixed mining recipes not being checked correctly when loaded mods change + - Fixed some mods' additional player entities causing crashes + +--------------------------------------------------------------------------------------------------- +Version: 0.17.3 +Date: 31. 05. 2019 + Features: + - Added thumbnail + Bugfixes: + - Fixed recipe picker filter not applying + +--------------------------------------------------------------------------------------------------- +Version: 0.17.2 +Date: 30. 05. 2019 + Bugfixes: + - Fixed an issue where products could have negative amounts + - Improved compatibility with Angel's mods + - Improved compatibility with Creative Mod + +--------------------------------------------------------------------------------------------------- +Version: 0.17.1 +Date: 30. 05. 2019 + Features: + - Initial beta release 🙌 diff --git a/modfiles/control.lua b/modfiles/control.lua new file mode 100644 index 000000000..195c73871 --- /dev/null +++ b/modfiles/control.lua @@ -0,0 +1,80 @@ +local active_mods = script.active_mods + +SPACE_TRAVEL = script.feature_flags["space_travel"] +DEBUGGER_ACTIVE = (active_mods["debugadapter"] ~= nil) +DEV_ACTIVE = true -- enables certain conveniences for development +llog = require("util.llog") + +MAGIC_NUMBERS = { + margin_of_error = 1e-6, -- the margin of error for floating point calculations + factory_deletion_delay = 15 * 60 * 60, -- ticks to deletion after factory trashing + modal_search_rate_limit = 10, -- ticks between modal search runs + + -- Some magic numbers to determine and calculate the dimensions of the main dialog + frame_spacing = 12, -- Spacing between the base frames in the main dialog + title_bar_height = 28, -- Height of the main dialog title bar + district_info_height = 36, + subheader_height = 36, -- Height of the factory list subheader + list_width = 300, -- Width of the factory list + list_element_height = 28, -- Height of an individual factory list element + item_button_size = 40, -- Size of item box buttons + item_box_max_rows = 4, -- Maximum number of rows in an item box + + -- Various other UI-related magic numbers + recipes_per_row = 6, -- Number of recipes per row in the recipe picker + items_per_row = 10, -- Number of items per row in the item picker + groups_per_row = 6, -- Number of groups in a row in the item picker + blueprint_limit = 12, -- Maxmimum number of blueprints allowed per factory + module_dialog_element_width = 440, -- Width of machine and beacon dialog elements + titlebar_label_width = 124, -- Width of the 'Factory Planner' titlebar label + context_menu_width = 270 -- total width of the context menu +} + +CUSTOM_EVENTS = { + open_modal_dialog = script.generate_event_name(), + close_modal_dialog = script.generate_event_name(), + build_gui_element = script.generate_event_name(), + refresh_gui_element = script.generate_event_name() +} + +-- Handlers saved in a central location for access via name +MODIFIER_ACTIONS = {} ---@type ActionTable +GLOBAL_HANDLERS = {} ---@type { [string]: function } + +PRODUCTS_PER_ROW_OPTIONS = {5, 6, 7, 8, 9, 10} +FACTORY_LIST_ROWS_OPTIONS = {20, 22, 24, 26, 28, 30, 32} +COMPACT_WIDTH_PERCENTAGE = {8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36} + +TIMESCALE_MAP = {[1] = "second", [60] = "minute"} +BLANK_EFFECTS = {speed = 0, productivity = 0, quality = 0, consumption = 0, pollution = 0} + + +ftable = require("__flib__.table") -- has more functionality than built-in table +translator = require("__flib__.dictionary") -- translation module for localised search + +util = require("util.util") + +require("ui.base.main_dialog") +require("ui.base.compact_dialog") +require("ui.base.modal_dialog") + +require("backend.init") +require("ui.event_handler") + +DEV_EXPORT_STRING = "eNrdVkuPnDAM/i85DyMew/PYQ0+tVKnHaoRCMLNRE8KGsO1oxH+vA8wUmJ3VrratdssJG3+2P9sxORH42ShtcqnKFgzJTqSgLZCM+Ft368VkQ0oougMtaWNAT/oA1SDggRooHU25aOeASvACZZSirYvyfUcFN8eFCWVG6WMjaF1fvHrWuG0oA4ceZin0Z3sOGOfbiTBBWxvx4+gFUTWVFvCBtpyhWIgOGs1rg1YnhNfKWCjBT41WZccMf8CM8kLVfLSY1Ev/X0bliDLKFmcKhOSZ0YhmDuOaddwaMSzHweaTEW5A2tJRQ3NzbGBStZYgl43gFYeSZEZ30NsKV7yGMi8slErV1dadhvuOa1RPmszbRssnTkI3dXdhmEau60VRGke70EviII5TP42SOOw3z2HDkYlzAKqdH3cA4h9QcbdJsHxSP9yFvh+nieu6SbBLEt8P0yBKkjTaJbsAuew3xKgmr4RS2mZ/GYNBsSE4kJh85uEb5rBs5SfUDIkw3kD+rHbOGY+4G5zPI6Xqs/mosQFLhXGziooWNoTauYMRhzDQDGozjLrnuhsiKbuzac6ofZ5U1z3D7yALJHpwJpwTLBv3m1S7YjMBbtCZjutVkWqlJRUrV6Mxv+WrUsgxF1xiRSfauGg6Afm0bC5EB+1XsIUfLZb9G78/dhKrijMONTs6I87xV2W4GKyrMIX560U4D73f73sUmZISrEzI/Hg+PaHDEcV1aeDNT2Zr0LNTdbqmQ6BZL1oJwuDIvqV5rDq7NGbbpBs24DoaU3S1GNkdSM6uMrD+Ho3ev2j2T/0Ts7LaeP7LN971yn93685btUPT6q3N1p/q93+zG8Z/Ev5m3816eFUL98PGf+rCihfg76+8sFrpZfcim+i+/wV3PxDb" + + +---@class Event +---@field name defines.events +---@field tick Tick + +---@class GuiEvent : Event +---@field player_index PlayerIndex + +---@alias PlayerIndex uint +---@alias Tick uint +---@alias VersionString string +---@alias ModToVersion { [string]: VersionString } +---@alias AllowedEffects { [string]: boolean } +---@alias ItemType string +---@alias ItemName string diff --git a/modfiles/data.lua b/modfiles/data.lua new file mode 100644 index 000000000..9a3ab4d51 --- /dev/null +++ b/modfiles/data.lua @@ -0,0 +1,5 @@ +require("prototypes.styles") +require("prototypes.sprites") +require("prototypes.hotkeys") +require("prototypes.shortcuts") +require("prototypes.tools") diff --git a/modfiles/graphics/agriculture_square.png b/modfiles/graphics/agriculture_square.png new file mode 100644 index 000000000..6c84db0a0 Binary files /dev/null and b/modfiles/graphics/agriculture_square.png differ diff --git a/modfiles/graphics/amount.png b/modfiles/graphics/amount.png new file mode 100644 index 000000000..0cb592c0b Binary files /dev/null and b/modfiles/graphics/amount.png differ diff --git a/modfiles/graphics/archive.png b/modfiles/graphics/archive.png new file mode 100644 index 000000000..84c88b330 Binary files /dev/null and b/modfiles/graphics/archive.png differ diff --git a/modfiles/graphics/arrow_down.png b/modfiles/graphics/arrow_down.png new file mode 100644 index 000000000..49f077883 Binary files /dev/null and b/modfiles/graphics/arrow_down.png differ diff --git a/modfiles/graphics/arrow_line_bar_up.png b/modfiles/graphics/arrow_line_bar_up.png new file mode 100644 index 000000000..191cedb3b Binary files /dev/null and b/modfiles/graphics/arrow_line_bar_up.png differ diff --git a/modfiles/graphics/arrow_line_up.png b/modfiles/graphics/arrow_line_up.png new file mode 100644 index 000000000..592f52d86 Binary files /dev/null and b/modfiles/graphics/arrow_line_up.png differ diff --git a/modfiles/graphics/arrow_up.png b/modfiles/graphics/arrow_up.png new file mode 100644 index 000000000..516c9da6f Binary files /dev/null and b/modfiles/graphics/arrow_up.png differ diff --git a/modfiles/graphics/beacon_selector.png b/modfiles/graphics/beacon_selector.png new file mode 100644 index 000000000..56886e625 Binary files /dev/null and b/modfiles/graphics/beacon_selector.png differ diff --git a/modfiles/graphics/calculator.png b/modfiles/graphics/calculator.png new file mode 100644 index 000000000..7dac03242 Binary files /dev/null and b/modfiles/graphics/calculator.png differ diff --git a/modfiles/graphics/collapse.png b/modfiles/graphics/collapse.png new file mode 100644 index 000000000..7827363dd Binary files /dev/null and b/modfiles/graphics/collapse.png differ diff --git a/modfiles/graphics/default.png b/modfiles/graphics/default.png new file mode 100644 index 000000000..e4f50388f Binary files /dev/null and b/modfiles/graphics/default.png differ diff --git a/modfiles/graphics/default_all.png b/modfiles/graphics/default_all.png new file mode 100644 index 000000000..d84e05c14 Binary files /dev/null and b/modfiles/graphics/default_all.png differ diff --git a/modfiles/graphics/divide.png b/modfiles/graphics/divide.png new file mode 100644 index 000000000..ef570426a Binary files /dev/null and b/modfiles/graphics/divide.png differ diff --git a/modfiles/graphics/dropup.png b/modfiles/graphics/dropup.png new file mode 100644 index 000000000..d7da4118e Binary files /dev/null and b/modfiles/graphics/dropup.png differ diff --git a/modfiles/graphics/expand.png b/modfiles/graphics/expand.png new file mode 100644 index 000000000..0af860901 Binary files /dev/null and b/modfiles/graphics/expand.png differ diff --git a/modfiles/graphics/fold_out_subfloors.png b/modfiles/graphics/fold_out_subfloors.png new file mode 100644 index 000000000..429d12c30 Binary files /dev/null and b/modfiles/graphics/fold_out_subfloors.png differ diff --git a/modfiles/graphics/generic_assembler.png b/modfiles/graphics/generic_assembler.png new file mode 100644 index 000000000..a55a51ec0 Binary files /dev/null and b/modfiles/graphics/generic_assembler.png differ diff --git a/modfiles/graphics/history.png b/modfiles/graphics/history.png new file mode 100644 index 000000000..45219c58b Binary files /dev/null and b/modfiles/graphics/history.png differ diff --git a/modfiles/graphics/limited_down.png b/modfiles/graphics/limited_down.png new file mode 100644 index 000000000..7b3b8300a Binary files /dev/null and b/modfiles/graphics/limited_down.png differ diff --git a/modfiles/graphics/limited_up.png b/modfiles/graphics/limited_up.png new file mode 100644 index 000000000..042d5da99 Binary files /dev/null and b/modfiles/graphics/limited_up.png differ diff --git a/modfiles/graphics/minus.png b/modfiles/graphics/minus.png new file mode 100644 index 000000000..eedb0fcff Binary files /dev/null and b/modfiles/graphics/minus.png differ diff --git a/modfiles/graphics/multiply.png b/modfiles/graphics/multiply.png new file mode 100644 index 000000000..8375ead66 Binary files /dev/null and b/modfiles/graphics/multiply.png differ diff --git a/modfiles/graphics/pin.png b/modfiles/graphics/pin.png new file mode 100644 index 000000000..2928f41f8 Binary files /dev/null and b/modfiles/graphics/pin.png differ diff --git a/modfiles/graphics/play.png b/modfiles/graphics/play.png new file mode 100644 index 000000000..864050ab8 Binary files /dev/null and b/modfiles/graphics/play.png differ diff --git a/modfiles/graphics/plus.png b/modfiles/graphics/plus.png new file mode 100644 index 000000000..ca21c6cb6 Binary files /dev/null and b/modfiles/graphics/plus.png differ diff --git a/modfiles/graphics/semitransparent_pixel.png b/modfiles/graphics/semitransparent_pixel.png new file mode 100644 index 000000000..77a9338d0 Binary files /dev/null and b/modfiles/graphics/semitransparent_pixel.png differ diff --git a/modfiles/graphics/shortcut_open_x24.png b/modfiles/graphics/shortcut_open_x24.png new file mode 100644 index 000000000..c6dd80ae5 Binary files /dev/null and b/modfiles/graphics/shortcut_open_x24.png differ diff --git a/modfiles/graphics/shortcut_open_x56.png b/modfiles/graphics/shortcut_open_x56.png new file mode 100644 index 000000000..f8c05d2e6 Binary files /dev/null and b/modfiles/graphics/shortcut_open_x56.png differ diff --git a/modfiles/graphics/silo_rocket.png b/modfiles/graphics/silo_rocket.png new file mode 100644 index 000000000..98646ddd2 Binary files /dev/null and b/modfiles/graphics/silo_rocket.png differ diff --git a/modfiles/graphics/stack.png b/modfiles/graphics/stack.png new file mode 100644 index 000000000..f951af118 Binary files /dev/null and b/modfiles/graphics/stack.png differ diff --git a/modfiles/graphics/trash_red.png b/modfiles/graphics/trash_red.png new file mode 100644 index 000000000..0b85601c6 Binary files /dev/null and b/modfiles/graphics/trash_red.png differ diff --git a/modfiles/graphics/universal_planet.png b/modfiles/graphics/universal_planet.png new file mode 100644 index 000000000..fef86d84b Binary files /dev/null and b/modfiles/graphics/universal_planet.png differ diff --git a/modfiles/graphics/warning_red.png b/modfiles/graphics/warning_red.png new file mode 100644 index 000000000..6139163ee Binary files /dev/null and b/modfiles/graphics/warning_red.png differ diff --git a/modfiles/graphics/white_square.png b/modfiles/graphics/white_square.png new file mode 100644 index 000000000..0961b8e8a Binary files /dev/null and b/modfiles/graphics/white_square.png differ diff --git a/modfiles/graphics/zone_selection.png b/modfiles/graphics/zone_selection.png new file mode 100644 index 000000000..aeaeb1201 Binary files /dev/null and b/modfiles/graphics/zone_selection.png differ diff --git a/modfiles/info.json b/modfiles/info.json new file mode 100644 index 000000000..f3e0ab52f --- /dev/null +++ b/modfiles/info.json @@ -0,0 +1,11 @@ +{ + "name": "factoryplanner", + "version": "2.0.31", + "title": "Factory Planner", + "author": "Therenas", + "factorio_version": "2.0", + "dependencies": [ + "base >= 2.0.49", + "flib >= 0.15.0" + ] +} \ No newline at end of file diff --git a/modfiles/locale/en/config.cfg b/modfiles/locale/en/config.cfg new file mode 100644 index 000000000..f9dc992ef --- /dev/null +++ b/modfiles/locale/en/config.cfg @@ -0,0 +1,573 @@ +[mod-name] +factoryplanner=Factory Planner + +[mod-description] +factoryplanner=This mod allows you to plan your production in advance, specifying the recipes and machines that make up each assembly line. It provides powerful features that are fast and intuitive to use, so you can focus on actually building your factory. + + +[controls] +fp_toggle_interface=Open/Close +fp_toggle_compact_view=Toggle compact view +fp_toggle_pause=Toggle pause +fp_refresh_production=Refresh production +fp_cycle_production_views=Cycle production view +fp_up_floor=Go up a floor +fp_top_floor=Go to the top floor +fp_toggle_fold_out_subfloors=Toggle folded out subfloors +fp_reverse_cycle_production_views=Reverse cycle production view +fp_toggle_calculator=Toggle calculator +fp_confirm_dialog=Confirm dialog +fp_focus_searchfield=Focus searchfield + +[controls-description] +fp_toggle_interface=Toggles the main interface +fp_toggle_compact_view=Switches between normal and compact view of the interface +fp_toggle_pause=Toggles pausing the game while the interface is open +fp_refresh_production=Refreshes the current production table +fp_up_floor=When down on a subfloor, go to the one above it +fp_top_floor=When down on a subfloor, go to the topmost one +fp_toggle_fold_out_subfloors=Toggles whether subfloors are folded out from their parents or not. +fp_cycle_production_views=Cycles through the different views of the production table +fp_reverse_cycle_production_views=Cycles backwards through the different views of the production table +fp_toggle_calculator=Toggles the calculator dialog from anywhere +fp_confirm_dialog=Confirms any modal dialog, even when no textfield is in focus +fp_focus_searchfield=Focuses the cursor on the searchfield of the product picker, if possible + + +[shortcut-name] +fp_open_interface=Open Factory Planner + +[item-name] +fp_beacon_selector=Beacon Selector + +[command-help] +fp_restart_translation=Restarts the translation of prototype names so search can use their natural language names. +fp_shrinkwrap_interface=Shrinks the width and height of the main interface until it fully fits onto the screen. + + +[fuel-category-name] +fluid-fuel=Fluid Fuel + + +[fp] +# Porter dialog +import=Import +export=Export +status=Status +location=Location +import_instruction_1=Paste your [font=default-bold]factory exchange string[/font] +import_instruction_2=Choose the factories you’d like to import +export_instruction=Choose the factories that you’d like to export +export_instruction_tt=This will generate a [font=default-bold]factory exchange string[/font] which is used to share your factories with other people or to import it in one of your other worlds. It does not generate a blueprint string! +import_button_tooltip=Import string +export_button_tooltip=Generate factory exchange string +importer_decoding_failure=The given string could not be properly decoded. This is due to it being malformed. Try exporting the factories again, and make sure to copy the entire string. +importer_migration_failure=The given string is from an older version of Factory Planner and could not be migrated. This can be due to it being too old and no longer supported, due to the data being corrupted in some way, or due to a programming error. If you think this string is valid, please contact the developer (me!) on Github. +importer_unpacking_failure=The given string could not be properly unpacked and validated. This can be due to the data being corrupted in some way or due to a programming error. If you think this string is valid, please contact the developer (me!) on the Github. +importer_issue_import_string=Import a valid factory exchange string +importer_issue_select_factory=Select at least one factory to import + +# Utility dialog +utilities=Utilities +utility_title_components=Components +utility_title_components_tt=Shows the machines and modules needed to construct the current factory/floor. Can only incorporate beacons and beacon-modules when their ’Beacon Total’ is set. Rounds the amounts on every line up individually. +utility_title_blueprints=Blueprints +utility_title_blueprints_tt=Allows for storage of relevant blueprints alongside the current factory. Click the ’+’ button with a blueprint in hand add it. +utility_title_notes=Notes +utility_title_productivity_boni=Recipe Productivity +utility_title_productivity_boni_tt=Allows overwriting the current bonus for recipes that can get additional productivity through research. +utility_productivity_imported=Bonuses imported successfully! +components_needed_tt=__1__\n[font=default-bold]__2__[/font] in inventory / [font=default-bold]__3__[/font] needed\n[font=default-semibold][color=#84CDEC]__CONTROL_LEFT_CLICK__[/color][/font] to handcraft this item +no_components_needed=No __1__ needed +import_from=Import from +import_from_tt=Import the custom-configured recipe productivity from another Factory +current=Current +custom=Custom +mining_recipes=Mining Recipes + +utility_combinator_tt=Convert all the items that your inventory is missing into a constant combinator in your cursor. These can then be connected to a requester chest to have them delivered to you. +utility_request_tt=Requests all the items that are missing from your inventory.\n It does this by creating a new logistics group on your character, which you can delete at any time. +utility_no_items_necessary=Inventory already contains all necessary items +utility_logistics_not_researched=Logistic robotics technology is required for this feature +utility_logistics_no_character=Can’t request without associated character +utility_logistics_request_set=Logistics request group successfully created! + +utility_crafting_no_character=Can’t handcraft without associated character +utility_no_crafting=Permissions disable crafting for this player +utility_no_recipes=No recipes can craft this item +utility_no_demand=Demand for this item already fulfilled +utility_no_resources=Not enough resources to handcraft +utility_cursor_empty=No item in hand +utility_blueprint_from_library=Can’t store blueprints from the library +utility_no_blueprint=Item in hand not a blueprint or blueprint book +utility_blueprint_not_setup=Can’t store empty blueprint +utility_blueprint_book_empty=Can’t store empty blueprint book +utility_blueprint_stored=Blueprint stored! + +# Preferences +preferences=Preferences +preferences_support=Support me at [font=default-semibold][color=#84CDEC]https://ko-fi.com/therenas[/color][/font]! + +preference_general_title=General preferences +preference_general_title_tt=Some general preferences that you might want to change regularly. +preference_general_show_gui_button=Show open/close-button +preference_general_show_gui_button_tt=Shows the button on the top left of the screen. It opens and closes the main interface. +preference_general_attach_factory_products=Attach factory product icons +preference_general_attach_factory_products_tt=Shows icons of the output produced by a factory alongside its given name. +preference_general_skip_factory_naming=Skip factory naming +preference_general_skip_factory_naming_tt=Decide whether adding new a factory should start with choosing its name or its first product. +preference_general_prefer_matrix_solver=Prefer matrix solver +preference_general_prefer_matrix_solver_tt=Decide whether new factories should enable the matrix solver instead of the traditional one. +preference_general_show_floor_items=Show floor items +preference_general_show_floor_items_tt=Replace the factory item totals with the totals for the current floor. Only applicable to subfloors, not the top floor. +preference_general_ingredient_satisfaction=Ingredient satisfaction +preference_general_ingredient_satisfaction_tt=Shows whether the ingredient-demands of a recipe are satisfied by the recipes below them. +preference_general_ignore_barreling_recipes=Ignore barreling/stacking recipes +preference_general_ignore_barreling_recipes_tt=Allows you to ignore (un)barreling and (un)stacking when looking for a recipe. (Only for compatible mods) +preference_general_ignore_recycling_recipes=Ignore recycling recipes +preference_general_ignore_recycling_recipes_tt=Allows you to ignore recipes that recycle an existing item. (Only for compatible mods) + +preference_dropdown_products_per_row=Interface width +preference_dropdown_products_per_row_tt=Set the main interface width by choosing how many top-level products are shown per row. +preference_dropdown_factory_list_rows=Interface height +preference_dropdown_factory_list_rows_tt=Set the main interface height by choosing how many factory names are shown in total. +preference_dropdown_compact_width_percentage=Compact dialog width +preference_dropdown_compact_width_percentage_tt=Set the width of the compact dialog as a percentage of the total screen width. + +preference_production_title=Production table columns +preference_production_title_tt=This allows you to enable some additional columns in the production table +preference_production_done_column=Mark recipe as done +preference_production_done_column_tt=Adds a column with a button that allows you to mark a recipe as done, which serves as purely visual information for you and has no effect on the recipe itself +preference_production_percentage_column=Percentage +preference_production_percentage_column_tt=Adds a column with a textfield that allows you to specify the percentage of the demand for the recipe’s products to be fulfilled +preference_production_line_comment_column=Recipe comments +preference_production_line_comment_column_tt=Adds a column with a textfield so you can take notes on individual recipes + +preference_default_belts_title=Preferred belt +preference_default_belts_title_tt=Sets the type of belt that is used when calculating belt/lane-demand or when specifying products by an amount of belts/lanes +preference_belts_or_lanes_tt=Indicate whether you think of item throughput as individual lanes or as full belts +preference_default_pumps_title=Preferred pump +preference_default_pumps_title_tt=Sets the type of pump that is used when calculating throughput demand +preference_default_wagons_title=Preferred wagon +preference_default_wagons_title_tt=Sets the type of wagon that is used when calculating wagon demand + +preference_views_title=Item rate views +preference_views_title_tt=Configure which views for item rates you want to use, and in which order +preference_pick_views=Pick up to [font=default-semibold]4[/font] different views + +# Recipe dialog +recipe_instruction=Choose a recipe to __1__ ’__2__’ +show=Show +unresearched_recipes=Unresearched recipes +hidden_recipes=Hidden recipes +no_recipe_found=No recipes match your filter conditions + +# Generator +deposit=(Deposit) +lake=(Lake) +launch=(Launch) +mining_recipe=(Mining) +pumping_recipe=(Pumping) +planting_recipe=(Planting) +spoiling_recipe=(Spoiling) +agriculture_square=Agriculture Tower Square +agriculture_unit=squares +time=Time +time_unit=seconds +catalyst=Catalyst +recipe_title=[img=__1__] [font=default-semibold][color=255,230,192]__2__[/color][/font] +recipe_crafting_time=\n[font=default-semibold]Crafting Time:[/font] __1__s +recipe_header=\n[font=default-semibold]__1__:[/font] +recipe_none=\n None +recipe_item=\n [img=__1__] [font=default-semibold]__2__ ×[/font] __3__ +surface_property=\n[font=default-semibold][color=#FFE6C0]__1__:[/color][/font] __2__ +pollutant_type=Pollutant Type +time_minutes=__1__ __plural_for_parameter__2__{1=minute|rest=minutes}__ +time_seconds=__1__ __plural_for_parameter__2__{1=second|rest=seconds}__ +universal_location=Universal +universal_location_tt=[font=default-semibold]Universal planet[/font]\n\nThis location supports all recipes and machines, regardless of their surface limitations. +fluid_with_temperature=__1__ (__2__°C) +min_temperature=(≥__1__°C) +max_temperature=(≤__1__°C) +min_max_temperature=(__1__°C → __2__°C) +no_temperature_configured=\n[font=default-semibold][color=#FFE6C0]Temperature not configured![/color][/font] +configured_temperature=\nConfigured to [font=default-semibold]__1__°C[/font] + +# Modal dialogs +submit=Submit +delete=Delete +cancel=Cancel +confirm_dialog_tt=- Press __CONTROL__fp_confirm_dialog__ to confirm - +cancel_dialog_tt=- Press __CONTROL__toggle-menu__ to cancel - +search_button_tt=- Press __CONTROL__focus-search__ to focus searchfield - +reset_toggle_tt=Click to reset these values to their default state +reset_confirm_tt=Are you sure? +close_button_tt=- Press __CONTROL__toggle-menu__ to close - +searchfield_tt=Filter results using their natural language names +searchfield_not_ready_tt=Natural language search not ready yet, please wait.\nUse the [font=default-semibold]’/fp-restart-translation’[/font] console command to restart it. +warning_with_icon=[img=fp_warning_red] __1__ + +# Picker dialog +amount_by=Amount by __1__ +no_item_found=There are no items that match your search term +picker_issue_select_item=Select the item you want to add +picker_issue_enter_amount=Specify an amount by number or belt +picker_already_selected=__1__ is already selected + +# Factory dialog +factory_dialog_description=Choose a name for the factory +factory_dialog_name_tt=The factory name allows rich text, which can be added using the button on the right +factory_dialog_rich_text=Rich Text +factory_dialog_rich_text_tt=These selectors can be used to add rich text to the factory name. Typing or pasting in any rich text yourself also works, of course. +factory_dialog_signals=Signals +factory_dialog_recipes=Recipes + +# Machine dialog +machine_dialog_description=Configure the machine for ’__1__’ +machine_no_fuel_required=None required +machine_limit=Limit +machine_limit_tt=Limits the number of machines this line will use. The actual amount might be lower if fewer machines are needed. +machine_force_limit=Exact limit +machine_force_limit_tt=Forces the number of machines to be the exact amount specified above, even if this leads to overproduction. Only makes sense in combination with a machine limit. +machine_limit_unavailable=[font=default-semibold][color=#FF3333]unavailable[/color][/font] +machine_limit_unavailable_tt=Machine limits are incompatible with the matrix solver and certain recipes. +machine_effects_tt=Machines can have inherent effects, marked in [font=default-semibold][color=#7CFF01]green[/color][/font] below.\nTechnologies can also enable effects, marked in [font=default-semibold][color=#01FFF4]blue[/color][/font] below. + +# Beacon dialog +beacon_dialog_description=Configure the beacon for ’__1__’ +beacon_amount_tt=This specifies how many beacons each individual machine will be affected by. This needs to be an integer number so the profile can be applied correctly. +beacon_profile_tt=The amount the beacon’s effect will be multiplied by, which depends on how many beacons are configured per machine. +beacon_total=Total +beacon_total_tt=This specifies the total amount of beacons that you use for this recipe in your actual factory, which can then be incorporated into the energy consumption calculations. +beacon_selector_tt=Use a selection tool to count the beacons in your already-built factory +beacon_issue_set_amount=Enter a beacon amount greater than 0 +beacon_issue_no_modules=Select at least one module + +# Item dialog +item_dialog_description=Choose a temperature for ’__1__’ +compatible_temperatures=Compatible temperatures +item_temperature_tt=Pick the temperature that should be used to satisfy this fluid ingredient. +temperature_value=__1__°C + +# Module configurator +configurator_duplicate_module=Can’t have two identical modules with the same quality + +# Calculator dialog +calculator=Calculator +toggle_history_tt=Toggle history + +# Title bar +switch_to_compact_view=Switch to compact view\n- Press __CONTROL__fp_toggle_compact_view__ to switch - +pause_on_interface=Toggle to automatically pause the game in the background when the main interface is open (Singleplayer only)\n- Press __CONTROL__fp_toggle_pause__ to toggle pause - +open_calculator=Open Calculator\n- Press __CONTROL__fp_toggle_calculator__ to open calculator - +close_interface=Close this interface\n- Press __CONTROL__fp_toggle_interface__ to close - + +# District info +view_districts=Click to view or change district + +# Factory list +action_open_archive_tt=Open the factory archive\n__1__ +archive_empty=[font=default-semibold]- Is currently empty -[/font] +archive_filled=[font=default-semibold]- Currently contains __1__ __2__ -[/font] +action_close_archive_tt=Close the factory archive +action_import_factory=Import factories using a string generated in another save +action_export_factory=Export factories to a string that can be shared with others +action_archive_factory=Move the selected factory to the archive +action_unarchive_factory=Move the selected factory back out of archive +action_duplicate_factory=Duplicate the selected factory\n[font=default-semibold][color=#84CDEC]__CONTROL_KEY_SHIFT__ + __CONTROL_LEFT_CLICK__[/color][/font] to add duplicate right below +action_add_factory_by_name=Create a new factory\n[font=default-semibold][color=#84CDEC]__CONTROL_KEY_SHIFT__ + __CONTROL_LEFT_CLICK__[/color][/font] to choose a product right away +action_add_factory_by_product=Pick an item for a new factory\n[font=default-semibold][color=#84CDEC]__CONTROL_KEY_SHIFT__ + __CONTROL_LEFT_CLICK__[/color][/font] to give it a name first +action_edit_factory=Edit the name of the selected factory +action_trash_factory=Trash the selected factory\nIt will be preserved in the archive for __1__ minutes +action_delete_factory=Delete the selected factory irreversibly +factory_trashed=\n[font=default-bold]Trashed[/font] - automatic deletion in __1__ __plural_for_parameter__1__{1=minute|rest=minutes}__ +factory_invalid=\n[font=default-bold]Invalid[/font] - needs to be repaired + +# Districts box +edit_name=Edit name +save_name=Save name +u_select=Select +u_selected=Selected +add_district=New District +location_tt=Locations set the applicable surface properties for crafting and the pollution type. +item_amount_production=\nSurplus of __1__ +item_amount_consumption=\nShortage of __1__ +item_amount_total=\nTotal of __1__ +toggle_district_items_tt=Toggle visibility of this district’s items + +# Item boxes +ingredients_to_combinator_tt=Convert all the ingredients of this factory into a constant combinator in your cursor. + +# Views +view_tt=__1__\n- Press __CONTROL__fp_cycle_production_views__/__CONTROL__fp_reverse_cycle_production_views__ to cycle - +items_per_timescale=Show item rates as [font=default-bold]per __1__[/font]. +throughput=Show item rates as a number of [font=default-bold]__1__[/font] for items or [font=default-bold]pumps[/font] for fluids.\nConfigured to use __2__ [font=default-bold]__3__[/font] or __4__ [font=default-bold]__5__[/font]__6__. +items_per_second_per_machine=Show item rates as [font=default-bold]per second per machine[/font]. This helps estimating how many inserters will be needed. +stacks_per_timescale=Show item rates as [img=fp_stack] [font=default-bold]stacks per __1__[/font]. Doesn’t apply to fluids. +wagons_per_timescale=Show item rates as full [font=default-bold]train wagons per __1__[/font].\nConfigured to use __2__ [font=default-bold]__3__[/font]__4__ or __5__ [font=default-bold]__6__[/font]__7__. +rockets_per_timescale=Show item rates as full [img=fp_silo_rocket] [font=default-bold]rockets per __1__[/font]. Doesn’t apply to fluids. +per_timescale=per __1__ +fluid_item=(fluid amount not applicable) +item_too_heavy=[font=default-bold]Too heavy for rockets![/font] + +# Production bar +refresh_production=- Press __CONTROL__fp_refresh_production__ to refresh - +level=Level +floor_up_tt=Go up a floor level\n- Press __CONTROL__fp_up_floor__ to go up - +floor_top_tt=Go the the top floor\n- Press __CONTROL__fp_top_floor__ to go to the top - +fold_out_subfloors_tt=Show all subfloors at the top level.\n- Press __CONTROL__fp_toggle_fold_out_subfloors__ to toggle - +utility_dialog_tt=Open the utility dialog +timescale_tt=Sets the time frame for the production. It means that any of the numbers shown are [font=default-semibold]per second[/font] or [font=default-semibold]per minute[/font]. + +# Production box +production_instruction_factory=Add a factory by clicking the green ’+’-button in the top left +production_instruction_product=Add a product by clicking the gray ’+’-button in the products-box above +production_instruction_recipe=Add a recipe by left-clicking a product in the products-box above +paste_line=Paste line +paste_line_tt=Paste the line in the clipboard to the bottom of this floor. +linearly_dependent_recipes=Linearly dependent recipes +linearly_dependent_recipes_tt=The matrix solver detected linearly dependent recipes. Make sure the following items are not produced by more than one recipe. +choose_unrestricted_items=Choose unrestricted items +choose_unrestricted_items_tt=Choose [font=default-bold]__1__[/font] unrestricted __2__ in total, so the matrix solver can determine a unique solution.\n\nThe left side lists the constrained items, while the right side contains the unrestricted ones. Click on any item to move it to the other side.\n\nUnrestricted items may become byproducts or ingredients, depending on the solver’s solution. +unrestricted_items_balanced=Unrestricted items balanced +unrestricted_items_balanced_tt=The current selection of unrestricted items is balanced.\n\nTo choose different unrestricted items, constrain one of the current ones first. +last_recipe_items=From last recipe +other_items=Other Items +turn_unrestricted=Switch [font=default-bold]__1__[/font] to be constrained +turn_constrained=Switch [font=default-bold]__1__[/font] to be unrestricted +solver_choice=Solver +solver_choice_tt=Choose which of the solvers to use for this factory. The traditional one works by going through your recipes in order and figuring out their needs. The matrix solver on the other hand can deal with loops and byproducts, but sometimes needs additional configuration. +solver_choice_traditional=Traditional +solver_choice_matrix=Matrix +factory_needs_repair=The active mods have changed, breaking this factory. Load the previous modset, or repair to remove any invalid parts. +modset_differences=Modset differences [img=info] +repair_factory=Repair +factory_modset_changes=Your active mods changed. +factory_mod_removed=\n\n[color=#FF3333][font=default-bold]These mods were removed:[/font][/color] +factory_mod_added=\n\n[color=#33CC33][font=default-bold]These mods were added:[/font][/color] +factory_mod_updated=\n\n[color=#CCCC00][font=default-bold]These mods were updated:[/font][/color] +factory_mod_and_version=\n__1__ (v__2__) +factory_mod_and_versions=\n__1__: v__2__ → v__3__ + +# Production table +column_done_tt=Mark a recipe as built to keep track of your progress.\n\nThis removes its building blocks from the components list in the utility dialog. +column_percentage_tt=This percentage determines how much of the demand for the products that this recipe produces should actually be fulfilled by this recipe. The calculation only updates after you confirm your changes by pressing [font=default-semibold][color=#84CDEC]Enter[/color][/font]. +column_comment=Comment +recipe_subfloor_attached=\n[Subfloor attached] +floor_recipe=\n[Floor recipe] +recipe_inactive=recipe inactive +recipe_consumes_byproduct=consumes byproduct +subfloor_machine_count=__1__ __2__ in use on this subfloor +machine_limit_force=Exact limit set [__1__] +machine_limit_set=Limit set [__1__] +add_machine_module=Add a module +add_beacon=Add a beacon +in_total=__1__ in total +emissions_line=__1__: __2__ +emissions_none=No emissions +blocking_condition=\n[color=#FF3333]Location can’t use this __1__[/color] +incompatible_solver=\n[color=#FF3333]Solver doesn’t support byproduct recipes[/color] +priority_product=Priority Product +can_only_edit_fluids=Only fluid ingredients have a temperature to configure +can_only_edit_lines=Ingredients can only be edited directly on their recipe + +# Compact frame +switch_to_main_view=Switch to main view\n- Press __CONTROL__fp_toggle_compact_view__ to switch - +compact_toggle_ingredients=Toggle factory ingredients + +# Clipboard +copied_into_clipboard=__1__ copied +pasted_from_clipboard=__1__ pasted +clipboard_empty=Your clipboard is empty +clipboard_incompatible_class=Can’t paste __1__ on __2__ +clipboard_incompatible=Pasted __1__ is incompatible +clipboard_already_exists=Pasted __1__ already exists +clipboard_no_empty_slots=No empty module slots +clipboard_recipe_irrelevant=Pasted line irrelevant to this floor + +# Modifier actions +action_click=[font=default-semibold][color=#84CDEC]__1__[/color][/font] +action_line=\n[font=default-semibold][color=#84CDEC]__1__:[/color][/font] __2__ +action_all=\n[font=default-semibold][color=#84CDEC]__CONTROL_RIGHT_CLICK__[/color] for all actions[/font] + +action_left=__CONTROL_LEFT_CLICK__ +action_right=__CONTROL_RIGHT_CLICK__ +action_shift=__CONTROL_KEY_SHIFT__ +action_control=__CONTROL_KEY_CTRL__ +action_alt=Alt + +action_select=Select +action_edit=Edit +action_delete=Delete +action_copy=Copy +action_paste=Paste +action_add_recipe=Add recipe +action_open_subfloor=Open subfloor +action_toggle=Toggle +action_set_limit=Set a limit +action_add_to_cursor=Add to cursor +action_add_recipe_to_end=Add recipe +action_add_recipe_below=Add recipe below +action_prioritize=Prioritize +action_pick_up=Pick up +action_factoriopedia=Open in Factoriopedia +action_move_left=Move left +action_move_right=Move right + +# Effects tooltip +effect_line=__1__:__2____3____4____5__ +effect_value=[font=default-semibold][color=__1__] __2__%[/color][/font] +consumption=Energy consumption +speed=Speed +productivity=Productivity +pollution=Pollution +quality=Quality + +# Defaults frame +defaults=Defaults +current_default=[font=default-bold]Current default[/font] +save_as_default_machine=[font=default-semibold]Set as default[/font]\nSave the current machine and its modules as the default for its category. +save_as_default_fuel=[font=default-semibold]Set as default[/font]\nSave the current fuel as the default for its category. +save_as_default_beacon=[font=default-semibold]Set as default[/font]\nSave the current beacon and its modules as the default. +save_for_all_machine=[font=default-semibold]Set for all categories[/font]\nSave the current machine and its modules as the default for all categories it is a part of. +save_for_all_fuel=[font=default-semibold]Set for all categories[/font]\nSave the current fuel as the default for all categories it is a part of. +save_beacon_amount=[font=default-semibold]Set amount[/font]\nSave the current beacon amount as the default. + +# Messages +error_no_relevant_recipe=No existing recipe produces this item +error_no_enabled_recipe=No enabled recipe produce this item (enable in Preferences) +error_no_compatible_machine=No existing machine can craft this recipe +error_no_subfloor_on_byproduct_recipes=Recipes that consume byproducts can’t have subfloors +error_no_new_subfloors_in_archive=Can’t add subfloors to archived factories +error_no_main_recipe_on_subfloor=Can’t add a recipe for a top floor item to a subfloor +warning_recipe_disabled="__1__" is not researched yet, so you can’t produce that recipe currently +warning_surface_not_compatible="__1__" is not compatible with the current location and has been disabled +warning_no_prioritizing_single_product=Recipes with a single relevant product can’t be prioritized + +# Units +prefix_kilo=k +prefix_mega=M +prefix_giga=G +prefix_tera=T +prefix_peta=P +prefix_exa=E +prefix_zetta=Z +prefix_yotta=Y +unit_watt=W +unit_joule=J +unit_emissions=E +unit_second=s +unit_minute=m +unit_hour=h +second=second +minute=minute +hour=hour + +# General +error_message=[color=#FF3333]__1__[/color] +warning_message=[color=#CCCC00]__1__[/color] +hint_message=[color=#33CC33]__1__[/color] +info_label=__1__ [img=info] +bold_label=[font=default-bold]__1__[/font] + +tt_title=[font=default-semibold]__1__[/font] +tt_title_with_note=[font=default-semibold]__1__[/font] (__2__) + +toggle_interface=__CONTROL__fp_toggle_interface__ + +selected=selected +preferred=preferred +satisfied=satisfied +valid=valid +invalid=invalid +increased=increased +decreased=decreased +capped=capped + +effects=effects +amount=Amount +name=Name + +on=On +off=Off +left=left +right=right +up=up +down=down +top=top +bottom=bottom + +add=Add +new=New +edit=Edit +choose=Choose +produce=produce +consume=consume + +combinator=Combinator +add_to_cursor_failed=__1__ can’t be added to the cursor +inserter_only_filters_items=Inserters can’t filter fluids +inserter_has_no_filters=This inserter can’t be filtered +inserter_filter_limit_reached=This inserter can’t have more filters +no_removal=__1__ can’t be removed +none=none + +expression_textfield=This textfield accepts mathematical expressions, like 2 + 2. It shows in red if the current expression is invalid. Pressing enter calculates the result and sets it as the new value. + +move_object=Move this __1__ __2__ +move_object_instructions=\n[font=default-semibold][color=#84CDEC]__CONTROL_KEY_CTRL__ + __CONTROL_LEFT_CLICK__[/color][/font] to move it by 5 spots\n[font=default-semibold][color=#84CDEC]__CONTROL_KEY_SHIFT__ + __CONTROL_LEFT_CLICK__[/color][/font] to move it to the __1__ +shift_to_paste=[font=default-semibold][color=#84CDEC]__CONTROL_KEY_SHIFT__ + __CONTROL_LEFT_CLICK__[/color][/font] to paste + +mod_reset=[color=#FF3333]Factory Planner has been reset![/color] Versions prior to 1.1.60 can not be migrated to this version, so any user data has been deleted. If you want to bring this save up to date, load it with Factory Planner version 1.1.60, save it, then load it with this version. [color=#CCCC00]DO NOT save this map if you want to keep the old data![/color] + +# Locale prefixes: s = singular; p = plural; l = lowercase; u = uppercase +pl_district=__plural_for_parameter__1__{1=district|rest=districts}__ +pu_district=__plural_for_parameter__1__{1=District|rest=Districts}__ +pl_factory=__plural_for_parameter__1__{1=factory|rest=factories}__ +pu_factory=__plural_for_parameter__1__{1=Factory|rest=Factories}__ +pl_floor=__plural_for_parameter__1__{1=floor|rest=floors}__ +pu_floor=__plural_for_parameter__1__{1=Floor|rest=Floors}__ +pl_line=__plural_for_parameter__1__{1=line|rest=lines}__ +pu_line=__plural_for_parameter__1__{1=Line|rest=Lines}__ +pl_item=__plural_for_parameter__1__{1=item|rest=items}__ +pu_item=__plural_for_parameter__1__{1=Item|rest=Items}__ +pl_fluid=__plural_for_parameter__1__{1=fluid|rest=fluids}__ +pu_fluid=__plural_for_parameter__1__{1=Fluid|rest=Fluids}__ +pl_simpleitem=__plural_for_parameter__1__{1=item|rest=items}__ +pu_simpleitem=__plural_for_parameter__1__{1=Item|rest=Items}__ +pl_product=__plural_for_parameter__1__{1=product|rest=products}__ +pu_product=__plural_for_parameter__1__{1=Product|rest=Products}__ +pl_byproduct=__plural_for_parameter__1__{1=byproduct|rest=byproducts}__ +pu_byproduct=__plural_for_parameter__1__{1=Byproduct|rest=Byproducts}__ +pl_ingredient=__plural_for_parameter__1__{1=ingredient|rest=ingredients}__ +pu_ingredient=__plural_for_parameter__1__{1=Ingredient|rest=Ingredients}__ +pl_fuel=__plural_for_parameter__1__{1=fuel|rest=fuels}__ +pu_fuel=__plural_for_parameter__1__{1=Fuel|rest=Fuels}__ +pl_recipe=__plural_for_parameter__1__{1=recipe|rest=recipes}__ +pu_recipe=__plural_for_parameter__1__{1=Recipe|rest=Recipes}__ +pl_machine=__plural_for_parameter__1__{1=machine|rest=machines}__ +pu_machine=__plural_for_parameter__1__{1=Machine|rest=Machines}__ +pl_module=__plural_for_parameter__1__{1=module|rest=modules}__ +pu_module=__plural_for_parameter__1__{1=Module|rest=Modules}__ +pl_beacon=__plural_for_parameter__1__{1=beacon|rest=beacons}__ +pu_beacon=__plural_for_parameter__1__{1=Beacon|rest=Beacons}__ +pl_belt=__plural_for_parameter__1__{1=belt|rest=belts}__ +pu_belt=__plural_for_parameter__1__{1=Belt|rest=Belts}__ +pl_lane=__plural_for_parameter__1__{1=lane|rest=lanes}__ +pu_lane=__plural_for_parameter__1__{1=Lane|rest=Lanes}__ +pl_pump=__plural_for_parameter__1__{1=pump|rest=pumps}__ +pu_pump=__plural_for_parameter__1__{1=Pump|rest=Pumps}__ +pl_wagon=__plural_for_parameter__1__{1=wagon|rest=wagons}__ +pu_wagon=__plural_for_parameter__1__{1=Wagon|rest=Wagons}__ +pl_stack=__plural_for_parameter__1__{1=stack|rest=stacks}__ +pu_stack=__plural_for_parameter__1__{1=Stack|rest=Stacks}__ +pl_location=__plural_for_parameter__1__{1=location|rest=locations}__ +pu_location=__plural_for_parameter__1__{1=Location|rest=Locations}__ +pl_rocket=__plural_for_parameter__1__{1=rocket|rest=rockets}__ +pu_rocket=__plural_for_parameter__1__{1=Rocket|rest=Rockets}__ + +l_fluid=fluid +u_power=Power +u_factory=Factory +u_archive=Archive +u_production=Production diff --git a/modfiles/prototypes/hotkeys.lua b/modfiles/prototypes/hotkeys.lua new file mode 100644 index 000000000..07a1dccd5 --- /dev/null +++ b/modfiles/prototypes/hotkeys.lua @@ -0,0 +1,31 @@ +---@diagnostic disable + +local order_string = "abcdefghijklmnopqrstuvwxyz" +local order_counter = 0 + +local function add_hotkey(name, sequence, alternate, consuming, linked) + order_counter = order_counter + 1 + data:extend{{ + type = "custom-input", + name = "fp_" .. name, + key_sequence = sequence, + alternative_key_sequence = alternate, + consuming = consuming, + linked_game_control = linked, + order = order_string:sub(order_counter, order_counter) + }} +end + +add_hotkey("toggle_interface", "CONTROL + R", nil, "game-only", nil) +add_hotkey("toggle_compact_view", "CONTROL + SHIFT + R", nil, "game-only", nil) +add_hotkey("toggle_pause", "CONTROL + P", nil, "none", nil) +add_hotkey("refresh_production", "R", nil, "none", nil) +add_hotkey("up_floor", "ALT + UP", nil, "none", nil) +add_hotkey("top_floor", "SHIFT + ALT + UP", nil, "none", nil) +add_hotkey("toggle_fold_out_subfloors", "SHIFT + F", nil, "none", nil) +add_hotkey("cycle_production_views", "CONTROL + RIGHT", nil, "none", nil) +add_hotkey("reverse_cycle_production_views", "CONTROL + LEFT", nil, "none", nil) +add_hotkey("confirm_dialog", "ENTER", "KP_ENTER", "none", nil) +add_hotkey("toggle_calculator", "CONTROL + SHIFT + C", nil, "game-only", nil) +add_hotkey("confirm_gui", "", nil, nil, "confirm-gui") +add_hotkey("focus_searchfield", "", nil, nil, "focus-search") diff --git a/modfiles/prototypes/shortcuts.lua b/modfiles/prototypes/shortcuts.lua new file mode 100644 index 000000000..d1d3ec144 --- /dev/null +++ b/modfiles/prototypes/shortcuts.lua @@ -0,0 +1,16 @@ +---@diagnostic disable + +data:extend({ + { + type = "shortcut", + name = "fp_open_interface", + action = "lua", + toggleable = false, + order = "fp-a[open]", + associated_control_input = "fp_toggle_interface", + icon = "__factoryplanner__/graphics/shortcut_open_x56.png", + icon_size = 56, + small_icon = "__factoryplanner__/graphics/shortcut_open_x24.png", + small_icon_size = 24 + } +}) diff --git a/modfiles/prototypes/sprites.lua b/modfiles/prototypes/sprites.lua new file mode 100644 index 000000000..aa1afd94c --- /dev/null +++ b/modfiles/prototypes/sprites.lua @@ -0,0 +1,50 @@ +---@diagnostic disable + +local function add_sprite(name, filename, size, mipmaps) + data:extend{{ + type = "sprite", name = "fp_" .. name, + filename = "__factoryplanner__/graphics/" .. (filename or (name .. ".png")), + size = size, mipmap_count = mipmaps, flags = {"gui-icon"} + }} +end + +add_sprite("mod_gui", "shortcut_open_x56.png", 56, nil) +add_sprite("zone_selection", nil, 32, nil) +add_sprite("generic_assembler", nil, 64, 2) +add_sprite("white_square", nil, 8, 2) +add_sprite("warning_red", nil, 32, 2) +add_sprite("trash_red", nil, 32, 2) +add_sprite("archive", nil, 32, 2) +add_sprite("arrow_up", nil, 32, 2) +add_sprite("arrow_down", nil, 32, 2) +add_sprite("arrow_line_up", nil, 32, 2) +add_sprite("arrow_line_bar_up", nil, 32, 2) +add_sprite("pin", nil, 32, 2) +add_sprite("silo_rocket", nil, 120, 1) +add_sprite("agriculture_square", nil, 120, 1) +add_sprite("play", nil, 32, 2) +add_sprite("limited_up", nil, 20, 1) +add_sprite("limited_down", nil, 20, 1) +add_sprite("stack", nil, 64, 1) +add_sprite("calculator", nil, 64, 1) +add_sprite("history", nil, 32, 1) +add_sprite("plus", nil, 32, 1) +add_sprite("minus", nil, 32, 1) +add_sprite("multiply", nil, 32, 1) +add_sprite("divide", nil, 32, 1) +add_sprite("default", nil, 32, 1) +add_sprite("default_all", nil, 32, 1) +add_sprite("amount", nil, 32, 1) +add_sprite("dropup", nil, 32, 2) +add_sprite("fold_out_subfloors", nil, 32, 1) +add_sprite("universal_planet", nil, 64, 1) +add_sprite("collapse", nil, 32, 1) +add_sprite("expand", nil, 32, 1) + + +-- Base game sprites +data:extend{{ + type = "sprite", name = "fp_panel", + filename = "__core__/graphics/icons/mip/expand-panel-black.png", + size = 64, flags = {"gui-icon"} +}} diff --git a/modfiles/prototypes/styles.lua b/modfiles/prototypes/styles.lua new file mode 100644 index 000000000..1ceb6cb21 --- /dev/null +++ b/modfiles/prototypes/styles.lua @@ -0,0 +1,237 @@ +---@diagnostic disable + +local styles = data.raw["gui-style"].default + +styles["fp_naked_frame"] = { + type = "frame_style", + parent = "frame", + padding = 0, + graphical_set = {} +} + +local function light_slots(size) + return { + position = {256, 136}, + corner_size = 16, + overall_tiling_vertical_size = size - 12, + overall_tiling_horizontal_size = size - 12, + overall_tiling_vertical_spacing = 12, + overall_tiling_horizontal_spacing = 12, + overall_tiling_vertical_padding = 6, + overall_tiling_horizontal_padding = 6 + } +end + +styles["fp_frame_light_slots"] = { + type = "frame_style", + parent = "fp_naked_frame", + background_graphical_set = light_slots(40) +} + +-- normal slots table is called filter_slot_table + +styles["fp_frame_light_slots_small"] = { + type = "frame_style", + parent = "fp_naked_frame", + background_graphical_set = light_slots(36) +} + +styles["fp_table_slots_small"] = { + type = "table_style", + parent = "slot_table", + wide_as_column_count = true, + column_widths = { + width = 36 + } +} + +styles["fp_frame_bordered_stretch"] = { + type = "frame_style", + parent = "bordered_frame", + horizontally_stretchable = "on" +} + +styles["fp_frame_module"] = { + type = "frame_style", + parent = "fp_frame_bordered_stretch", + padding = 8, + horizontal_flow_style = { + type = "horizontal_flow_style", + horizontal_spacing = 8, + vertical_align = "center" + } +} + +styles["fp_frame_semitransparent"] = { + type = "frame_style", + graphical_set = { + base = { + type = "composition", + filename = "__factoryplanner__/graphics/semitransparent_pixel.png", + corner_size = 1, + position = {0, 0} + } + } +} + +styles["fp_table_production"] = { + type = "table_style", + odd_row_graphical_set = { + filename = "__core__/graphics/gui-new.png", + position = {472, 25}, + size = 1 + } +} + +styles["fp_drop-down_slim"] = { + type = "dropdown_style", + minimal_width = 70, + height = 24, + top_padding = -2, + right_padding = 2, + bottom_padding = 0, + left_padding = 4 +} + +-- This style is hacked together from rounded-button and textbox +styles["fp_sprite-button_inset"] = { + type = "button_style", + size = 32, + padding = 0, + default_graphical_set = styles.textbox.default_background, + hovered_graphical_set = styles.rounded_button.clicked_graphical_set, + clicked_graphical_set = styles.textbox.active_background, + disabled_graphical_set = styles.rounded_button.disabled_graphical_set +} + +styles["fp_sprite-button_group_tab"] = { + type = "button_style", + parent = "filter_group_button_tab_slightly_larger", + horizontally_stretchable = "on", + width = 0, -- allows stretching + padding = 1 +} + +-- frame_action_button but correct +styles["fp_button_frame"] = { + type = "button_style", + parent = "frame_action_button", + selected_graphical_set = styles.frame_button.clicked_graphical_set, + selected_hovered_graphical_set = styles.frame_button.hovered_graphical_set, + selected_clicked_graphical_set = styles.frame_button.default_graphical_set, +} + +-- Text button in the style of icon tool buttons, for use in the title bar +styles["fp_button_frame_tool"] = { + type = "button_style", + parent = "frame_button", + font = "heading-2", + default_font_color = {0.9, 0.9, 0.9}, + minimal_width = 0, + height = 24, + right_padding = 8, + left_padding = 8 +} + +styles["fp_button_push"] = { + type = "button_style", + height = 26, + minimal_width = 0, + top_padding = 0, + right_padding = 8, + bottom_padding = 0, + left_padding = 8 +} + +styles["fp_sprite-button_rounded_sprite"] = { + type = "button_style", + parent = "rounded_button", + size = 26, + padding = 2 +} + +styles["fp_sprite-button_rounded_icon"] = { + type = "button_style", + parent = "fp_sprite-button_rounded_sprite", + invert_colors_of_picture_when_disabled = true +} + +styles["fp_sprite-button_move"] = { + type = "button_style", + parent = "list_box_item", + invert_colors_of_picture_when_hovered_or_toggled = true +} + +-- Need to copy this style to get rid of the stupid built-in tooltip +styles["fp_button_green"] = { + type = "button_style", + parent = "button", + default_graphical_set = { + base = {position = {68, 17}, corner_size = 8}, + shadow = default_dirt + }, + hovered_graphical_set = { + base = {position = {102, 17}, corner_size = 8}, + shadow = default_dirt, + glow = default_glow(green_button_glow_color, 0.5) + }, + clicked_graphical_set = { + base = {position = {119, 17}, corner_size = 8}, + shadow = default_dirt + }, + disabled_graphical_set = { + base = {position = {85, 17}, corner_size = 8}, + shadow = default_dirt + }, + left_click_sound = "__core__/sound/gui-green-confirm.ogg" +} + +-- Generate smaller versions of flib's slot buttons (size 36) +for _, color in pairs{"default", "grey", "red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink"} do + styles["flib_slot_button_" .. color .. "_small"] = { + type = "button_style", + parent = "flib_slot_button_" .. color, + size = 36 + } +end + +styles["flib_slot_button_disabled"] = { + type = "button_style", + parent = "flib_slot_button_default", + default_graphical_set = {}, + hovered_graphical_set = {}, + clicked_graphical_set = {}, + disabled_graphical_set = {}, + padding = 4 +} + +styles["flib_slot_button_disabled_small"] = { + type = "button_style", + parent = "flib_slot_button_disabled", + size = 36 +} + +styles["flib_slot_button_grayscale_small"] = { + type = "button_style", + parent = "flib_slot_button_default_small", + draw_grayscale_picture = true +} + +styles["flib_slot_button_disabled_grayscale_small"] = { + type = "button_style", + parent = "flib_slot_button_disabled_small", + draw_grayscale_picture = true +} + + +styles["fp_label_module_error"] = { + type = "label_style", + font = "heading-2", + padding = 2 +} + +styles["fp_label_frame_title"] = { + type = "label_style", + parent = "frame_title", + top_margin = -3 +} diff --git a/modfiles/prototypes/tools.lua b/modfiles/prototypes/tools.lua new file mode 100644 index 000000000..498b4a148 --- /dev/null +++ b/modfiles/prototypes/tools.lua @@ -0,0 +1,27 @@ +---@diagnostic disable + +data:extend({ + { + type = "selection-tool", + name = "fp_beacon_selector", + icon = "__factoryplanner__/graphics/beacon_selector.png", + icon_size = 32, + flags = {"only-in-cursor"}, + subgroup = "other", + order = "z_fp1", + hidden = true, + stack_size = 1, + select = { + mode = "entity-with-health", + border_color = { r = 0.75, g = 0, b = 0.75 }, + cursor_box_type = "entity", + entity_filter_mode = "whitelist", + entity_type_filters = {"beacon"} + }, + alt_select = { + mode = "nothing", + border_color = { r = 0.75, g = 0, b = 0.75 }, + cursor_box_type = "entity" + } + } +}) diff --git a/modfiles/thumbnail.png b/modfiles/thumbnail.png new file mode 100644 index 000000000..f138de90e Binary files /dev/null and b/modfiles/thumbnail.png differ diff --git a/modfiles/ui/base/calculator_dialog.lua b/modfiles/ui/base/calculator_dialog.lua new file mode 100644 index 000000000..53573916a --- /dev/null +++ b/modfiles/ui/base/calculator_dialog.lua @@ -0,0 +1,192 @@ +-- This isn't a standard dialog so it can be opened independently of others + +local function style_textfield(textfield, style) + textfield.style = style + textfield.style.margin = {0, 8, 12, 8} + textfield.style.horizontal_align = "right" + textfield.style.font = "default-large-semibold" + textfield:focus() +end + +local function run_calculation(player) + local calculator_elements = util.globals.ui_state(player).calculator_elements + local textfield = calculator_elements.textfield + local expression = tostring(util.gui.parse_expression_field(textfield)) + + if expression == "nil" then + style_textfield(textfield, "invalid_value_textfield") + else + if expression ~= textfield.text then -- avoid x = x label + local history_frame = calculator_elements.history_frame + local caption = textfield.text .. " = [font=default-semibold]" .. expression .. "[/font]" + history_frame.add{type="label", caption=caption, index=1} + + local children = history_frame.children + if #children > 15 then children[#children].destroy() end + end + + style_textfield(textfield, "textbox") + textfield.text = expression + end +end + +local function handle_button_click(player, tags, _) + local textfield = util.globals.ui_state(player).calculator_elements.textfield + local action = tags.action + + if action == "=" then + run_calculation(player) + else + if action == "AC" then + textfield.text = "" + elseif action == "DEL" then + textfield.text = string.sub(textfield.text, 1, -2) + else + textfield.text = textfield.text .. action + end + -- Reset textfield so it doesn't stay red + style_textfield(textfield, "textbox") + end +end + +local button_layout = { + {"AC", "(", ")", "/"}, + {"7", "8", "9", "*"}, + {"4", "5", "6", "-"}, + {"1", "2", "3", "+"}, + {"DEL", "0", ".", "="} +} + +local alternate_labels = { + ["+"] = "[img=fp_plus]", + ["-"] = "[img=fp_minus]", + ["*"] = "[img=fp_multiply]", + ["/"] = "[img=fp_divide]" +} + +local alternate_colors = { + ["AC"] = {0.8, 0.8, 0.8}, + ["DEL"] = {0.8, 0.8, 0.8}, + ["="] = {0.8, 0.8, 0.8}, + ["("] = {0.7, 0.7, 0.7}, + [")"] = {0.7, 0.7, 0.7} +} + +local function build_calculator_dialog(player, elements) + -- Not visible by default so it can be toggled right after + local frame = player.gui.screen.add{type="frame", visible=false, direction="vertical"} + + -- Titlebar + local flow_title = frame.add{type="flow", direction="horizontal", style="frame_header_flow"} + flow_title.drag_target = frame + flow_title.add{type="label", caption={"fp.calculator"}, style="fp_label_frame_title", ignored_by_interaction=true} + flow_title.add{type="empty-widget", style="flib_titlebar_drag_handle", ignored_by_interaction=true} + + flow_title.add{type="sprite-button", sprite="fp_history", tooltip={"fp.toggle_history_tt"}, style="fp_button_frame", + tags={mod="fp", on_gui_click="toggle_calculator_history"}, auto_toggle=true, mouse_button_filter={"left"}} + + local close_button = flow_title.add{type="sprite-button", sprite="utility/close", style="fp_button_frame", + tags={mod="fp", on_gui_click="close_calculator_dialog"}, mouse_button_filter={"left"}} + close_button.style.padding = 1 + + + local horizontal_flow = frame.add{type="flow", direction="horizontal"} + horizontal_flow.style.horizontal_spacing = 12 + + -- Subheader + local main_frame = horizontal_flow.add{type="frame", direction="vertical", style="inside_shallow_frame"} + local subheader = main_frame.add{type="frame", direction="horizontal", style="subheader_frame"} + subheader.style.maximal_height = 100 + + local textfield = subheader.add{type="textfield", clear_and_focus_on_right_click=true, + tags={mod="fp", on_gui_click="focus_textfield", on_gui_confirmed="calculator_input"}} + style_textfield(textfield, "textbox") + elements.textfield = textfield + + -- Buttons + local button_table = main_frame.add{type="table", column_count=4} + button_table.style.horizontal_spacing = 0 + button_table.style.vertical_spacing = 0 + for _, button_row in pairs(button_layout) do + for _, action in pairs(button_row) do + local label = alternate_labels[action] or action + local button = button_table.add{type="button", caption=label, style="side_menu_button", + tags={mod="fp", on_gui_click="calculator_button", action=action}} + button.style.size = 56 + button.style.font = "default-large-semibold" + button.style.font_color = alternate_colors[action] or {1, 1, 1} + end + end + + -- History + local history_frame = horizontal_flow.add{type="frame", direction="vertical", visible=false, + style="inside_shallow_frame"} + history_frame.style.size = {240, 328} + history_frame.style.padding = {14, 12} + elements.history_frame = history_frame + + frame.force_auto_center() + return frame +end + +local function toggle_calculator_dialog(player) + local ui_state = util.globals.ui_state(player) + local dialog = ui_state.calculator_elements.frame + + if not dialog or not dialog.valid then + dialog = build_calculator_dialog(player, ui_state.calculator_elements) + ui_state.calculator_elements.frame = dialog + end ---@cast dialog -nil + + dialog.bring_to_front() + dialog.visible = not dialog.visible + -- No player.opened so it can be concurrent +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { -- central place to catch calculator buttons + name = "open_calculator_dialog", + handler = toggle_calculator_dialog + }, + { + name = "close_calculator_dialog", + handler = toggle_calculator_dialog + }, + { + name = "toggle_calculator_history", + handler = (function(player, _, _) + local ui_state = util.globals.ui_state(player) + local history_frame = ui_state.calculator_elements.history_frame + history_frame.visible = not history_frame.visible + end) + }, + { + name = "focus_textfield", + handler = (function(player, _, _) + local calculator_elements = util.globals.ui_state(player).calculator_elements + calculator_elements.textfield.select_all() + end) + }, + { + name = "calculator_button", + handler = handle_button_click + } + }, + on_gui_confirmed = { + { + name = "calculator_input", + handler = run_calculation + } + } +} + +listeners.misc = { + fp_toggle_calculator = toggle_calculator_dialog +} + +return { listeners } diff --git a/modfiles/ui/base/compact_dialog.lua b/modfiles/ui/base/compact_dialog.lua new file mode 100644 index 000000000..8f228b390 --- /dev/null +++ b/modfiles/ui/base/compact_dialog.lua @@ -0,0 +1,843 @@ +-- The main GUI parts for the compact dialog +local function determine_available_columns(floor, frame_width) + local frame_border_size = 12 + local table_padding, table_spacing = 8, 12 + local recipe_and_check_width = 58 + local button_width, button_spacing = 36, 4 + + local max_module_count = 0 + for line in floor:iterator() do + if line.class == "Line" then + local module_kinds = line.machine.module_set:count() + max_module_count = math.max(max_module_count, module_kinds) + end + if line.beacon ~= nil then + local module_kinds = line.beacon.module_set:count() + max_module_count = math.max(max_module_count, module_kinds) + end + end + + local used_width = 0 + used_width = used_width + (frame_border_size * 2) -- border on both sides + used_width = used_width + (table_padding * 2) -- padding on both sides + used_width = used_width + (table_spacing * 4) -- 5 columns -> 4 spaces + used_width = used_width + recipe_and_check_width -- constant + -- Add up machines button, module buttons, and spacing for them + used_width = used_width + button_width + (max_module_count * button_width) + (max_module_count * button_spacing) + + -- Calculate the remaining width and divide by the amount a button takes up + local available_columns = (frame_width - used_width + button_spacing) / (button_width + button_spacing) + return math.floor(available_columns) -- amount is floored as to not cause a horizontal scrollbar +end + +local function determine_table_height(floor, column_counts) + local total_height = 0 + for line in floor:iterator() do + local items_height = 0 + for column, count in pairs(column_counts) do + local item_count = #line[column .. "s"] + + if line.class == "Line" then + if column == "ingredient" and line.machine.fuel then item_count = item_count + 1 end + local catalysts = line.recipe_proto.catalysts[column .. "s"] + if catalysts then item_count = item_count + table_size(catalysts) end + end + + local column_height = math.ceil(item_count / count) + items_height = math.max(items_height, column_height) + end + + local machines_height = (line.beacon ~= nil) and 2 or 1 + total_height = total_height + math.max(machines_height, items_height) + end + return total_height +end + +local function determine_column_counts(floor, available_columns) + local column_counts = {ingredient = 1, product = 1, byproduct = 0} -- ordered by priority + available_columns = available_columns - 2 -- two buttons are already assigned + + local previous_height, increment = math.huge, 1 + while available_columns > 0 do + local table_heights, minimal_height = {}, math.huge + + for column, count in pairs(column_counts) do + local potential_column_counts = ftable.shallow_copy(column_counts) + potential_column_counts[column] = count + increment + local new_height = determine_table_height(floor, potential_column_counts) + table_heights[column] = new_height + minimal_height = math.min(minimal_height, new_height) + end + + -- If increasing any column by 1 doesn't change the height, try incrementing by more + -- until height is decreased, or no columns are available anymore + if not (minimal_height < previous_height) and increment < available_columns then + increment = increment + 1 + else + for column, height in pairs(table_heights) do + if available_columns > 0 and height == minimal_height then + column_counts[column] = column_counts[column] + 1 + available_columns = available_columns - 1 + break + end + end + + previous_height, increment = minimal_height, 1 -- reset these + end + end + + return column_counts +end + + +local function add_checkmark_button(parent_flow, line, relevant_line) + parent_flow.add{type="checkbox", state=relevant_line.done, mouse_button_filter={"left"}, + tags={mod="fp", on_gui_checked_state_changed="checkmark_compact_line", line_id=line.id}} +end + +local function add_recipe_button(parent_flow, line, relevant_line, metadata) + local recipe_proto = relevant_line.recipe_proto + local style = (line.class == "Floor") and "flib_slot_button_blue_small" or "flib_slot_button_default_small" + style = (relevant_line.done) and "flib_slot_button_grayscale_small" or style + local tooltip = (line.class == "Line") and {"", {"fp.tt_title", recipe_proto.localised_name}} + or {"", {"fp.tt_title", recipe_proto.localised_name}} + table.insert(tooltip, {"", "\n", metadata.action_tooltips["act_on_compact_recipe"]}) + + local button = parent_flow.add{type="sprite-button", sprite=recipe_proto.sprite, style=style, + tags={mod="fp", on_gui_click="act_on_compact_recipe", line_id=line.id, on_gui_hover="set_tooltip", + context="compact_dialog"}, mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip +end + +local function add_modules_flow(parent_flow, parent_type, line, metadata) + for module in line[parent_type].module_set:iterator() do + local quality_proto = module.quality_proto + local title_line = (not quality_proto.always_show) and {"fp.tt_title", module.proto.localised_name} + or {"fp.tt_title_with_note", module.proto.localised_name, quality_proto.rich_text} + local number_line = {"", "\n", module.amount, " ", {"fp.pl_module", module.amount}} + local tooltip = {"", title_line, number_line, "\n", metadata.action_tooltips["act_on_compact_module"]} + local style = (line.done) and "flib_slot_button_grayscale_small" or "flib_slot_button_default_small" + + local button = parent_flow.add{type="sprite-button", sprite=module.proto.sprite, style=style, + tags={mod="fp", on_gui_click="act_on_compact_module", module_id=module.id, + on_gui_hover="set_tooltip", context="compact_dialog"}, quality=quality_proto.name, + number=module.amount, mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + end +end + +local function add_machine_flow(parent_flow, line, metadata) + if line.class == "Line" then + local machine_flow = parent_flow.add{type="flow", direction="horizontal"} + local machine, machine_proto = line.machine, line.machine.proto + local quality_proto = machine.quality_proto + + local title_line = (not quality_proto.always_show) and {"fp.tt_title", machine_proto.localised_name} + or {"fp.tt_title_with_note", machine_proto.localised_name, quality_proto.rich_text} + local amount, tooltip_line = util.format.machine_count(machine.amount, true) + local tooltip = {"", title_line, tooltip_line, "\n", metadata.action_tooltips["act_on_compact_machine"]} + local style = (line.done) and "flib_slot_button_grayscale_small" or "flib_slot_button_default_small" + + local button = machine_flow.add{type="sprite-button", sprite=machine_proto.sprite, number=amount, style=style, + tags={mod="fp", on_gui_click="act_on_compact_machine", type="machine", line_id=line.id, + on_gui_hover="set_tooltip", context="compact_dialog"}, quality=quality_proto.name, + mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + + add_modules_flow(machine_flow, "machine", line, metadata) + end +end + +local function add_beacon_flow(parent_flow, line, metadata) + if line.class == "Line" and line.beacon ~= nil then + local beacon_flow = parent_flow.add{type="flow", direction="horizontal"} + local beacon, beacon_proto = line.beacon, line.beacon.proto + local quality_proto = beacon.quality_proto + + local title_line = (not quality_proto.always_show) and {"fp.tt_title", beacon_proto.localised_name} + or {"fp.tt_title_with_note", beacon_proto.localised_name, quality_proto.rich_text} + local plural_parameter = (beacon.amount == 1) and 1 or 2 -- needed because the amount can be decimal + local number_line = {"", "\n", beacon.amount, " ", {"fp.pl_beacon", plural_parameter}} + local tooltip = {"", title_line, number_line, "\n", metadata.action_tooltips["act_on_compact_beacon"]} + local style = (line.done) and "flib_slot_button_grayscale_small" or "flib_slot_button_default_small" + + local button = beacon_flow.add{type="sprite-button", sprite=beacon_proto.sprite, number=beacon.amount, + tags={mod="fp", on_gui_click="act_on_compact_beacon", type="beacon", line_id=line.id, + on_gui_hover="set_tooltip", context="compact_dialog"}, quality=quality_proto.name, style=style, + mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + + add_modules_flow(beacon_flow, "beacon", line, metadata) + end +end + + +local function add_item_flow(line, relevant_line, item_category, button_color, metadata, item_buttons) + local column_count = metadata.column_counts[item_category] + if column_count == 0 then metadata.parent.add{type="empty-widget"}; return end + local item_table = metadata.parent.add{type="table", column_count=column_count} + + for index, item in pairs(line[item_category .. "s"]) do + local proto, type = item.proto, item.proto.type + -- items/s/machine does not make sense for lines with subfloors, show items/s instead + local machine_count = (line.class == "Line") and line.machine.amount or nil + local amount, number_tooltip = item_views.process_item(metadata.player, item, nil, machine_count) + if amount == -1 then goto skip_item end -- an amount of -1 means it was below the margin of error + + local style, enabled = "flib_slot_button_" .. button_color .. "_small" + if relevant_line.done then style = "flib_slot_button_grayscale_small" end + local name_line, temperature_line = {"", {"fp.tt_title", {"", proto.localised_name}}}, "" + + if type == "entity" then + style = (relevant_line.done) and "flib_slot_button_disabled_grayscale_small" + or "flib_slot_button_disabled_small" + elseif type == "fluid" and item_category == "ingredient" and line.class ~= "Floor" then + local temperature_data = line.temperature_data[proto.name] -- exists for any fluid ingredient + table.insert(name_line, temperature_data.annotation) + + local temperature = line.temperatures[proto.name] + if temperature == nil then + style = "flib_slot_button_purple_small" + temperature_line = {"fp.no_temperature_configured"} + else + temperature_line = {"fp.configured_temperature", temperature} + end + end + + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + local tooltip = {"", name_line, temperature_line, number_line, "\n", + metadata.action_tooltips["act_on_compact_item"]} + + local button = item_table.add{type="sprite-button", sprite=proto.sprite, number=amount, + tags={mod="fp", on_gui_click="act_on_compact_item", line_id=line.id, item_category=item_category .. "s", + item_index=index, on_gui_hover="hover_compact_item", on_gui_leave="leave_compact_item", + context="compact_dialog"}, style=style, mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + + item_buttons[type] = item_buttons[type] or {} + item_buttons[type][proto.name] = item_buttons[type][proto.name] or {} + table.insert(item_buttons[type][proto.name], {button=button, proper_style=style, size="_small"}) + + ::skip_item:: + end + + if line.class == "Floor" then return end + + if item_category == "product" or item_category == "ingredient" then + for _, item in pairs(line.recipe_proto.catalysts[item_category .. "s"]) do + local item_proto = prototyper.util.find("items", item.name, item.type) --[[@as FPItemPrototype]] + + local amount, number_tooltip = item_views.process_item(metadata.player, {proto=item_proto}, + (item.amount * line.production_ratio), line.machine.amount) + local title_line = {"fp.tt_title_with_note", item_proto.localised_name, {"fp.catalyst"}} + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + + item_table.add{type="sprite-button", sprite=item_proto.sprite, number=amount, + tooltip={"", title_line, number_line}, style="flib_slot_button_blue_small"} + end + end + + if item_category == "ingredient" and line.machine.fuel then + local fuel, machine_count = line.machine.fuel, line.machine.amount + local amount, number_tooltip = item_views.process_item(metadata.player, fuel, nil, machine_count) + if amount == -1 then goto skip_fuel end -- an amount of -1 means it was below the margin of error + + local style = "flib_slot_button_cyan_small" + local name_line, temperature_line = {"fp.tt_title_with_note", fuel.proto.localised_name, {"fp.pu_fuel", 1}}, "" + + if fuel.proto.type == "fluid" then + local temperature_data = fuel.temperature_data -- exists for any fluid fuel + table.insert(name_line, temperature_data.annotation) + + if fuel.temperature == nil then + style = "flib_slot_button_purple_small" + temperature_line = {"fp.no_temperature_configured"} + else + temperature_line = {"fp.configured_temperature", fuel.temperature} + end + end + + style = (relevant_line.done) and "flib_slot_button_grayscale_small" or style + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + local tooltip = {"", name_line, temperature_line, number_line, "\n", + metadata.action_tooltips["act_on_compact_item"]} + + local button = item_table.add{type="sprite-button", sprite=fuel.proto.sprite, style=style, number=amount, + tags={mod="fp", on_gui_click="act_on_compact_item", fuel_id=fuel.id, on_gui_hover="hover_compact_item", + on_gui_leave="leave_compact_item", context="compact_dialog"}, mouse_button_filter={"left-and-right"}, + raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + + local type, name = fuel.proto.type, fuel.proto.name + item_buttons[type] = item_buttons[type] or {} + item_buttons[type][name] = item_buttons[type][name] or {} + table.insert(item_buttons[type][name], {button=button, proper_style=style, size="_small"}) + + ::skip_fuel:: + end +end + + +local function refresh_compact_header(player, factory) + local player_table = util.globals.player_table(player) + local compact_elements = player_table.ui_state.compact_elements + + local attach_factory_products = player_table.preferences.attach_factory_products + compact_elements.name_label.caption = factory:tostring(attach_factory_products, true) + + local current_floor = util.context.get(player, "Floor") + compact_elements.level_label.caption = {"fp.bold_label", {"", "- ", {"fp.level"}, " ", current_floor.level}} + compact_elements.floor_up_button.enabled = (current_floor.level > 1) + compact_elements.floor_top_button.enabled = (current_floor.level > 1) + + local compact_ingredients = player_table.preferences.compact_ingredients + compact_elements.ingredient_toggle.toggled = compact_ingredients + compact_elements.ingredient_toggle.sprite = (compact_ingredients) and "fp_dropup" or "utility/dropdown" + + local ingredients_frame = compact_elements.ingredients_frame + ingredients_frame.visible = compact_ingredients + + ingredients_frame.clear() + + local frame_width = compact_elements.compact_frame.style.maximal_width + local available_space = frame_width - (2*12) -- 12px padding on both sides + local column_count = math.floor(available_space / 40) + local padding = (available_space - (column_count * 40)) / 2 + ingredients_frame.style.padding = {0, padding} + + local item_frame = ingredients_frame.add{type="frame", style="slot_button_deep_frame"} + local table_items = item_frame.add{type="table", column_count=column_count, style="filter_slot_table"} + + local item_buttons = compact_elements.item_buttons + + local show_floor_items = player_table.preferences.show_floor_items + local relevant_floor = (show_floor_items) and current_floor or factory.top_floor + for index, ingredient in pairs(relevant_floor.ingredients) do + local amount, number_tooltip = item_views.process_item(player, ingredient, nil, nil) + if amount == -1 then goto skip_ingredient end -- an amount of -1 means it was below the margin of error + + local name_line = {"fp.tt_title", ingredient.proto.localised_name} + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + local tooltip = {"", name_line, number_line, "\n", MODIFIER_ACTIONS["act_on_compact_item"].tooltip} + local style = "flib_slot_button_default" + + local button = table_items.add{type="sprite-button", number=amount, tooltip=tooltip, + tags={mod="fp", on_gui_click="act_on_compact_ingredient", floor_id=relevant_floor.id, item_index=index, + on_gui_hover="hover_compact_item", on_gui_leave="leave_compact_item", context="compact_dialog"}, + sprite=ingredient.proto.sprite, style=style, mouse_button_filter={"left-and-right"}, + raise_hover_events=true} + player_table.ui_state.tooltips[button.index] = tooltip + + local type, name = ingredient.proto.type, ingredient.proto.name + item_buttons[type] = item_buttons[type] or {} + item_buttons[type][name] = item_buttons[type][name] or {} + table.insert(item_buttons[type][name], {button=button, proper_style=style, size=""}) + + ::skip_ingredient:: + end +end + +local function refresh_compact_production(player, factory) + local ui_state = util.globals.ui_state(player) + local compact_elements = ui_state.compact_elements + + local floor = util.context.get(player, "Floor") --[[@as Floor]] + + local production_table = compact_elements.production_table + production_table.clear() + + -- Available columns for items only, as recipe and machines can't be 'compressed' + local frame_width = compact_elements.compact_frame.style.maximal_width + local available_columns = determine_available_columns(floor, frame_width) + if available_columns < 2 then available_columns = 2 end -- fix for too many modules or too high of a GUI scale + local column_counts = determine_column_counts(floor, available_columns) + + local metadata = { + player = player, + parent = production_table, + column_counts = column_counts, + tooltips = ui_state.tooltips.compact_dialog, + action_tooltips = { + act_on_compact_recipe = MODIFIER_ACTIONS["act_on_compact_recipe"].tooltip, + act_on_compact_module = MODIFIER_ACTIONS["act_on_compact_module"].tooltip, + act_on_compact_machine = MODIFIER_ACTIONS["act_on_compact_machine"].tooltip, + act_on_compact_beacon = MODIFIER_ACTIONS["act_on_compact_beacon"].tooltip, + act_on_compact_item = MODIFIER_ACTIONS["act_on_compact_item"].tooltip + } + } + + for line in floor:iterator() do -- build the individual lines + local relevant_line = (line.class == "Floor") and line.first or line --[[@as Line]] + if not relevant_line.active or not relevant_line:get_surface_compatibility().overall + or (not factory.matrix_free_items and relevant_line.production_type == "consume") then + goto skip_line + end + + -- Recipe and Checkmark + local recipe_flow = production_table.add{type="flow", direction="horizontal"} + recipe_flow.style.vertical_align = "center" + add_checkmark_button(recipe_flow, line, relevant_line) + add_recipe_button(recipe_flow, line, relevant_line, metadata) + + -- Machine and Beacon + local machines_flow = production_table.add{type="flow", direction="vertical"} + add_machine_flow(machines_flow, line, metadata) + add_beacon_flow(machines_flow, line, metadata) + + -- Products, Byproducts and Ingredients + add_item_flow(line, relevant_line, "product", "default", metadata, compact_elements.item_buttons) + add_item_flow(line, relevant_line, "byproduct", "red", metadata, compact_elements.item_buttons) + add_item_flow(line, relevant_line, "ingredient", "green", metadata, compact_elements.item_buttons) + + production_table.add{type="empty-widget", style="flib_horizontal_pusher"} + + ::skip_line:: + end +end + +local function refresh_compact_factory(player) + local factory = util.context.get(player, "Factory") --[[@as Factory?]] + if not factory or not factory.valid then return end + + local ui_state = util.globals.ui_state(player) + ui_state.tooltips.compact_dialog = {} + ui_state.compact_elements.item_buttons = {} + + refresh_compact_header(player, factory) + refresh_compact_production(player, factory) +end + +local function build_compact_factory(player) + local ui_state = util.globals.ui_state(player) + local compact_elements = ui_state.compact_elements + local content_flow = compact_elements.content_flow + + -- Header frame + local subheader = content_flow.add{type="frame", direction="vertical", style="inside_deep_frame"} + subheader.style.padding = 4 + + -- View state + local container_views = subheader.add{type="flow", direction="horizontal"} + container_views.style.padding = {4, 4, 0, 0} + container_views.add{type="empty-widget", style="flib_horizontal_pusher"} + + local flow_views = container_views.add{type="flow", direction="horizontal"} + compact_elements["views_flow"] = flow_views + + local line = subheader.add{type="line", direction="horizontal"} + line.style.padding = {2, 0, 6, 0} + + -- Flow navigation + local flow_navigation = subheader.add{type="flow", direction="horizontal"} + flow_navigation.style.vertical_align = "center" + flow_navigation.style.margin = {4, 4, 4, 8} + + local label_name = flow_navigation.add{type="label"} + label_name.style.font = "heading-2" + label_name.style.horizontally_squashable = true + compact_elements["name_label"] = label_name + + local label_level = flow_navigation.add{type="label"} + label_level.style.margin = {0, 6, 0, 6} + compact_elements["level_label"] = label_level + + local button_floor_up = flow_navigation.add{type="sprite-button", sprite="fp_arrow_line_up", + tooltip={"fp.floor_up_tt"}, tags={mod="fp", on_gui_click="change_compact_floor", destination="up"}, + style="fp_sprite-button_rounded_icon", mouse_button_filter={"left"}} + compact_elements["floor_up_button"] = button_floor_up + + local button_floor_top = flow_navigation.add{type="sprite-button", sprite="fp_arrow_line_bar_up", + tooltip={"fp.floor_top_tt"}, tags={mod="fp", on_gui_click="change_compact_floor", destination="top"}, + style="fp_sprite-button_rounded_icon", mouse_button_filter={"left"}} + button_floor_top.style.padding = {3, 2, 1, 2} + compact_elements["floor_top_button"] = button_floor_top + + flow_navigation.add{type="empty-widget", style="flib_horizontal_pusher"} + + local button_ingredients = flow_navigation.add{type="sprite-button", auto_toggle=true, + tooltip={"fp.compact_toggle_ingredients"}, tags={mod="fp", on_gui_click="toggle_compact_ingredients"}, + style="fp_sprite-button_rounded_icon", mouse_button_filter={"left"}} + button_ingredients.style.padding = 0 + compact_elements["ingredient_toggle"] = button_ingredients + + -- Ingredients frame + local ingredients_frame = content_flow.add{type="frame", direction="vertical", + style="inside_deep_frame"} + compact_elements["ingredients_frame"] = ingredients_frame + + -- Production table + local production_frame = content_flow.add{type="frame", direction="vertical", + style="inside_deep_frame"} + local scroll_pane_production = production_frame.add{type="scroll-pane", + style="flib_naked_scroll_pane_no_padding"} + scroll_pane_production.horizontal_scroll_policy = "never" + scroll_pane_production.style.horizontally_stretchable = true + + local table_production = scroll_pane_production.add{type="table", column_count=6, style="fp_table_production"} + table_production.vertical_centering = false + table_production.style.horizontal_spacing = 12 + table_production.style.vertical_spacing = 8 + table_production.style.padding = {4, 8} + compact_elements["production_table"] = table_production + + refresh_compact_factory(player) +end + + +local function handle_ingredient_click(player, tags, action) + local floor = OBJECT_INDEX[tags.floor_id] + local item = floor.ingredients[tags.item_index] + + if action == "add_to_cursor" then + util.cursor.handle_item_click(player, item.proto, item.amount) + + elseif action == "factoriopedia" then + local name = (item.proto.temperature) and item.proto.base_name or item.proto.name + player.open_factoriopedia_gui(prototypes[item.proto.type][name]) + end +end + +local function handle_recipe_click(player, tags, action) + local line = OBJECT_INDEX[tags.line_id] + local relevant_line = (line.class == "Floor") and line.first or line + + if action == "open_subfloor" then + if line.class == "Floor" then + util.context.set(player, line) + refresh_compact_factory(player) + end + elseif action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes["recipe"][relevant_line.recipe_proto.name]) + end +end + +local function handle_module_click(player, tags, action) + local module = OBJECT_INDEX[tags.module_id] + + if action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes["item"][module.proto.name]) + end +end + +local function handle_machine_click(player, tags, action) + local line = OBJECT_INDEX[tags.line_id] + -- We don't need to care about relevant lines here because this only gets called on lines without subfloor + + if action == "add_to_cursor" then + util.cursor.set_entity(player, line, line.machine) + + elseif action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes["entity"][line.machine.proto.name]) + end +end + +local function handle_beacon_click(player, tags, action) + local line = OBJECT_INDEX[tags.line_id] + -- We don't need to care about relevant lines here because this only gets called on lines without subfloor + + if action == "add_to_cursor" then + util.cursor.set_entity(player, line, line.beacon) + + elseif action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes["entity"][line.beacon.proto.name]) + end +end + +local function handle_item_click(player, tags, action) + local item = (tags.fuel_id) and OBJECT_INDEX[tags.fuel_id] + or OBJECT_INDEX[tags.line_id][tags.item_category][tags.item_index] + + if action == "add_to_cursor" then + if item.proto.type == "entity" then return end + util.cursor.handle_item_click(player, item.proto, item.amount) + + elseif action == "factoriopedia" then + local name = item.proto.name + if item.proto.type == "entity" then name = name:gsub("custom%-", "") + elseif item.proto.temperature then name = item.proto.base_name end + player.open_factoriopedia_gui(prototypes[item.proto.type][name]) + end +end + +local function handle_hover_change(player, tags, event) + local proto = nil + if tags.floor_id then + proto = OBJECT_INDEX[tags.floor_id].ingredients[tags.item_index].proto + elseif tags.fuel_id then + proto = OBJECT_INDEX[tags.fuel_id].proto + else + proto = OBJECT_INDEX[tags.line_id][tags.item_category][tags.item_index].proto + end + + local compact_elements = util.globals.ui_state(player).compact_elements + local relevant_buttons = compact_elements.item_buttons[proto.type][proto.name] + for _, button_data in pairs(relevant_buttons) do + button_data.button.style = (event.name == defines.events.on_gui_hover) + and "flib_slot_button_pink" .. button_data.size or button_data.proper_style + end +end + + +-- ** EVENTS ** +local factory_listeners = {} + +factory_listeners.gui = { + on_gui_click = { + { + name = "change_compact_floor", + handler = (function(player, tags, _) + local floor_changed = util.context.ascend_floors(player, tags.destination) + if floor_changed then refresh_compact_factory(player) end + end) + }, + { + name = "toggle_compact_ingredients", + handler = (function(player, _, event) + local preferences = util.globals.preferences(player) + preferences.compact_ingredients = not preferences.compact_ingredients + + local compact_elements = util.globals.ui_state(player).compact_elements + local sprite = (preferences.compact_ingredients) and "fp_dropup" or "utility/dropdown" + compact_elements.ingredient_toggle.sprite = sprite + compact_elements.ingredients_frame.visible = preferences.compact_ingredients + end) + }, + { + name = "act_on_compact_ingredient", + actions_table = { + add_to_cursor = {shortcut="left", show=true}, + factoriopedia = {shortcut="alt-right", show=true} + }, + handler = handle_ingredient_click + }, + { + name = "act_on_compact_recipe", + actions_table = { + open_subfloor = {shortcut="left", show=true}, + factoriopedia = {shortcut="alt-right", show=true} + }, + handler = handle_recipe_click + }, + { + name = "act_on_compact_module", + actions_table = { + factoriopedia = {shortcut="alt-right", show=true} + }, + handler = handle_module_click + }, + { + name = "act_on_compact_machine", + actions_table = { + add_to_cursor = {shortcut="left", show=true}, + factoriopedia = {shortcut="alt-right", show=true} + }, + handler = handle_machine_click + }, + { + name = "act_on_compact_beacon", + actions_table = { + add_to_cursor = {shortcut="left", show=true}, + factoriopedia = {shortcut="alt-right", show=true} + }, + handler = handle_beacon_click + }, + { + name = "act_on_compact_item", + actions_table = { + add_to_cursor = {shortcut="left", show=true}, + factoriopedia = {shortcut="alt-right", show=true} + }, + handler = handle_item_click + } + }, + on_gui_checked_state_changed = { + { + name = "checkmark_compact_line", + handler = (function(player, tags, _) + local line = OBJECT_INDEX[tags.line_id] + local relevant_line = (line.class == "Floor") and line.first or line + relevant_line.done = not relevant_line.done + refresh_compact_factory(player) + end) + } + }, + on_gui_hover = { + { + name = "hover_compact_item", + handler = (function(player, tags, event) + handle_hover_change(player, tags, event) + main_dialog.set_tooltip(player, event.element) + end) + } + }, + on_gui_leave = { + { + name = "leave_compact_item", + handler = handle_hover_change + } + } +} + +factory_listeners.misc = { + build_gui_element = (function(player, event) + if event.trigger == "compact_factory" then + build_compact_factory(player) + end + end), + refresh_gui_element = (function(player, event) + if event.trigger == "compact_factory" then + refresh_compact_factory(player) + end + end) +} + + +-- ** UTIL ** +-- Set frame dimensions in a relative way, taking player resolution and scaling into account +local function set_compact_frame_dimensions(player, frame) + local scaled_resolution = util.gui.calculate_scaled_resolution(player) + local compact_width_percentage = util.globals.preferences(player).compact_width_percentage + frame.style.width = scaled_resolution.width * (compact_width_percentage / 100) + frame.style.maximal_height = scaled_resolution.height * 0.8 +end + +local function set_compact_frame_location(player, frame) + local scale = player.display_scale + frame.location = {10 * scale, 63 * scale} +end + + +-- ** TOP LEVEL ** +compact_dialog = {} + +function compact_dialog.rebuild(player, default_visibility) + local ui_state = util.globals.ui_state(player) + + local interface_visible = default_visibility + local compact_frame = ui_state.compact_elements.compact_frame + -- Delete the existing interface if there is one + if compact_frame ~= nil then + if compact_frame.valid then + interface_visible = compact_frame.visible + compact_frame.destroy() + end + + ui_state.compact_elements = {} -- reset all compact element references + end + + local frame_compact_dialog = player.gui.screen.add{type="frame", direction="vertical", + visible=interface_visible, name="fp_frame_compact_dialog"} + set_compact_frame_location(player, frame_compact_dialog) + set_compact_frame_dimensions(player, frame_compact_dialog) + ui_state.compact_elements["compact_frame"] = frame_compact_dialog + + -- Title bar + local flow_title_bar = frame_compact_dialog.add{type="flow", direction="horizontal", style="frame_header_flow", + tags={mod="fp", on_gui_click="place_compact_dialog"}} + flow_title_bar.drag_target = frame_compact_dialog + + flow_title_bar.add{type="sprite-button", style="fp_button_frame", toggled=true, + tags={mod="fp", on_gui_click="switch_to_main_view"}, tooltip={"fp.switch_to_main_view"}, + sprite="fp_pin", mouse_button_filter={"left"}} + + local button_calculator = flow_title_bar.add{type="sprite-button", sprite="fp_calculator", + tooltip={"fp.open_calculator"}, style="fp_button_frame", mouse_button_filter={"left"}, + tags={mod="fp", on_gui_click="open_calculator_dialog"}} + button_calculator.style.padding = -3 + + flow_title_bar.add{type="empty-widget", style="flib_titlebar_drag_handle", + ignored_by_interaction=true} + flow_title_bar.add{type="label", caption={"mod-name.factoryplanner"}, style="fp_label_frame_title", + ignored_by_interaction=true} + flow_title_bar.add{type="empty-widget", style="flib_titlebar_drag_handle", + ignored_by_interaction=true} + + local button_close = flow_title_bar.add{type="sprite-button", tags={mod="fp", on_gui_click="close_compact_dialog"}, + sprite="utility/close", tooltip={"fp.close_interface"}, style="fp_button_frame", mouse_button_filter={"left"}} + button_close.style.padding = 1 + + local flow_content = frame_compact_dialog.add{type="flow", direction="vertical"} + flow_content.style.vertical_spacing = 8 + ui_state.compact_elements["content_flow"] = flow_content + + item_views.rebuild_data(player) + util.raise.build(player, "compact_factory", nil) + item_views.rebuild_interface(player) + + return frame_compact_dialog +end + +function compact_dialog.toggle(player) + local frame_compact_dialog = util.globals.ui_state(player).compact_elements.compact_frame + -- Doesn't set player.opened so other GUIs like the inventory can be opened when building + + if frame_compact_dialog == nil or not frame_compact_dialog.valid then + compact_dialog.rebuild(player, true) -- refreshes on its own + else + local new_dialog_visibility = not frame_compact_dialog.visible + frame_compact_dialog.visible = new_dialog_visibility + + if new_dialog_visibility then refresh_compact_factory(player) end + end +end + +function compact_dialog.is_in_focus(player) + local frame_compact_dialog = util.globals.ui_state(player).compact_elements.compact_frame + return (frame_compact_dialog ~= nil and frame_compact_dialog.valid and frame_compact_dialog.visible) +end + + +-- ** EVENTS ** +local dialog_listeners = {} + +dialog_listeners.gui = { + on_gui_click = { + { + name = "switch_to_main_view", + handler = (function(player, _, _) + util.globals.ui_state(player).compact_view = false + compact_dialog.toggle(player) + + main_dialog.toggle(player) + util.raise.refresh(player, "production") + end) + }, + { + name = "close_compact_dialog", + handler = (function(player, _, _) + compact_dialog.toggle(player) + end) + }, + { + name = "place_compact_dialog", + handler = (function(player, _, event) + if event.button == defines.mouse_button_type.middle then + local frame_compact_dialog = util.globals.ui_state(player).compact_elements.compact_frame + set_compact_frame_location(player, frame_compact_dialog) + end + end) + } + } +} + +dialog_listeners.misc = { + on_player_display_resolution_changed = (function(player, _) + compact_dialog.rebuild(player, false) + end), + + on_player_display_scale_changed = (function(player, _) + compact_dialog.rebuild(player, false) + end), + + on_lua_shortcut = (function(player, event) + if event.prototype_name == "fp_open_interface" and util.globals.ui_state(player).compact_view then + compact_dialog.toggle(player) + end + end), + + fp_toggle_interface = (function(player, _) + if util.globals.ui_state(player).compact_view then compact_dialog.toggle(player) end + end) +} + +return { factory_listeners, dialog_listeners } diff --git a/modfiles/ui/base/main_dialog.lua b/modfiles/ui/base/main_dialog.lua new file mode 100644 index 000000000..8104f724d --- /dev/null +++ b/modfiles/ui/base/main_dialog.lua @@ -0,0 +1,278 @@ +main_dialog = {} + +-- Accepts custom width and height parameters so dimensions can be tried out without changing actual preferences +local function determine_main_dimensions(player, products_per_row, factory_list_rows) + local preferences = util.globals.preferences(player) + products_per_row = products_per_row or preferences.products_per_row + factory_list_rows = factory_list_rows or preferences.factory_list_rows + local frame_spacing = MAGIC_NUMBERS.frame_spacing + + -- Width of the larger ingredients-box, which has twice the buttons per row + local boxes_width_1 = (products_per_row * 2 * MAGIC_NUMBERS.item_button_size) + (2 * frame_spacing) + -- Width of the two smaller product+byproduct-boxes + local boxes_width_2 = 2 * ((products_per_row * MAGIC_NUMBERS.item_button_size) + (2 * frame_spacing)) + local width = MAGIC_NUMBERS.list_width + boxes_width_1 + boxes_width_2 + ((2+3) * frame_spacing) + + local factory_list_height = (factory_list_rows * MAGIC_NUMBERS.list_element_height) + + MAGIC_NUMBERS.subheader_height + local height = MAGIC_NUMBERS.title_bar_height + MAGIC_NUMBERS.district_info_height + + factory_list_height + (3 * frame_spacing) + + return {width=width, height=height} +end + +-- Downscale width and height preferences until the main interface fits onto the player's screen +function main_dialog.shrinkwrap_interface(player) + local scaled_resolution = util.gui.calculate_scaled_resolution(player) + local preferences = util.globals.preferences(player) + + local width_minimum = PRODUCTS_PER_ROW_OPTIONS[1] + while (scaled_resolution.width * 0.95) < determine_main_dimensions(player).width + and preferences.products_per_row > width_minimum do + preferences.products_per_row = preferences.products_per_row - 1 + end + + local height_minimum = FACTORY_LIST_ROWS_OPTIONS[1] + while (scaled_resolution.height * 0.95) < determine_main_dimensions(player).height + and preferences.factory_list_rows > height_minimum do + preferences.factory_list_rows = preferences.factory_list_rows - 2 + end + + main_dialog.rebuild(player, false) +end + + +local function interface_toggle(metadata) + local player = game.get_player(metadata.player_index) --[[@as LuaPlayer]] + local compact_view = util.globals.ui_state(player).compact_view + if compact_view then compact_dialog.toggle(player) + else main_dialog.toggle(player) end +end + + +function main_dialog.rebuild(player, default_visibility) + local ui_state = util.globals.ui_state(player) + local main_elements = ui_state.main_elements + + local interface_visible = default_visibility + local main_frame = main_elements.main_frame + -- Delete the existing interface if there is one + if main_frame ~= nil then + if main_frame.valid then + interface_visible = main_frame.visible + main_frame.destroy() + end + + ui_state.main_elements = {} -- reset all main element references + main_elements = ui_state.main_elements + end + + -- Create and configure the top-level frame + local frame_main_dialog = player.gui.screen.add{type="frame", direction="vertical", + visible=interface_visible, tags={mod="fp", on_gui_closed="close_main_dialog"}, + name="fp_frame_main_dialog"} + main_elements["main_frame"] = frame_main_dialog + + local dimensions = determine_main_dimensions(player) + ui_state.main_dialog_dimensions = dimensions + frame_main_dialog.style.size = dimensions + util.gui.properly_center_frame(player, frame_main_dialog, dimensions) + + + -- Create the actual dialog structure + local frame_spacing = MAGIC_NUMBERS.frame_spacing + main_elements.flows = {} + + local top_horizontal = frame_main_dialog.add{type="flow", direction="horizontal"} + main_elements.flows["top_horizontal"] = top_horizontal + + local main_horizontal = frame_main_dialog.add{type="flow", direction="horizontal"} + main_horizontal.style.horizontal_spacing = frame_spacing + main_elements.flows["main_horizontal"] = main_horizontal + + local left_vertical = main_horizontal.add{type="flow", direction="vertical"} + left_vertical.style.vertical_spacing = frame_spacing + left_vertical.style.width = MAGIC_NUMBERS.list_width + main_elements.flows["left_vertical"] = left_vertical + + local right_vertical = main_horizontal.add{type="flow", direction="vertical"} + right_vertical.style.vertical_spacing = frame_spacing + right_vertical.style.width = dimensions.width - MAGIC_NUMBERS.list_width - (3 * MAGIC_NUMBERS.frame_spacing) + main_elements.flows["right_vertical"] = right_vertical + + item_views.rebuild_data(player) + util.raise.build(player, "main_dialog", nil) -- tells all elements to build themselves + item_views.rebuild_interface(player) + + if interface_visible then player.opened = frame_main_dialog end + main_dialog.set_pause_state(player, frame_main_dialog) +end + +function main_dialog.toggle(player, skip_opened) + local ui_state = util.globals.ui_state(player) + local frame_main_dialog = ui_state.main_elements.main_frame + + if frame_main_dialog == nil or not frame_main_dialog.valid then + main_dialog.rebuild(player, true) -- sets opened and paused-state itself + + -- Don't toggle if a modal dialog or context menu is opened + elseif ui_state.modal_dialog_type == nil and ui_state.context_menu == nil then + local new_dialog_visibility = not frame_main_dialog.visible + frame_main_dialog.visible = new_dialog_visibility + if not skip_opened then -- flag used only for hacky internal reasons + player.opened = (new_dialog_visibility) and frame_main_dialog or nil + end + + main_dialog.set_pause_state(player, frame_main_dialog) + + -- Make sure FP is not behind some vanilla interfaces + if new_dialog_visibility then frame_main_dialog.bring_to_front() end + end +end + + +-- Returns true when the main dialog is open while no modal dialogs are +function main_dialog.is_in_focus(player) + local frame_main_dialog = util.globals.main_elements(player).main_frame + return (frame_main_dialog ~= nil and frame_main_dialog.valid and frame_main_dialog.visible + and util.globals.ui_state(player).modal_dialog_type == nil) +end + +-- Sets the game.paused-state as is appropriate +function main_dialog.set_pause_state(player, frame_main_dialog, force_false) + -- Don't touch paused-state if this is a multiplayer session or the editor is active + if game.is_multiplayer() or player.physical_controller_type == defines.controllers.editor then return end + if not frame_main_dialog or not frame_main_dialog.valid then return end + + game.tick_paused = (util.globals.preferences(player).pause_on_interface and not force_false) + and frame_main_dialog.visible or false +end + +-- General handler for setting a previously stored tooltip on any element +function main_dialog.set_tooltip(player, element) + local ui_state = util.globals.ui_state(player) + local tooltips = ui_state.tooltips[element.tags.context] + if tooltips[element.index] ~= nil then + element.tooltip = tooltips[element.index] + tooltips[element.index] = nil + end +end + +-- Centralized here to avoid another global variable +function main_dialog.toggle_districts_view(player, force_false) + local ui_state = util.globals.ui_state(player) + ui_state.districts_view = not ui_state.districts_view and not force_false + + util.raise.refresh(player, "district_info") +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_closed = { + { + name = "close_main_dialog", + handler = (function(player, _, _) + main_dialog.toggle(player) + end) + } + }, + on_gui_click = { + { + name = "mod_gui_toggle_interface", + handler = (function(player, _, _) + interface_toggle({player_index=player.index}) + end) + } + }, + on_gui_hover = { + { + name = "set_tooltip", + handler = (function(player, _, event) + main_dialog.set_tooltip(player, event.element) + end) + } + } +} + +listeners.misc = { + -- Makes sure that another GUI can open properly while a modal dialog is open. + -- The FP interface can have at most 3 layers of GUI: main interface, modal dialog, selection mode. + -- We need to make sure opening the technology screen (for example) from any of those layers behaves properly. + -- We need to consider that if the technology screen is opened (which is the reason we get this event), + -- the game automtically closes the currently open GUI before calling this one. This means the top layer + -- that's open at that stage is closed already when we get here. So we're at most at the modal dialog + -- layer at this point and need to close the things below, if there are any. + on_gui_opened = (function(player, _) + local ui_state = util.globals.ui_state(player) + + -- With that in mind, if there's a modal dialog open, we were in selection mode, and need to close the dialog + if ui_state.modal_dialog_type ~= nil then util.raise.close_dialog(player, "cancel", true) end + + -- Then, at this point we're at most at the stage where the main dialog is open, so close it + if main_dialog.is_in_focus(player) then main_dialog.toggle(player, true) end + end), + + on_player_display_resolution_changed = (function(player, _) + main_dialog.shrinkwrap_interface(player) + main_dialog.rebuild(player, false) + end), + + on_player_display_scale_changed = (function(player, _) + main_dialog.shrinkwrap_interface(player) + main_dialog.rebuild(player, false) + end), + + on_singleplayer_init = (function(player, _) + main_dialog.rebuild(player, false) + end), + + on_multiplayer_init = (function(player, _) + main_dialog.rebuild(player, false) + end), + + on_lua_shortcut = (function(player, event) + if event.prototype_name == "fp_open_interface" and not util.globals.ui_state(player).compact_view then + main_dialog.toggle(player) + end + end), + + fp_toggle_interface = (function(player, _) + if not util.globals.ui_state(player).compact_view then main_dialog.toggle(player) end + end), + + -- This needs to be in a single place, otherwise the events cancel each other out + fp_toggle_compact_view = (function(player, _) + local ui_state = util.globals.ui_state(player) + local factory = util.context.get(player, "Factory") + + local main_focus = main_dialog.is_in_focus(player) + local compact_focus = compact_dialog.is_in_focus(player) + + -- Open the compact view if this toggle is pressed when neither dialog + -- is open as that makes the most sense from a user perspective + if not main_focus and not compact_focus then + ui_state.compact_view = true + compact_dialog.toggle(player) + + elseif ui_state.compact_view and compact_focus then + compact_dialog.toggle(player) + main_dialog.toggle(player) + util.raise.refresh(player, "production") + ui_state.compact_view = false + + elseif factory ~= nil and factory.valid then + main_dialog.toggle(player) + compact_dialog.toggle(player) -- toggle also refreshes + ui_state.compact_view = true + end + end) +} + +listeners.global = { + interface_toggle = interface_toggle +} + +return { listeners } diff --git a/modfiles/ui/base/modal_dialog.lua b/modfiles/ui/base/modal_dialog.lua new file mode 100644 index 000000000..268b209c4 --- /dev/null +++ b/modfiles/ui/base/modal_dialog.lua @@ -0,0 +1,458 @@ +modal_dialog = {} + +---@alias ModalDialogType string + +-- ** LOCAL UTIL ** +local function create_base_dialog(player, dialog_settings, modal_data) + local modal_elements = modal_data.modal_elements + + local frame_modal_dialog = player.gui.screen.add{type="frame", direction="vertical", + tags={mod="fp", on_gui_closed="close_modal_dialog"}} + frame_modal_dialog.style.minimal_width = 220 + modal_elements.modal_frame = frame_modal_dialog + + -- Title bar + if dialog_settings.caption ~= nil then + local flow_title_bar = frame_modal_dialog.add{type="flow", direction="horizontal", style="frame_header_flow", + tags={mod="fp", on_gui_click="re-center_modal_dialog"}} + flow_title_bar.drag_target = frame_modal_dialog + flow_title_bar.add{type="label", caption=dialog_settings.caption, style="fp_label_frame_title", + ignored_by_interaction=true} + + flow_title_bar.add{type="empty-widget", style="flib_titlebar_drag_handle", ignored_by_interaction=true} + + if dialog_settings.search_handler_name then -- add a search field if requested + modal_data.search_handler_name = dialog_settings.search_handler_name + modal_data.next_search_tick = nil -- used for rate limited search + + local searchfield = flow_title_bar.add{type="textfield", style="search_popup_textfield", + tags={mod="fp", on_gui_text_changed="modal_searchfield"}} + searchfield.style.width = 140 + searchfield.style.top_margin = -3 + modal_elements.search_textfield = searchfield + modal_dialog.set_searchfield_state(player) + + flow_title_bar.add{type="sprite-button", tooltip={"fp.search_button_tt"}, + tags={mod="fp", on_gui_click="focus_modal_searchfield"}, sprite="utility/search", + style="fp_button_frame", mouse_button_filter={"left"}} + end + + if dialog_settings.reset_handler_name then -- add a reset button if requested + modal_data.reset_handler_name = dialog_settings.reset_handler_name + + local reset_toggle = flow_title_bar.add{type="sprite-button", tooltip={"fp.reset_toggle_tt"}, + tags={mod="fp", on_gui_click="modal_dialog_toggle_reset"}, sprite="utility/reset", + style="tool_button_red", mouse_button_filter={"left"}} + reset_toggle.style.size = 24 + reset_toggle.style.padding = 1 + modal_elements.toggle_reset = reset_toggle + + local reset_confirm = flow_title_bar.add{type="sprite-button", tooltip={"fp.reset_confirm_tt"}, + tags={mod="fp", on_gui_click="modal_dialog_confirm_reset"}, sprite="utility/check_mark", + style="flib_tool_button_light_green", visible=false, mouse_button_filter={"left"}} + reset_confirm.style.size = 24 + reset_confirm.style.padding = -1 + modal_elements.confirm_reset = reset_confirm + end + + if not dialog_settings.show_submit_button then -- add X-to-close button if this is not a submit dialog + local close_button = flow_title_bar.add{type="sprite-button", tooltip={"fp.close_button_tt"}, + tags={mod="fp", on_gui_click="exit_modal_dialog", action="cancel"}, sprite="utility/close", + style="fp_button_frame", mouse_button_filter={"left"}} + close_button.style.left_margin = 4 + close_button.style.padding = 1 + end + end + + local flow_content = frame_modal_dialog.add{type="flow", direction="horizontal"} + flow_content.style.horizontal_spacing = 12 + + -- Content frame + local content_frame = flow_content.add{type="frame", direction="vertical", style="inside_shallow_frame"} + content_frame.style.vertically_stretchable = true + + if dialog_settings.subheader_text then + local subheader = content_frame.add{type="frame", direction="horizontal", style="subheader_frame"} + subheader.style.horizontally_stretchable = true + subheader.style.padding = 12 + subheader.add{type="label", caption=dialog_settings.subheader_text, + tooltip=dialog_settings.subheader_tooltip, style="semibold_label"} + end + + local scroll_pane = content_frame.add{type="scroll-pane", style="flib_naked_scroll_pane"} + if dialog_settings.disable_scroll_pane then scroll_pane.vertical_scroll_policy = "never" end + modal_elements.content_frame = scroll_pane + + -- Secondary frame + if dialog_settings.secondary_frame then + local frame_secondary = flow_content.add{type="frame", direction="vertical", style="inside_shallow_frame"} + + local scroll_pane_secondary = frame_secondary.add{type="scroll-pane", style="flib_naked_scroll_pane"} + scroll_pane_secondary.style.padding = 12 + + modal_elements.secondary_frame = scroll_pane_secondary + end + + modal_elements.auxiliary_flow = frame_modal_dialog.add{type="flow", direction="vertical"} + + local dialog_max_height = (util.globals.ui_state(player).main_dialog_dimensions.height) * 0.96 + modal_data.dialog_maximal_height = dialog_max_height + frame_modal_dialog.style.maximal_height = dialog_max_height + + if dialog_settings.show_submit_button then -- if there is a submit button, there should be a button bar + -- Button bar + local button_bar = frame_modal_dialog.add{type="flow", direction="horizontal", + style="dialog_buttons_horizontal_flow"} + + -- Cancel button + local button_cancel = button_bar.add{type="button", tags={mod="fp", on_gui_click="exit_modal_dialog", + action="cancel"}, style="back_button", caption={"fp.cancel"}, tooltip={"fp.cancel_dialog_tt"}, + mouse_button_filter={"left"}} + button_cancel.style.minimal_width = 0 + button_cancel.style.padding = {1, 12, 0, 12} + + -- Delete button and spacers + if dialog_settings.show_delete_button then + local left_drag_handle = button_bar.add{type="empty-widget", style="flib_dialog_footer_drag_handle"} + left_drag_handle.drag_target = frame_modal_dialog + + local button_delete = button_bar.add{type="button", caption={"fp.delete"}, style="red_button", + tags={mod="fp", on_gui_click="exit_modal_dialog", action="delete"}, mouse_button_filter={"left"}} + button_delete.style.font = "default-dialog-button" + button_delete.style.height = 32 + button_delete.style.minimal_width = 0 + button_delete.style.padding = {0, 8} + + -- If there is a delete button present, we need to set a minimum dialog width for it to look good + frame_modal_dialog.style.minimal_width = 340 + end + + -- One 'drag handle' should always be visible + local right_drag_handle = button_bar.add{type="empty-widget", style="flib_dialog_footer_drag_handle"} + right_drag_handle.drag_target = frame_modal_dialog + + -- Submit button + local button_submit = button_bar.add{type="button", tags={mod="fp", on_gui_click="exit_modal_dialog", + action="submit"}, caption={"fp.submit"}, tooltip={"fp.confirm_dialog_tt"}, style="confirm_button", + mouse_button_filter={"left"}} + button_submit.style.minimal_width = 0 + button_submit.style.padding = {1, 8, 0, 12} + modal_elements.dialog_submit_button = button_submit + end + + return frame_modal_dialog +end + +-- ** TOP LEVEL ** +-- Opens a barebone modal dialog and calls upon the given function to populate it +function modal_dialog.enter(player, metadata, dialog_open, early_abort) + if early_abort ~= nil and early_abort(player, metadata.modal_data or {}) then return end + + local ui_state = util.globals.ui_state(player) + ui_state.modal_data = metadata.modal_data or {} + ui_state.modal_data.modal_elements = {} + ui_state.modal_data.confirmed_dialog = false + + -- Create interface_dimmer first so the layering works out correctly + if main_dialog.is_in_focus(player) then + local interface_dimmer = player.gui.screen.add{type="frame", style="fp_frame_semitransparent", + tags={mod="fp", on_gui_click="re-layer_interface_dimmer"}, visible=(not metadata.skip_dimmer)} + interface_dimmer.style.size = ui_state.main_dialog_dimensions + interface_dimmer.location = ui_state.main_elements.main_frame.location + ui_state.modal_data.modal_elements.interface_dimmer = interface_dimmer + end + + -- Create modal dialog framework and let the dialog itself fill it out + ui_state.modal_dialog_type = metadata.dialog + local frame_modal_dialog = create_base_dialog(player, metadata, ui_state.modal_data) + dialog_open(player, ui_state.modal_data) + + frame_modal_dialog.force_auto_center() + player.opened = frame_modal_dialog +end + +-- Handles the closing process of a modal dialog, reopening the main dialog thereafter +function modal_dialog.exit(player, action, skip_opened, dialog_close) + local ui_state = util.globals.ui_state(player) -- dialog guaranteed to be open + + local modal_elements = ui_state.modal_data.modal_elements + local submit_button = modal_elements.dialog_submit_button + + -- Stop exiting if trying to submit while submission is disabled + if action == "submit" and (submit_button and not submit_button.enabled) then return end + + -- Call the closing function for this dialog, if it has one + if dialog_close ~= nil then dialog_close(player, action) end + + -- Unregister the delayed search handler if present + local search_tick = ui_state.modal_data.next_search_tick + if search_tick ~= nil then util.nth_tick.cancel(search_tick) end + + ui_state.modal_dialog_type = nil + ui_state.modal_data = nil + + if modal_elements.interface_dimmer then modal_elements.interface_dimmer.destroy() end + modal_elements.modal_frame.destroy() + + if not skip_opened then player.opened = ui_state.main_elements.main_frame end +end + + +function modal_dialog.open_context_menu(player, tags, handler, actions, location) + local ui_state = util.globals.ui_state(player) + local frame_modal_dialog = player.gui.screen.add{type="frame", direction="vertical", + tags={mod="fp", on_gui_closed="close_context_menu"}, style="fp_naked_frame"} + frame_modal_dialog.style.padding = 0 -- make sure the visible part starts right on the cursor + ui_state.context_menu = frame_modal_dialog + + local button_flow = frame_modal_dialog.add{type="flow", direction="vertical"} + button_flow.style.vertical_spacing = 0 + + local action_counter = 0 + local active_limitations = util.actions.current_limitations(player) + + for _, action in pairs(actions) do + if util.actions.allowed(action.limitations, active_limitations) then + local caption = {"fp.tt_title", {"fp.action_" .. action.name}} + local button = button_flow.add{type="button", style="list_box_item", mouse_button_filter={"left"}, + tags={mod="fp", on_gui_click="choose_context_action", tags=tags, handler=handler, action=action.name}} + button.style.width = MAGIC_NUMBERS.context_menu_width + + local flow = button.add{type="flow", direction="horizontal"} + flow.style.width = MAGIC_NUMBERS.context_menu_width + flow.style.right_padding = 20 + flow.add{type="label", caption=caption, style="bold_label"} + flow.add{type="empty-widget", style="flib_horizontal_pusher"} + flow.add{type="label", caption=action.shortcut_string} + + action_counter = action_counter + 1 + end + end + local dialog_height = action_counter * 28 + button_flow.style.height = dialog_height + + -- Make sure the dialog doesn't go off-screen at the bottom or the right + local scaled_dialog_height = dialog_height * player.display_scale + local distance_to_bottom = player.display_resolution.height - location.y + if distance_to_bottom < scaled_dialog_height then location.y = location.y - scaled_dialog_height end + + local scaled_dialog_width = MAGIC_NUMBERS.context_menu_width * player.display_scale + local distance_to_right = player.display_resolution.width - location.x + if distance_to_right < scaled_dialog_width then location.x = location.x - scaled_dialog_width end + + frame_modal_dialog.location = location + player.opened = frame_modal_dialog +end + +function modal_dialog.close_context_menu(player) + local ui_state = util.globals.ui_state(player) + if not ui_state.context_menu then return end + + ui_state.context_menu.destroy() + ui_state.context_menu = nil + + if ui_state.modal_dialog_type ~= nil then + player.opened = ui_state.modal_data.modal_elements.modal_frame + else + player.opened = ui_state.main_elements.main_frame + end +end + + +function modal_dialog.set_searchfield_state(player) + local player_table = util.globals.player_table(player) + if not player_table.ui_state.modal_dialog_type then return end + local searchfield = player_table.ui_state.modal_data.modal_elements.search_textfield + if not searchfield then return end + + local status = (player_table.translation_tables ~= nil) + searchfield.enabled = status -- disables on nil and false + searchfield.tooltip = (status) and {"fp.searchfield_tt"} or {"fp.warning_with_icon", {"fp.searchfield_not_ready_tt"}} +end + +function modal_dialog.set_submit_button_state(modal_elements, enabled, message) + local caption = (enabled) and {"fp.submit"} or {"fp.warning_with_icon", {"fp.submit"}} + local tooltip = (enabled) and {"fp.confirm_dialog_tt"} or {"fp.warning_with_icon", message} + + local button = modal_elements.dialog_submit_button + button.style.left_padding = (enabled) and 12 or 6 + button.enabled = enabled + button.caption = caption + button.tooltip = tooltip +end + + +function modal_dialog.enter_selection_mode(player, selector_name) + local ui_state = util.globals.ui_state(player) + ui_state.selection_mode = true + player.cursor_stack.set_stack(selector_name) + + local frame_main_dialog = ui_state.main_elements.main_frame + frame_main_dialog.visible = false + main_dialog.set_pause_state(player, frame_main_dialog, true) + + local modal_elements = ui_state.modal_data.modal_elements + modal_elements.interface_dimmer.visible = false + + modal_elements.modal_frame.ignored_by_interaction = true + modal_elements.modal_frame.location = {25, 50} +end + +function modal_dialog.leave_selection_mode(player) + local ui_state = util.globals.ui_state(player) + ui_state.selection_mode = false + player.cursor_stack.set_stack(nil) + + local modal_elements = ui_state.modal_data.modal_elements + modal_elements.interface_dimmer.visible = true + + -- player.opened needs to be set because on_gui_closed sets it to nil + player.opened = modal_elements.modal_frame + modal_elements.modal_frame.ignored_by_interaction = false + modal_elements.modal_frame.force_auto_center() + + local frame_main_dialog = ui_state.main_elements.main_frame + frame_main_dialog.visible = true + + main_dialog.set_pause_state(player, frame_main_dialog) +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "re-layer_interface_dimmer", + handler = (function(player, _, _) + util.globals.modal_elements(player).modal_frame.bring_to_front() + end) + }, + { + name = "re-center_modal_dialog", + handler = (function(player, _, event) + if event.button == defines.mouse_button_type.middle then + local modal_elements = util.globals.modal_elements(player) + modal_elements.modal_frame.force_auto_center() + end + end) + }, + { + name = "exit_modal_dialog", + handler = (function(player, tags, _) + util.raise.close_dialog(player, tags.action) + end) + }, + { + name = "choose_context_action", + handler = (function(player, tags, _) + modal_dialog.close_context_menu(player) + MODIFIER_ACTIONS[tags.handler].handler(player, tags.tags, tags.action) + end) + }, + { + name = "focus_modal_searchfield", + handler = (function(player, _, _) + util.gui.select_all(util.globals.modal_elements(player).search_textfield) + end) + }, + { + name = "modal_dialog_toggle_reset", + handler = (function(player, _, _) + local modal_elements = util.globals.modal_elements(player) + modal_elements.toggle_reset.visible = false + modal_elements.confirm_reset.visible = true + end) + }, + { + name = "modal_dialog_confirm_reset", + handler = (function(player, _, _) + local modal_data = util.globals.modal_data(player) --[[@as table]] + modal_data.modal_elements.toggle_reset.visible = true + modal_data.modal_elements.confirm_reset.visible = false + GLOBAL_HANDLERS[modal_data.reset_handler_name](player) + end) + } + }, + on_gui_text_changed = { + { + name = "modal_searchfield", + timeout = MAGIC_NUMBERS.modal_search_rate_limit, + handler = (function(player, _, metadata) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local search_tick = modal_data.search_tick + if search_tick ~= nil then util.nth_tick.cancel(search_tick) end + + local search_term = metadata.text:gsub("^%s*(.-)%s*$", "%1"):lower() + GLOBAL_HANDLERS[modal_data.search_handler_name](player, search_term) + + -- Set up delayed search update to circumvent issues caused by rate limiting + local desired_tick = game.tick + MAGIC_NUMBERS.modal_search_rate_limit + modal_data.next_search_tick = util.nth_tick.register(desired_tick, + "run_delayed_modal_search", {player_index=player.index}) + end) + } + }, + on_gui_closed = { + { + name = "close_modal_dialog", + handler = (function(player, _, event) + local ui_state = util.globals.ui_state(player) + + if ui_state.selection_mode then + modal_dialog.leave_selection_mode(player) + elseif ui_state.context_menu == nil then -- don't close if opening context menu + -- Here, we need to distinguish between submitting a dialog with E or ESC + util.raise.close_dialog(player, (ui_state.modal_data.confirmed_dialog) and "submit" or "cancel") + -- If the dialog was not closed, it means submission was disabled, and we need to re-set .opened + if event.element.valid then player.opened = event.element end + end + + -- Reset .confirmed_dialog if this event didn't actually lead to the dialog closing + if event.element.valid and ui_state.modal_data then ui_state.modal_data.confirmed_dialog = false end + end) + }, + { + name = "close_context_menu", + handler = modal_dialog.close_context_menu + } + } +} + +listeners.misc = { + fp_confirm_dialog = (function(player, _) + if not util.globals.ui_state(player).selection_mode then + util.raise.close_dialog(player, "submit") + end + end), + + fp_confirm_gui = (function(player, _) + -- Note that a GUI was closed by confirming, so it'll try submitting on_gui_closed + local modal_data = util.globals.modal_data(player) + if modal_data ~= nil then modal_data.confirmed_dialog = true end + end), + + fp_focus_searchfield = (function(player, _) + local ui_state = util.globals.ui_state(player) + + if ui_state.modal_dialog_type ~= nil then + local textfield_search = ui_state.modal_data.modal_elements.search_textfield + if textfield_search then util.gui.select_all(textfield_search) end + end + end) +} + +listeners.global = { + run_delayed_modal_search = (function(metadata) + local player = game.get_player(metadata.player_index) --[[@as LuaPlayer]] + local modal_data = util.globals.modal_data(player) + if not modal_data or not modal_data.modal_elements then return end + + local searchfield = modal_data.modal_elements.search_textfield + local search_term = searchfield.text:gsub("^%s*(.-)%s*$", "%1"):lower() + GLOBAL_HANDLERS[modal_data.search_handler_name](player, search_term) + end) +} + +return { listeners } diff --git a/modfiles/ui/components/item_views.lua b/modfiles/ui/components/item_views.lua new file mode 100644 index 000000000..e6d74de74 --- /dev/null +++ b/modfiles/ui/components/item_views.lua @@ -0,0 +1,343 @@ +item_views = {} + +local processors = {} -- individual functions for each kind of view state + +function processors.items_per_timescale(metadata, raw_amount, item_proto, _) + local number = util.format.number(raw_amount * metadata.timescale, metadata.formatting_precision) + local plural_parameter = (number == "1") and 1 or 2 + local type_string = (item_proto.type == "fluid") and {"fp.l_fluid"} or {"fp.pl_item", plural_parameter} + return number, {"", number, " ", type_string, "/", metadata.timescale_string} +end + +function processors.throughput(metadata, raw_amount, item_proto, _) + local raw_number, unit_name = nil, nil + + if item_proto.type == "fluid" then + raw_number = raw_amount / metadata.pumping_speed + unit_name = "pump" + else + raw_number = raw_amount * metadata.throughput_multiplier + unit_name = metadata.belt_or_lane + end + + local number = util.format.number(raw_number, metadata.formatting_precision) + local plural_parameter = (number == "1") and 1 or 2 + local tooltip = {"", number, " ", {"fp.pl_" .. unit_name, plural_parameter}} + + return number, tooltip +end + +function processors.items_per_second_per_machine(metadata, raw_amount, item_proto, machine_count) + if machine_count == 0 then return 0, nil end -- avoid division by zero + + local raw_number = raw_amount / (math.ceil((machine_count or 1) - 0.001)) + local number = util.format.number(raw_number, metadata.formatting_precision) + + local plural_parameter = (number == "1") and 1 or 2 + local type_string = (item_proto.type == "fluid") and {"fp.l_fluid"} or {"fp.pl_item", plural_parameter} + -- If machine_count is nil, this shouldn't show /machine + local per_machine = (machine_count ~= nil) and {"", "/", {"fp.pl_machine", 1}} or "" + local tooltip = {"", number, " ", type_string, "/", {"fp.second"}, per_machine} + + return number, tooltip +end + +function processors.stacks_per_timescale(metadata, raw_amount, item_proto, _) + if item_proto.type == "fluid" then return nil, {"fp.fluid_item"} end + + local raw_number = (raw_amount * metadata.timescale) / item_proto.stack_size + local number = util.format.number(raw_number, metadata.formatting_precision) + + local plural_parameter = (number == "1") and 1 or 2 + local tooltip = {"", number, " ", {"fp.pl_stack", plural_parameter}, "/", metadata.timescale_string} + + return number, tooltip +end + +function processors.wagons_per_timescale(metadata, raw_amount, item_proto, _) + local wagon_capacity = (item_proto.type == "fluid") and metadata.fluid_wagon_capacity + or metadata.cargo_wagon_capactiy * item_proto.stack_size + local wagon_count = (raw_amount * metadata.timescale) / wagon_capacity + local number = util.format.number(wagon_count, metadata.formatting_precision) + + local plural_parameter = (number == "1") and 1 or 2 + local tooltip = {"", number, " ", {"fp.pl_wagon", plural_parameter}, "/", metadata.timescale_string} + + return number, tooltip +end + +local lift_capactity = 1000000 -- There is no API to read this utility constant +function processors.rockets_per_timescale(metadata, raw_amount, item_proto, _) + if item_proto.type == "fluid" then return nil, {"fp.fluid_item"} end + if item_proto.weight > lift_capactity then return nil, {"fp.item_too_heavy"} end + + local total_weight = raw_amount * metadata.timescale * item_proto.weight + local raw_number = total_weight / lift_capactity + local number = util.format.number(raw_number, metadata.formatting_precision) + + local plural_parameter = (number == "1") and 1 or 2 + local tooltip = {"", number, " ", {"fp.pl_rocket", plural_parameter}, "/", metadata.timescale_string} + + return number, tooltip +end + +---@param player LuaPlayer +---@param item SimpleItem +---@param item_amount number? +---@param machine_count number? +---@return string | -1 +---@return LocalisedString +function item_views.process_item(player, item, item_amount, machine_count) + local views_data = util.globals.ui_state(player).views_data ---@cast views_data -nil + + local raw_amount = item_amount or item.amount + if raw_amount == nil or (raw_amount ~= 0 and raw_amount < views_data.adjusted_margin_of_error) then + return -1, nil + end + + local proto = item.proto + if proto.type == "entity" then + local amount = (proto.fixed_unit) and raw_amount or raw_amount * views_data.timescale + local number = util.format.number(amount, views_data.formatting_precision) + local unit = proto.fixed_unit or {"fp.per_timescale", {"fp." .. TIMESCALE_MAP[views_data.timescale]}} + return number, {"", number, " ", unit} + else + local view_preferences = util.globals.preferences(player).item_views + local selected_view = view_preferences.views[view_preferences.selected_index].name + return processors[selected_view](views_data, raw_amount, proto, machine_count) + end +end + + +---@class ItemViewsData +---@field views { string: ItemViewData } +---@field timescale Timescale +---@field timescale_string LocalisedString +---@field adjusted_margin_of_error number +---@field belt_or_lane "belt" | "lane" +---@field throughput_multiplier number +---@field formatting_precision integer +---@field cargo_wagon_capactiy number +---@field fluid_wagon_capacity number + +---@class ItemViewData +---@field index integer +---@field caption LocalisedString +---@field tooltip LocalisedString + + +local function proto_and_quality_string(default) + local proto = prototypes.entity[default.proto.name] + local quality = (default.quality and default.quality.always_show) + and {"", " (", default.quality.rich_text, ")"} or "" + return proto, quality +end + +---@param player LuaPlayer +function item_views.rebuild_data(player) + local preferences = util.globals.preferences(player) + local timescale_string = TIMESCALE_MAP[preferences.timescale] + + local belt_proto = defaults.get(player, "belts").proto --[[@as FPBeltPrototype]] + local belts_or_lanes = preferences.belts_or_lanes + local throughput_divisor = (belts_or_lanes == "belts") and belt_proto.throughput or (belt_proto.throughput / 2) + + local default_pump = defaults.get(player, "pumps") + local pump_proto, pump_quality = proto_and_quality_string(default_pump) + + local default_cargo_wagon = defaults.get(player, "wagons", "cargo-wagon") + local cargo_wagon_proto, cargo_wagon_quality = proto_and_quality_string(default_cargo_wagon) + + local default_fluid_wagon = defaults.get(player, "wagons", "fluid-wagon") + local fluid_wagon_proto, fluid_wagon_quality = proto_and_quality_string(default_fluid_wagon) + + util.globals.ui_state(player).views_data = { + views = { + items_per_timescale = { + index = 1, + caption = {"", {"fp.pu_item", 2}, "/", {"fp.unit_" .. timescale_string}}, + tooltip = {"fp.view_tt", {"fp.items_per_timescale", {"fp." .. timescale_string}}} + }, + throughput = { + index = 2, + caption = {"", belt_proto.rich_text, " ", default_pump.proto.rich_text}, + tooltip = {"fp.view_tt", {"fp.throughput", {"fp.pl_" .. belts_or_lanes:sub(1, -2), 2}, + belt_proto.rich_text, belt_proto.localised_name, default_pump.proto.rich_text, + default_pump.proto.localised_name, pump_quality}} + }, + items_per_second_per_machine = { + index = 3, + caption = {"", {"fp.pu_item", 2}, "/", {"fp.unit_second"}, "/[img=fp_generic_assembler]"}, + tooltip = {"fp.view_tt", {"fp.items_per_second_per_machine"}} + }, + stacks_per_timescale = { + index = 4, + caption = {"", "[img=fp_stack]", "/", {"fp.unit_" .. timescale_string}}, + tooltip = {"fp.view_tt", {"fp.stacks_per_timescale", {"fp." .. timescale_string}}} + }, + wagons_per_timescale = { + index = 5, + caption = {"", default_cargo_wagon.proto.rich_text, default_fluid_wagon.proto.rich_text, + "/", {"fp.unit_" .. timescale_string}}, + tooltip = {"fp.view_tt", {"fp.wagons_per_timescale", {"fp." .. timescale_string}, + default_cargo_wagon.proto.rich_text, default_cargo_wagon.proto.localised_name, + cargo_wagon_quality, default_fluid_wagon.proto.rich_text, + default_fluid_wagon.proto.localised_name, fluid_wagon_quality}} + }, + rockets_per_timescale = { + index = 6, + caption = {"", "[img=fp_silo_rocket]", "/", {"fp.unit_" .. timescale_string}}, + tooltip = {"fp.view_tt", {"fp.rockets_per_timescale", {"fp." .. timescale_string}}} + } + }, + timescale = preferences.timescale, + timescale_string = {"fp.unit_" .. TIMESCALE_MAP[preferences.timescale]}, + adjusted_margin_of_error = MAGIC_NUMBERS.margin_of_error / preferences.timescale, + belt_or_lane = belts_or_lanes:sub(1, -2), + throughput_multiplier = 1 / throughput_divisor, + pumping_speed = pump_proto.get_pumping_speed(default_pump.quality.name) * 60, + cargo_wagon_capactiy = cargo_wagon_proto.get_inventory_size(defines.inventory.cargo_wagon, + default_cargo_wagon.quality.name), + -- TODO This can't check whether FluidWagonPrototype::quality_affects_capacity is set to true + -- and there is no API to get capacity dependent on quality + fluid_wagon_capacity = fluid_wagon_proto.fluid_capacity * default_cargo_wagon.quality.multiplier, + formatting_precision = 4 + } +end + +---@class ItemViewPreferences +---@field views ItemViewPreference[] +---@field selected_index integer + +---@class ItemViewPreference +---@field name string +---@field enabled boolean + +---@return ItemViewPreference[] +function item_views.default_preferences() + return { + views = { + {name="items_per_timescale", enabled=true}, + {name="throughput", enabled=true}, + {name="items_per_second_per_machine", enabled=true}, + {name="stacks_per_timescale", enabled=false}, + {name="wagons_per_timescale", enabled=false}, + {name="rockets_per_timescale", enabled=false} + }, + selected_index = 1 + } +end + + +---@param player LuaPlayer +---@param func function +local function run_on_all_views(player, func) + local ui_state = util.globals.ui_state(player) + + local main_interface = ui_state.main_elements.views_flow + local compact_interface = ui_state.compact_elements.views_flow + + for _, interface in pairs({main_interface, compact_interface}) do + if interface ~= nil and interface.valid then func(interface) end + end +end + +---@param player LuaPlayer +function item_views.rebuild_interface(player) + local view_preferences = util.globals.preferences(player).item_views + local views = util.globals.ui_state(player).views_data.views + + local function rebuild(flow) + flow.clear() + local table = flow.add{type="table", name="table_views", column_count=table_size(views)} + table.style.horizontal_spacing = 0 + + -- Iterate preferences for proper ordering + for index, view_preference in pairs(view_preferences.views) do + local view = views[view_preference.name] + local button = table.add{type="button", caption=view.caption, tooltip=view.tooltip, + tags={mod="fp", on_gui_click="change_view", view_index=index}, style="fp_button_push", + mouse_button_filter={"left"}} + end + end + + run_on_all_views(player, rebuild) + item_views.refresh_interface(player) +end + +---@param player LuaPlayer +function item_views.refresh_interface(player) + local view_preferences = util.globals.preferences(player).item_views + + local function refresh(flow) + for _, view_button in pairs(flow["table_views"].children) do + local index = view_button.tags.view_index + local preference = view_preferences.views[index] + view_button.toggled = (view_preferences.selected_index == index) + view_button.visible = preference.enabled + end + end + + run_on_all_views(player, refresh) +end + + +---@param player LuaPlayer +---@param new_index integer +local function select_view(player, new_index) + local view_preferences = util.globals.preferences(player).item_views + view_preferences.selected_index = new_index + + item_views.refresh_interface(player) + local compact_view = util.globals.ui_state(player).compact_view + local refresh = (compact_view) and "compact_factory" or "factory" + util.raise.refresh(player, refresh) +end + +---@param player LuaPlayer +---@param direction "standard" | "reverse" +function item_views.cycle_views(player, direction) + local view_preferences = util.globals.preferences(player).item_views + + local next_option = view_preferences.selected_index + local total_options = #view_preferences.views + local mover = (direction == "standard") and 1 or -1 + + while true do + next_option = next_option + mover + if next_option > total_options then next_option = 1 + elseif next_option < 1 then next_option = total_options end + + local preference = view_preferences.views[next_option] + if preference.enabled then + select_view(player, next_option) + break + end + end +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "change_view", + handler = (function(player, tags, _) + select_view(player, tags.view_index) + end) + } + } +} + +listeners.misc = { + fp_cycle_production_views = (function(player, _) + item_views.cycle_views(player, "standard") + end), + fp_reverse_cycle_production_views = (function(player, _) + item_views.cycle_views(player, "reverse") + end) +} + +return { listeners } diff --git a/modfiles/ui/components/module_configurator.lua b/modfiles/ui/components/module_configurator.lua new file mode 100644 index 000000000..e59850a41 --- /dev/null +++ b/modfiles/ui/components/module_configurator.lua @@ -0,0 +1,233 @@ +local Module = require("backend.data.Module") + +-- Contains the UI and event handling for machine/beacon modules +module_configurator = {} + +-- ** LOCAL UTIL** +local function determine_slider_config(module, empty_slots) + local slider_value = (module) and module.amount or empty_slots + local maximum_value = (module) and (module.amount + empty_slots) or empty_slots + local minimum_value = (maximum_value == 1) and 0 or 1 -- to make sure that the slider can be created + return slider_value, maximum_value, minimum_value +end + +local function add_module_frame(parent_flow, module, module_filters, empty_slots) + local module_id = module and module.id or nil + + local frame_module = parent_flow.add{type="frame", style="fp_frame_module", direction="horizontal", + tags={module_id=module_id}} + frame_module.add{type="label", caption={"fp.pu_module", 1}, style="semibold_label"} + + local button_module = frame_module.add{type="choose-elem-button", name="fp_chooser_module", + tags={mod="fp", on_gui_elem_changed="select_module", module_id=module_id}, + elem_type="item-with-quality", elem_filters=module_filters, style="fp_sprite-button_inset"} + button_module.elem_value = (module) and module:elem_value() or nil + + local label_amount = frame_module.add{type="label", caption={"fp.amount"}, style="semibold_label"} + label_amount.style.left_margin = 8 + + local slider_value, maximum_value, minimum_value = determine_slider_config(module, empty_slots) + local numeric_enabled = (maximum_value ~= 1 and module ~= nil) + + local slider = frame_module.add{type="slider", name="fp_slider_module_amount", style="notched_slider", + tags={mod="fp", on_gui_value_changed="module_amount_value", module_id=module_id}, + minimum_value=minimum_value, maximum_value=maximum_value, value=slider_value, value_step=0.1} + slider.style.minimal_width = 0 + slider.style.horizontally_stretchable = true + slider.style.margin = {0, 6} + -- Fix for the slider value step "not bug" (see https://forums.factorio.com/viewtopic.php?p=516440#p516440) + -- Fixed by setting step to something other than 1 first, then setting it to 1 + slider.set_slider_value_step(1) + slider.enabled = numeric_enabled -- needs to be set here because sliders are buggy as fuck + + local textfield = frame_module.add{type="textfield", name="fp_textfield_module_amount", enabled=numeric_enabled, + text=tostring(slider_value), tags={mod="fp", on_gui_text_changed="module_amount_text", module_id=module_id}} + util.gui.setup_numeric_textfield(textfield, false, false) + textfield.style.width = 40 +end + +local function add_effects_section(parent_flow, object, modal_elements) + local frame_effects = parent_flow.add{type="frame", direction="vertical", style="fp_frame_bordered_stretch"} + frame_effects.style.vertically_stretchable = true + frame_effects.style.width = (MAGIC_NUMBERS.module_dialog_element_width / 2) - 2 + + local class_lower = object.class:lower() + local title = (object.class == "Line") and "recipe" or class_lower + local caption, tooltip = {"", {"fp.pu_" .. title, 1}, " ", {"fp.effects"}}, {""} + if object.class == "Machine" then caption, tooltip = {"fp.info_label", caption}, {"fp.machine_effects_tt"} end + frame_effects.add{type="label", caption=caption, tooltip=tooltip, style="semibold_label"} + + local label_effects = frame_effects.add{type="label", caption=object.effects_tooltip} + label_effects.style.single_line = false + modal_elements[class_lower .. "_effects_label"] = label_effects +end + + +local function handle_module_selection(player, tags, event) + local module_set = util.globals.modal_data(player).module_set + local new_module = event.element.elem_value + + local function check_existing(module) + if module_set:find({proto=module.proto, quality_proto=module.quality_proto}) then + util.cursor.create_flying_text(player, {"fp.configurator_duplicate_module"}) + return true + end + end + + if tags.module_id then -- editing an existing module + local existing_module = OBJECT_INDEX[tags.module_id] --[[@as Module]] + if new_module then -- changed to another module + local new_proto = MODULE_NAME_MAP[new_module.name] + local new_quality_proto = prototyper.util.find("qualities", new_module.quality, nil) + local module = Module.init(new_proto, existing_module.amount, new_quality_proto) + module_set:remove(existing_module) + if not check_existing(module) then module_set:insert(module) end + else -- removed module + module_set:remove(existing_module) + end + elseif new_module then -- choosing a new module on an empty line + local slider = event.element.parent["fp_slider_module_amount"] + local module_proto = MODULE_NAME_MAP[new_module.name] + local quality_proto = prototyper.util.find("qualities", new_module.quality, nil) + local module = Module.init(module_proto, slider.slider_value, quality_proto) + if not check_existing(module) then module_set:insert(module) end + end + + module_set:normalize({effects=true}) + module_configurator.refresh_modules_flow(player, false) +end + +local function handle_module_slider_change(player, tags, event) + local module_set = util.globals.modal_data(player).module_set + local new_slider_value = event.element.slider_value + + local module = OBJECT_INDEX[tags.module_id] --[[@as Module]] + module:set_amount(new_slider_value) + module_set:normalize({effects=true}) + module_configurator.refresh_modules_flow(player, true) +end + +local function handle_module_textfield_change(player, tags, event) + local module_set = util.globals.modal_data(player).module_set + local new_textfield_value = tonumber(event.element.text) + local module_slider = event.element.parent["fp_slider_module_amount"] + + local slider_maximum = module_slider.get_slider_maximum() + local normalized_amount = math.max(1, (new_textfield_value or 1)) + local new_amount = math.min(normalized_amount, slider_maximum) + + local module = OBJECT_INDEX[tags.module_id] --[[@as Module]] + module:set_amount(new_amount) + module_set:normalize({effects=true}) + module_configurator.refresh_modules_flow(player, true) +end + + +-- ** TOP LEVEL ** +function module_configurator.add_modules_flow(parent, modal_data) + local flow_modules = parent.add{type="flow", direction="vertical"} + modal_data.modal_elements["modules_flow"] = flow_modules +end + +function module_configurator.refresh_effects_flow(modal_data) + local class_lower = modal_data.object.class:lower() + local object_label = modal_data.modal_elements[class_lower .. "_effects_label"] + if not object_label or not object_label.valid then return end + + local line_effects = modal_data.line.effects_tooltip + local any_line_effects = (#line_effects > 1) + + object_label.parent.parent.visible = any_line_effects + if any_line_effects then + object_label.caption = modal_data.object.effects_tooltip + modal_data.modal_elements["line_effects_label"].caption = line_effects + end +end + +function module_configurator.refresh_modules_flow(player, update_only) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local modules_flow = modal_data.modal_elements.modules_flow + + local module_filters = modal_data.module_set:compile_filter() + local empty_slots = modal_data.module_set.empty_slots + + if update_only then + module_configurator.refresh_effects_flow(modal_data) + + -- Update the UI instead of rebuilding it so the slider can be dragged properly + for _, frame in pairs(modules_flow.children) do + if frame.name == "flow_effects" then goto skip end + + local module_id = frame.tags.module_id + if module_id == nil then + frame.destroy() -- destroy empty frame as it'll be re-added below + else + local module = modal_data.module_set:find({id=module_id}) + if module == nil then + frame.destroy() + else + local slider_value, maximum_value, minimum_value = determine_slider_config(module, empty_slots) + + frame["fp_chooser_module"].elem_value = module:elem_value() + + local textfield = frame["fp_textfield_module_amount"] + textfield.text = tostring(module.amount) + textfield.enabled = (maximum_value ~= 1) + + local slider = frame["fp_slider_module_amount"] + slider.set_slider_value_step(0.1) -- bug workaround + slider.set_slider_minimum_maximum(minimum_value, maximum_value) + slider.slider_value = slider_value + slider.set_slider_value_step(1) -- bug workaround + slider.enabled = (maximum_value ~= 1) + end + end + ::skip:: + end + else + modules_flow.clear() + + if #modal_data.line.effects_tooltip > 1 then + local effects_flow = modules_flow.add{type="flow", direction="horizontal", name="flow_effects"} + add_effects_section(effects_flow, modal_data.object, modal_data.modal_elements) + add_effects_section(effects_flow, modal_data.line, modal_data.modal_elements) + end + + for module in modal_data.module_set:iterator() do + add_module_frame(modules_flow, module, module_filters, empty_slots) + end + end + + if empty_slots > 0 then add_module_frame(modules_flow, nil, module_filters, empty_slots) end + if modal_data.defaults_refresher then GLOBAL_HANDLERS[modal_data.defaults_refresher](player) end + if modal_data.submit_checker then GLOBAL_HANDLERS[modal_data.submit_checker](modal_data) end + + modules_flow.visible = (#modules_flow.children > 0) +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_elem_changed = { + { + name = "select_module", + handler = handle_module_selection + } + }, + on_gui_value_changed = { + { + name = "module_amount_value", + handler = handle_module_slider_change + } + }, + on_gui_text_changed = { + { + name = "module_amount_text", + handler = handle_module_textfield_change + } + } +} + +return { listeners } diff --git a/modfiles/ui/dialogs/beacon_dialog.lua b/modfiles/ui/dialogs/beacon_dialog.lua new file mode 100644 index 000000000..1a8d039b2 --- /dev/null +++ b/modfiles/ui/dialogs/beacon_dialog.lua @@ -0,0 +1,342 @@ +local Beacon = require("backend.data.Beacon") + +-- ** LOCAL UTIL ** +local function refresh_defaults_frame(player) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local modal_elements = modal_data.modal_elements + local beacon = modal_data.object --[[@as Beacon]] + + local beacon_tooltip = defaults.generate_tooltip(player, "beacons", nil) + local beacon_default = defaults.get(player, "beacons", nil) + local equals_beacon = defaults.equals_default(player, "beacons", beacon, nil) + local equals_amount = (beacon_default.beacon_amount == beacon.amount) + + modal_elements.beacon_title.tooltip = beacon_tooltip + modal_elements.beacon.enabled = not equals_beacon + modal_elements.amount.enabled = not equals_amount +end + +local function add_defaults_frame(parent_frame, player) + local modal_elements = util.globals.modal_elements(player) + + local frame_defaults = parent_frame.add{type="frame", direction="horizontal", style="fp_frame_bordered_stretch"} + frame_defaults.style.top_padding = 7 + local flow_defaults = frame_defaults.add{type="flow", direction="horizontal"} + flow_defaults.style.vertical_align = "center" + modal_elements["defaults_flow"] = flow_defaults + + flow_defaults.add{type="label", caption={"fp.defaults"}, style="semibold_label"} + + local info_caption = {"fp.info_label", {"", {"fp.pu_beacon", 1}, " & ", {"fp.pu_module", 2}}} + local label_info = modal_elements.defaults_flow.add{type="label", caption=info_caption, style="semibold_label"} + label_info.style.margin = {0, 8, 0, 24} + modal_elements["beacon_title"] = label_info + + local button_beacon = modal_elements.defaults_flow.add{type="sprite-button", sprite="fp_default", + tags={mod="fp", on_gui_click="set_beacon_default", action="beacon"}, + tooltip={"fp.save_as_default_beacon"}, style="tool_button"} + modal_elements["beacon"] = button_beacon + + local button_amount = modal_elements.defaults_flow.add{type="sprite-button", sprite="fp_amount", + tags={mod="fp", on_gui_click="set_beacon_default", action="amount"}, + tooltip={"fp.save_beacon_amount"}, style="tool_button"} + modal_elements["amount"] = button_amount + + refresh_defaults_frame(player) +end + +local function set_defaults(player, tags, _) + local beacon = util.globals.modal_data(player).object + + if tags.action == "beacon" then + local data = { + prototype = beacon.proto.name, + quality = beacon.quality_proto.name, + modules = beacon.module_set:compile_default(), + } + defaults.set(player, "beacons", data, nil) + + elseif tags.action == "amount" then + local data = { beacon_amount = beacon.amount } + defaults.set(player, "beacons", data, nil) + end + + refresh_defaults_frame(player) +end + + +local function add_beacon_frame(parent_flow, modal_data) + local modal_elements = modal_data.modal_elements + local beacon = modal_data.object + + local flow_beacon = parent_flow.add{type="frame", style="fp_frame_module", direction="horizontal"} + flow_beacon.style.width = MAGIC_NUMBERS.module_dialog_element_width + + flow_beacon.add{type="label", caption={"fp.pu_beacon", 1}, style="semibold_label"} + local beacon_filter = {{filter="type", type="beacon"}, {filter="hidden", invert=true, mode="and"}} + local button_beacon = flow_beacon.add{type="choose-elem-button", elem_type="entity-with-quality", + tags={mod="fp", on_gui_elem_changed="select_beacon"}, elem_filters=beacon_filter, + style="fp_sprite-button_inset"} + button_beacon.elem_value = beacon:elem_value() + button_beacon.style.right_margin = 12 + modal_elements["beacon_button"] = button_beacon + + flow_beacon.add{type="label", caption={"fp.info_label", {"fp.amount"}}, tooltip={"fp.beacon_amount_tt"}, + style="semibold_label"} + local beacon_amount = (beacon.amount ~= 0) and tostring(beacon.amount) or "" + local amount_width = 40 + local textfield_amount = flow_beacon.add{type="textfield", text=beacon_amount, + tags={mod="fp", on_gui_text_changed="beacon_amount", on_gui_confirmed="confirm_beacon", + width=amount_width}, tooltip={"fp.expression_textfield"}} + textfield_amount.style.width = amount_width + util.gui.select_all(textfield_amount) + modal_elements["beacon_amount"] = textfield_amount + + local label_profile = flow_beacon.add{type="label", tooltip={"fp.beacon_profile_tt"}} + label_profile.style.width = 64 + modal_elements["profile_label"] = label_profile + + flow_beacon.add{type="label", caption={"fp.info_label", {"fp.beacon_total"}}, tooltip={"fp.beacon_total_tt"}, + style="semibold_label"} + local total_width = 40 + local textfield_total = flow_beacon.add{type="textfield", text=tostring(beacon.total_amount or ""), + tags={mod="fp", on_gui_text_changed="beacon_total_amount", on_gui_confirmed="confirm_beacon", + width=total_width}, tooltip={"fp.expression_textfield"}} + textfield_total.style.width = total_width + modal_elements["beacon_total"] = textfield_total + + local button_total = flow_beacon.add{type="sprite-button", tags={mod="fp", on_gui_click="use_beacon_selector"}, + tooltip={"fp.beacon_selector_tt"}, sprite="fp_zone_selection", style="button", mouse_button_filter={"left"}} + button_total.style.padding = 2 + button_total.style.size = 26 + button_total.style.top_margin = 1 +end + + +local function update_profile_label(modal_data) + local profile_multiplier = modal_data.object:profile_multiplier() + local label_profile = modal_data.modal_elements.profile_label + label_profile.caption = (profile_multiplier > 0) and "x " .. profile_multiplier or "x ---" +end + +local function update_dialog_submit_button(modal_data) + local beacon_amount = modal_data.object.amount + + local message = nil + if not beacon_amount or beacon_amount == 0 then + message = {"fp.beacon_issue_set_amount"} + elseif modal_data.module_set.module_count == 0 then + message = {"fp.beacon_issue_no_modules"} + end + modal_dialog.set_submit_button_state(modal_data.modal_elements, (message == nil), message) +end + + +local function reset_beacon(player) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local beacon = modal_data.object --[[@as Beacon]] + beacon:reset(player) + + -- Some manual refreshing which don't have their own method + modal_data.modal_elements["beacon_button"].elem_value = beacon:elem_value() + modal_data.modal_elements["beacon_amount"].text = tostring(beacon.amount) + + module_configurator.refresh_modules_flow(player, false) + refresh_defaults_frame(player) + update_dialog_submit_button(modal_data) +end + + +local function handle_beacon_change(player, _, _) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local beacon = modal_data.object + local beacon_button = modal_data.modal_elements.beacon_button + local elem_value = beacon_button.elem_value + + if not elem_value then + beacon_button.elem_value = beacon:elem_value() -- reset the beacon so it can't be nil + util.cursor.create_flying_text(player, {"fp.no_removal", {"fp.pu_beacon", 1}}) + return -- nothing changed + end + + -- Change the beacon to the new type + beacon.proto = prototyper.util.find("beacons", elem_value.name, nil) + beacon.quality_proto = prototyper.util.find("qualities", elem_value.quality, nil) + beacon.module_set:normalize({compatibility=true, trim=true, effects=true}) + + update_profile_label(modal_data) + module_configurator.refresh_modules_flow(player, false) + refresh_defaults_frame(player) +end + +local function handle_amount_change(player, _, _) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local textfield = modal_data.modal_elements.beacon_amount + + local expression = util.gui.parse_expression_field(textfield) + local invalid = (textfield.text ~= "" and (expression == nil or expression < 0 or expression % 1 ~= 0)) + + textfield.style = (invalid) and "invalid_value_textfield" or "textbox" + textfield.style.width = textfield.tags.width --[[@as number]] -- this is stupid but styles work out that way + + modal_data.object.amount = (invalid) and 0 or (expression or 0) + modal_data.module_set:normalize({effects=true}) + + update_profile_label(modal_data) + module_configurator.refresh_modules_flow(player, false) + refresh_defaults_frame(player) + update_dialog_submit_button(modal_data) +end + +local function handle_beacon_selection(player, entities) + local modal_elements = util.globals.modal_elements(player) + modal_elements.beacon_total.text = tostring(table_size(entities)) + modal_elements.beacon_total.focus() + + modal_dialog.leave_selection_mode(player) +end + + +local function open_beacon_dialog(player, modal_data) + local line = OBJECT_INDEX[modal_data.line_id] --[[@as Line]] + modal_data.line = line + + if line.beacon ~= nil then + modal_data.backup_beacon = line.beacon:clone() + modal_data.object = line.beacon + else + local default_beacon = defaults.get(player, "beacons") + modal_data.object = Beacon.init(default_beacon.proto --[[@as FPBeaconPrototype]], line) + modal_data.object.quality_proto = default_beacon.quality + modal_data.object.amount = default_beacon.beacon_amount or 0 + modal_data.object.module_set:ingest_default(default_beacon.modules) + line:set_beacon(modal_data.object) + end + modal_data.module_set = modal_data.object.module_set + + local content_frame = modal_data.modal_elements.content_frame + + -- Beacon + add_beacon_frame(content_frame, modal_data) + update_profile_label(modal_data) + update_dialog_submit_button(modal_data) + + -- Modules + modal_data.submit_checker = "beacon_submit_checker" + module_configurator.add_modules_flow(content_frame, modal_data) + module_configurator.refresh_modules_flow(player, false) + + -- Defaults + modal_data.defaults_refresher = "beacon_defaults_refresher" + add_defaults_frame(content_frame, player) +end + +local function close_beacon_dialog(player, action) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local factory = util.context.get(player, "Factory") + + if action == "submit" then + local beacon = modal_data.object + local total_amount = util.gui.parse_expression_field(modal_data.modal_elements.beacon_total) or 0 + beacon.total_amount = (total_amount > 0) and total_amount or nil + + solver.update(player, factory) + util.raise.refresh(player, "factory") + + elseif action == "delete" then + modal_data.line:set_beacon(nil) + solver.update(player, factory) + util.raise.refresh(player, "factory") + + else -- action == "cancel" + modal_data.line:set_beacon(modal_data.backup_beacon) -- could be nil + -- Need to refresh so the buttons have the 'new' backup beacon for further actions + util.raise.refresh(player, "production_detail") + end +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_elem_changed = { + { + name = "select_beacon", + handler = handle_beacon_change + } + }, + on_gui_text_changed = { + { + name = "beacon_amount", + handler = handle_amount_change + }, + { + name = "beacon_total_amount", + handler = (function(_, _, event) + util.gui.update_expression_field(event.element) + end) + } + }, + on_gui_confirmed = { + { + name = "confirm_beacon", + handler = (function(player, _, event) + local confirmed = util.gui.confirm_expression_field(event.element, true) + if confirmed then util.raise.close_dialog(player, "submit") end + end) + } + }, + on_gui_click = { + { + name = "use_beacon_selector", + timeout = 20, + handler = (function(player, _, _) + modal_dialog.enter_selection_mode(player, "fp_beacon_selector") + end) + }, + { + name = "set_beacon_default", + handler = set_defaults + } + } +} + +listeners.dialog = { + dialog = "beacon", + metadata = (function(modal_data) + local line = OBJECT_INDEX[modal_data.line_id] --[[@as Line]] + local machine_name = line.machine.proto.localised_name + return { + caption = {"", {"fp." .. "edit"}, " ", {"fp.pl_beacon", 1}}, + subheader_text = {"fp.beacon_dialog_description", machine_name}, + show_submit_button = true, + show_delete_button = (line.beacon ~= nil), + reset_handler_name = "reset_beacon" + } + end), + open = open_beacon_dialog, + close = close_beacon_dialog +} + +listeners.global = { + beacon_defaults_refresher = refresh_defaults_frame, + beacon_submit_checker = update_dialog_submit_button, + reset_beacon = reset_beacon +} + +listeners.misc = { + on_player_cursor_stack_changed = (function(player, _) + -- If the cursor stack is not valid_for_read, it's empty, thus the selector has been put away + if util.globals.ui_state(player).selection_mode and not player.cursor_stack.valid_for_read then + modal_dialog.leave_selection_mode(player) + end + end), + on_player_selected_area = (function(player, event) + if event.item == "fp_beacon_selector" and util.globals.ui_state(player).selection_mode then + handle_beacon_selection(player, event.entities) + end + end) +} + +return { listeners } diff --git a/modfiles/ui/dialogs/factory_dialog.lua b/modfiles/ui/dialogs/factory_dialog.lua new file mode 100644 index 000000000..d160b8d86 --- /dev/null +++ b/modfiles/ui/dialogs/factory_dialog.lua @@ -0,0 +1,66 @@ +-- ** LOCAL UTIL ** +local function open_factory_dialog(player, modal_data) + local id = modal_data.factory_id + modal_data.factory = (id ~= nil) and OBJECT_INDEX[id] or nil + + local content_frame = modal_data.modal_elements.content_frame + local flow_name = content_frame.add{type="flow", direction="horizontal"} + flow_name.style.vertical_align = "center" + flow_name.add{type="label", caption={"fp.info_label", {"fp.name"}}, tooltip={"fp.factory_dialog_name_tt"}} + + local factory_name = (modal_data.factory ~= nil) and modal_data.factory.name or "" + local textfield_name = flow_name.add{type="textfield", text=factory_name, icon_selector=true, + tags={mod="fp", on_gui_confirmed="factory_name"}} + textfield_name.style.left_margin = 16 + textfield_name.focus() + modal_data.modal_elements["factory_name"] = textfield_name +end + +local function close_factory_dialog(player, action) + local modal_data = util.globals.modal_data(player) + + if action == "submit" then + local name_textfield = modal_data.modal_elements.factory_name + local factory_name = name_textfield.text:gsub("^%s*(.-)%s*$", "%1") + + if modal_data.factory ~= nil then modal_data.factory.name = factory_name + else factory_list.add_factory(player, factory_name) end + + util.raise.refresh(player, "all") + + elseif action == "delete" then + factory_list.delete_factory(player) -- handles archiving if necessary + end +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_confirmed = { + { + name = "factory_name", + handler = (function(player, _, _) + util.raise.close_dialog(player, "submit") + end) + } + } +} + +listeners.dialog = { + dialog = "factory", + metadata = (function(modal_data) + local action = (modal_data.factory_id) and {"fp.edit"} or {"fp.add"} + return { + caption = {"", action, " ", {"fp.pl_factory", 1}}, + subheader_text = {"fp.factory_dialog_description"}, + show_submit_button = true, + show_delete_button = (modal_data.factory_id ~= nil) + } + end), + open = open_factory_dialog, + close = close_factory_dialog +} + +return { listeners } diff --git a/modfiles/ui/dialogs/item_dialog.lua b/modfiles/ui/dialogs/item_dialog.lua new file mode 100644 index 000000000..6c287de1e --- /dev/null +++ b/modfiles/ui/dialogs/item_dialog.lua @@ -0,0 +1,101 @@ +-- ** LOCAL UTIL ** +local function select_temperature(player, temperature) + local modal_data = util.globals.modal_data(player) + local table_temperatures = modal_data.modal_elements.temperatures_table + + for _, button in pairs(table_temperatures.children) do + local matched = (button.tags.temperature == temperature) + button.toggled = not button.toggled and matched + end +end + +local function open_item_dialog(player, modal_data) + local object = OBJECT_INDEX[modal_data.line_id or modal_data.fuel_id] + local temperature_data = (modal_data.line_id) and object.temperature_data[modal_data.name] + or object.temperature_data + + local content_frame = modal_data.modal_elements.content_frame + local flow_temperature = content_frame.add{type="flow", direction="horizontal"} + flow_temperature.style.vertical_align = "center" + flow_temperature.add{type="label", caption={"fp.info_label", {"fp.compatible_temperatures"}}, + tooltip={"fp.item_temperature_tt"}} + + local annotation = flow_temperature.add{type="label", caption=temperature_data.annotation} + annotation.style.left_margin = 16 + + local values = temperature_data.applicable_values + local table_temperatures = content_frame.add{type="table", column_count=#values} + table_temperatures.style.horizontal_spacing = 0 + table_temperatures.style.top_margin = 8 + table_temperatures.style.left_margin = 12 + modal_data.modal_elements["temperatures_table"] = table_temperatures + + for index, temperature in pairs(values) do + table_temperatures.add{type="button", caption={"fp.temperature_value", temperature}, + tags={mod="fp", on_gui_click="change_item_temperature", temperature=temperature}, + style="fp_button_push", mouse_button_filter={"left"}} + end + + local temperature = nil -- needs to be an if because the value can be nil + if object.class == "Line" then temperature = object.temperatures[modal_data.name] + else temperature = object.temperature end + select_temperature(player, temperature) -- sets toggled state +end + +local function close_item_dialog(player, action) + if action == "submit" then + local modal_data = util.globals.modal_data(player) + local table_temperatures = modal_data.modal_elements.temperatures_table + + local object = OBJECT_INDEX[modal_data.line_id or modal_data.fuel_id] + local temperature = nil -- reset if none is selected + + for _, button in pairs(table_temperatures.children) do + if button.toggled then + temperature = button.tags.temperature + break + end + end + + if object.class == "Fuel" then + object.temperature = temperature + else + object.temperatures[modal_data.name] = temperature + end + + solver.update(player) + util.raise.refresh(player, "factory") + end +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "change_item_temperature", + handler = (function(player, tags, _) + select_temperature(player, tags.temperature) + end) + } + } +} + +listeners.dialog = { + dialog = "item", + metadata = (function(modal_data) + local data_type = (modal_data.fuel_id) and "fuels" or "items" + local proto = prototyper.util.find(data_type, modal_data.name, modal_data.category_id) + return { + caption = {"", {"fp.edit"}, " ", {"fp.pl_item", 1}}, + subheader_text = {"fp.item_dialog_description", proto.localised_name}, + show_submit_button = true + } + end), + open = open_item_dialog, + close = close_item_dialog +} + +return { listeners } diff --git a/modfiles/ui/dialogs/machine_dialog.lua b/modfiles/ui/dialogs/machine_dialog.lua new file mode 100644 index 000000000..8da804e2c --- /dev/null +++ b/modfiles/ui/dialogs/machine_dialog.lua @@ -0,0 +1,372 @@ +-- ** LOCAL UTIL ** +local function add_defaults_section(modal_elements, identifier, info_caption) + local label_info = modal_elements.defaults_flow.add{type="label", caption=info_caption, style="semibold_label"} + label_info.style.margin = {0, 8, 0, 24} + modal_elements[identifier .. "_title"] = label_info + + local button = modal_elements.defaults_flow.add{type="sprite-button", sprite="fp_default", + tags={mod="fp", on_gui_click="set_machine_default", action=identifier}, + tooltip={"fp.save_as_default_" .. identifier}, style="tool_button"} + modal_elements[identifier] = button + + local button_all = modal_elements.defaults_flow.add{type="sprite-button", sprite="fp_default_all", + tags={mod="fp", on_gui_click="set_machine_default", action=(identifier .. "_all")}, + tooltip={"fp.save_for_all_" .. identifier}, style="tool_button"} + modal_elements[identifier .. "_all"] = button_all +end + +local function refresh_defaults_frame(player) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local modal_elements = modal_data.modal_elements + local machine = modal_data.object --[[@as Machine]] + + -- Machine + local machine_tooltip = defaults.generate_tooltip(player, "machines", machine.proto.category) + local equals_machine = defaults.equals_default(player, "machines", machine, machine.proto.category) + local equals_all_machines = defaults.equals_all_defaults(player, "machines", machine) + + modal_elements.machine_title.tooltip = machine_tooltip + modal_elements.machine.enabled = not equals_machine + modal_elements.machine_all.enabled = not equals_all_machines + + -- Fuel + local fuel_required = (machine.proto.burner ~= nil) + local fuel_tooltip = {"fp.machine_no_fuel_required"} ---@type LocalisedString + local equals_fuel, equals_all_fuels = false, false + if fuel_required then + local category = machine.proto.burner.combined_category + fuel_tooltip = defaults.generate_tooltip(player, "fuels", category) + equals_fuel = defaults.equals_default(player, "fuels", machine.fuel, category) + equals_all_fuels = defaults.equals_all_defaults(player, "fuels", machine.fuel) + end + + modal_elements.fuel_title.tooltip = fuel_tooltip + modal_elements.fuel.enabled = fuel_required and not equals_fuel + modal_elements.fuel_all.enabled = fuel_required and not equals_all_fuels +end + +local function add_defaults_frame(parent_frame, player) + local modal_elements = util.globals.modal_elements(player) + + local frame_defaults = parent_frame.add{type="frame", direction="horizontal", style="fp_frame_bordered_stretch"} + frame_defaults.style.top_padding = 7 + local flow_defaults = frame_defaults.add{type="flow", direction="horizontal"} + flow_defaults.style.vertical_align = "center" + modal_elements["defaults_flow"] = flow_defaults + + flow_defaults.add{type="label", caption={"fp.defaults"}, style="semibold_label"} + + local machine_info = {"fp.info_label", {"", {"fp.pu_machine", 1}, " & ", {"fp.pu_module", 2}}} + add_defaults_section(modal_elements, "machine", machine_info) + + local fuel_info = {"fp.info_label", {"fp.pu_fuel", 1}} + add_defaults_section(modal_elements, "fuel", fuel_info) + + refresh_defaults_frame(player) +end + +local function set_defaults(player, tags, _) + local machine = util.globals.modal_data(player).object + + local machine_data = { + prototype = machine.proto.name, + quality = machine.quality_proto.name, + modules = machine.module_set:compile_default() + } + + if tags.action == "machine_all" then + defaults.set_all(player, "machines", machine_data) + elseif tags.action == "machine" then + defaults.set(player, "machines", machine_data, machine.proto.category) + + elseif tags.action == "fuel_all" then + defaults.set_all(player, "fuels", {prototype=machine.fuel.proto.name}) + elseif tags.action == "fuel" then + local category = machine.proto.burner.combined_category + defaults.set(player, "fuels", {prototype=machine.fuel.proto.name}, category) + end + + refresh_defaults_frame(player) +end + + +local function refresh_fuel_frame(player) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local modal_elements = modal_data.modal_elements + local machine = modal_data.object + + local burner = machine.proto.burner + modal_elements.fuel_label.visible = (burner == nil) + modal_elements.fuel_button_flow.clear() + + if burner == nil then return end + + local fuel_proto = machine.fuel.proto + local elem_type = (burner and burner.categories["fluid-fuel"]) and "fluid" or "item" + + modal_elements.fuel_button_flow.add{type="choose-elem-button", elem_type=elem_type, + [elem_type] = fuel_proto.name, elem_filters=machine:compile_fuel_filter(), + tags={mod="fp", on_gui_elem_changed="choose_fuel"}, style="fp_sprite-button_inset"} +end + + +local function reset_machine(player) + local machine = util.globals.modal_data(player).object --[[@as Machine]] + machine:reset(player) + + -- Some manual refreshing which don't have their own method + local modal_elements = util.globals.modal_elements(player) --[[@as table]] + modal_elements["machine_button"].elem_value = machine:elem_value() + + local limit_switch = modal_elements.force_limit_switch + if limit_switch.enabled then + modal_elements["limit_textfield"].text = machine.limit or "" + limit_switch.switch_state = util.gui.switch.convert_to_state(machine.force_limit) + end + + refresh_fuel_frame(player) + module_configurator.refresh_modules_flow(player, false) + refresh_defaults_frame(player) +end + + +local function create_choice_frame(parent_frame, label_caption) + local frame_choices = parent_frame.add{type="frame", direction="horizontal", style="fp_frame_bordered_stretch"} + frame_choices.style.width = (MAGIC_NUMBERS.module_dialog_element_width / 2) - 2 + + local flow_choices = frame_choices.add{type="flow", direction="horizontal"} + flow_choices.style.vertical_align = "center" + + flow_choices.add{type="label", caption=label_caption, style="semibold_label"} + flow_choices.add{type="empty-widget", style="flib_horizontal_pusher"} + + return flow_choices +end + +local function add_machine_frame(parent_frame, player, line) + local modal_elements = util.globals.modal_data(player).modal_elements + local flow_choices = create_choice_frame(parent_frame, {"fp.pu_machine", 1}) + + local button_machine = flow_choices.add{type="choose-elem-button", elem_type="entity-with-quality", + tags={mod="fp", on_gui_elem_changed="choose_machine"}, style="fp_sprite-button_inset", + elem_filters=line:compile_machine_filter()} + button_machine.elem_value = line.machine:elem_value() + modal_elements["machine_button"] = button_machine +end + +local function add_fuel_frame(parent_frame, player, line) + local modal_elements = util.globals.modal_data(player).modal_elements + local flow_choices = create_choice_frame(parent_frame, {"fp.pu_fuel", 1}) + + local label_fuel = flow_choices.add{type="label", caption={"fp.machine_no_fuel_required"}} + label_fuel.style.padding = {6, 4} + modal_elements["fuel_label"] = label_fuel + + local flow_fuel_button = flow_choices.add{type="flow", direction="horizontal"} + modal_elements["fuel_button_flow"] = flow_fuel_button + -- Button recreated on refresh because its type can change + + refresh_fuel_frame(player) +end + + +local function add_limit_frame(parent_frame, player, enabled) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local machine = modal_data.object + + local frame_limit = parent_frame.add{type="frame", direction="horizontal", style="fp_frame_module"} + frame_limit.add{type="label", caption={"fp.info_label", {"fp.machine_limit"}}, + tooltip={"fp.machine_limit_tt"}, style="semibold_label"} + + local textfield_width = 45 + local textfield_limit = frame_limit.add{type="textfield", tags={mod="fp", on_gui_text_changed="machine_limit", + on_gui_confirmed="confirm_machine", width=textfield_width}, tooltip={"fp.expression_textfield"}, + text=machine.limit, enabled=enabled} + textfield_limit.style.width = textfield_width + modal_data.modal_elements["limit_textfield"] = textfield_limit + + local label_force = frame_limit.add{type="label", caption={"fp.info_label", {"fp.machine_force_limit"}}, + tooltip={"fp.machine_force_limit_tt"}, style="semibold_label"} + label_force.style.left_margin = 12 + + local state = util.gui.switch.convert_to_state(machine.force_limit) + local switch_force_limit = util.gui.switch.add_on_off(frame_limit, nil, {}, state) + switch_force_limit.enabled = enabled + modal_data.modal_elements["force_limit_switch"] = switch_force_limit + + if not enabled then + frame_limit.add{type="label", caption={"fp.machine_limit_unavailable"}, + tooltip={"fp.machine_limit_unavailable_tt"}} + end +end + + +local function handle_machine_choice(player, _, event) + local machine = util.globals.modal_data(player).object --[[@as Machine]] + local elem_value = event.element.elem_value + + if not elem_value then + event.element.elem_value = machine:elem_value() -- reset the machine so it can't be nil + util.cursor.create_flying_text(player, {"fp.no_removal", {"fp.pu_machine", 1}}) + return -- nothing changed + end + + local new_machine_proto = prototyper.util.find("machines", elem_value.name, machine.proto.category) + local new_quality_proto = prototyper.util.find("qualities", elem_value.quality, nil) + + -- Can't use Line:change_machine_to_proto() as that modifies the line, which we can't do + machine.proto = new_machine_proto + machine.quality_proto = new_quality_proto + machine.parent.surface_compatibility = nil -- reset since the machine changed + machine:normalize_fuel(player) + machine.module_set:normalize({compatibility=true, trim=true, effects=true}) + + -- Make sure the line's beacon is removed if this machine no longer supports it + if not machine.parent:uses_beacon_effects() then machine.parent:set_beacon(nil) end + + refresh_fuel_frame(player) + module_configurator.refresh_modules_flow(player, false) + refresh_defaults_frame(player) +end + +local function handle_fuel_choice(player, _, event) + local machine = util.globals.modal_data(player).object + local elem_value = event.element.elem_value + + if not elem_value then + event.element.elem_value = machine.fuel.proto.name -- reset the fuel so it can't be nil + util.cursor.create_flying_text(player, {"fp.no_removal", {"fp.pu_fuel", 1}}) + return -- nothing changed + end + + local combined_category = machine.proto.burner.combined_category + machine.fuel.proto = prototyper.util.find("fuels", elem_value, combined_category) + machine.fuel:build_temperatures_data() -- validate temperature + + refresh_defaults_frame(player) +end + + +local function open_machine_dialog(player, modal_data) + modal_data.object = OBJECT_INDEX[modal_data.machine_id] --[[@as Machine]] + modal_data.line = modal_data.object.parent --[[@as Line]] + + modal_data.machine_backup = modal_data.object:clone() + modal_data.beacon_backup = modal_data.line.beacon and modal_data.line.beacon:clone() + modal_data.module_set = modal_data.object.module_set + + local content_frame = modal_data.modal_elements.content_frame + + -- Machine & Fuel + local flow_machine = content_frame.add{type="flow", direction="horizontal"} + add_machine_frame(flow_machine, player, modal_data.line) + add_fuel_frame(flow_machine, player, modal_data.line) + + -- Limit + local factory = util.context.get(player, "Factory") + -- Unavailable with matrix solver or special recipes + local limit_enabled = (factory.matrix_free_items == nil and modal_data.line.recipe_proto.energy > 0) + add_limit_frame(content_frame, player, limit_enabled) + + -- Modules + module_configurator.add_modules_flow(content_frame, modal_data) + module_configurator.refresh_modules_flow(player, false) + + -- Defaults + modal_data.defaults_refresher = "machine_defaults_refresher" + add_defaults_frame(content_frame, player) +end + +local function close_machine_dialog(player, action) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local machine, line = modal_data.object, modal_data.line + + if action == "submit" then + machine.module_set:normalize({sort=true}) + + local limit_switch = modal_data.modal_elements.force_limit_switch + if limit_switch.enabled then + machine.limit = util.gui.parse_expression_field(modal_data.modal_elements.limit_textfield) + machine.force_limit = util.gui.switch.convert_to_boolean(limit_switch.switch_state) + end + + solver.update(player) + util.raise.refresh(player, "factory") + + else -- action == "cancel" + line.machine = modal_data.machine_backup + line.machine.module_set:normalize({effects=true}) + line:set_beacon(modal_data.beacon_backup) + -- Need to refresh so the buttons have the 'new' backup machine for further actions + util.raise.refresh(player, "production_detail") + end +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_elem_changed = { + { + name = "choose_machine", + handler = handle_machine_choice + }, + { + name = "choose_fuel", + handler = handle_fuel_choice + } + }, + on_gui_text_changed = { + { + name = "machine_limit", + handler = (function(_, _, event) + util.gui.update_expression_field(event.element) + end) + } + }, + on_gui_confirmed = { + { + name = "confirm_machine", + handler = (function(player, _, event) + local confirmed = util.gui.confirm_expression_field(event.element) + if confirmed then util.raise.close_dialog(player, "submit") end + end) + } + }, + on_gui_click = { + { + name = "set_machine_default", + handler = set_defaults + } + }, + on_gui_checked_state_changed = { + { + name = "machine_checkbox_all", + handler = refresh_defaults_frame + } + } +} + +listeners.dialog = { + dialog = "machine", + metadata = (function(modal_data) + local machine = OBJECT_INDEX[modal_data.machine_id] --[[@as Machine]] + local recipe_name = machine.parent.recipe_proto.localised_name + return { + caption = {"", {"fp.edit"}, " ", {"fp.pl_machine", 1}}, + subheader_text = {"fp.machine_dialog_description", recipe_name}, + show_submit_button = true, + reset_handler_name = "reset_machine" + } + end), + open = open_machine_dialog, + close = close_machine_dialog +} + +listeners.global = { + machine_defaults_refresher = refresh_defaults_frame, + reset_machine = reset_machine +} + +return { listeners } diff --git a/modfiles/ui/dialogs/picker_dialog.lua b/modfiles/ui/dialogs/picker_dialog.lua new file mode 100644 index 000000000..46eb9e020 --- /dev/null +++ b/modfiles/ui/dialogs/picker_dialog.lua @@ -0,0 +1,512 @@ +local Product = require("backend.data.Product") + +-- This dialog works as the product picker currently, but could also work as an ingredient picker down the line +-- ** ITEM PICKER ** +local function select_item_group(modal_data, new_group_id) + modal_data.selected_group_id = new_group_id + + for group_id, group_elements in pairs(modal_data.modal_elements.groups) do + local selected_group = (group_id == new_group_id) + group_elements.button.toggled = selected_group + group_elements.scroll_pane.visible = selected_group + end +end + +local function search_picker_items(player, search_term) + local modal_data = util.globals.modal_data(player) + local modal_elements = modal_data.modal_elements + + -- Groups are indexed continuously, so using ipairs here is fine + local first_visible_group_id = nil + for group_id, group in ipairs(modal_elements.groups) do + local any_item_visible = false + + for _, subgroup_table in pairs(group.subgroup_tables) do + for item_data, element in pairs(subgroup_table) do + -- Can only get to this if translations are complete, as the textfield is disabled otherwise + local visible = (search_term == item_data.name) + or (string.find(item_data.translated_name, search_term, 1, true) ~= nil) + element.visible = visible + any_item_visible = any_item_visible or visible + end + end + + group.button.visible = any_item_visible + first_visible_group_id = first_visible_group_id or ((any_item_visible) and group_id or nil) + end + + local any_result_found = (first_visible_group_id ~= nil) + modal_elements.warning_label.visible = not any_result_found + modal_elements.filter_frame.visible = any_result_found + + if first_visible_group_id ~= nil then + local selected_group_id = modal_data.selected_group_id + local is_selected_group_visible = modal_elements.groups[selected_group_id].button.visible + local group_id_to_select = is_selected_group_visible and selected_group_id or first_visible_group_id + select_item_group(modal_data, group_id_to_select) + end +end + +local function add_item_picker(parent_flow, player) + local player_table = util.globals.player_table(player) + local ui_state = player_table.ui_state + local modal_elements = ui_state.modal_data.modal_elements + local translations = player_table.translation_tables + + local label_warning = parent_flow.add{type="label", caption={"fp.error_message", {"fp.no_item_found"}}} + label_warning.style.font = "heading-2" + label_warning.style.margin = 12 + label_warning.visible = false -- There can't be a warning upon first opening of the dialog + modal_elements["warning_label"] = label_warning + + -- Item picker (optimized for performance, so not everything is done in the obvious way) + local groups_per_row = MAGIC_NUMBERS.groups_per_row + local table_item_groups = parent_flow.add{type="table", column_count=groups_per_row} + table_item_groups.style.width = 71 * groups_per_row + table_item_groups.style.horizontal_spacing = 0 + table_item_groups.style.vertical_spacing = 0 + + local frame_filters = parent_flow.add{type="frame", style="filter_frame"} + modal_elements["filter_frame"] = frame_filters + + local group_id_cache, group_flow_cache, subgroup_table_cache = {}, {}, {} + modal_elements.groups = {} + + local existing_products = {} + if not ui_state.modal_data.create_factory then -- check if this is for a new factory or not + local factory = util.context.get(player, "Factory") --[[@as Factory]] + for product in factory:iterator() do + existing_products[product.proto.name] = true + end + end + + local items_per_row = MAGIC_NUMBERS.items_per_row + local current_item_rows, max_item_rows = 0, 0 + local current_items_in_table_count = 0 + for _, item_proto in ipairs(SORTED_ITEMS) do + if not item_proto.hidden and not item_proto.ingredient_only then + local group_name = item_proto.group.name + local group_id = group_id_cache[group_name] + local flow_subgroups, subgroup_tables = nil, nil + + if group_id == nil then + local cache_count = table_size(group_id_cache) + 1 + group_id_cache[group_name] = cache_count + group_id = cache_count + + local button_group = table_item_groups.add{type="sprite-button", sprite=("item-group/" .. group_name), + tags={mod="fp", on_gui_click="select_picker_item_group", group_id=group_id}, + style="fp_sprite-button_group_tab", tooltip=item_proto.group.localised_name, + mouse_button_filter={"left"}} + + -- This only exists when button_group also exists + local scroll_pane_subgroups = frame_filters.add{type="scroll-pane", style="shallow_scroll_pane"} + scroll_pane_subgroups.style.vertically_stretchable = true + + local frame_subgroups = scroll_pane_subgroups.add{type="frame", style="slot_button_deep_frame"} + frame_subgroups.style.vertically_stretchable = true + + -- This flow is only really needed to set the correct vertical spacing + flow_subgroups = frame_subgroups.add{type="flow", name="flow_group", direction="vertical"} + flow_subgroups.style.vertical_spacing = 0 + group_flow_cache[group_id] = flow_subgroups + + modal_elements.groups[group_id] = { + button = button_group, + frame = frame_subgroups, + scroll_pane = scroll_pane_subgroups, + subgroup_tables = {} + } + subgroup_tables = modal_elements.groups[group_id].subgroup_tables + + -- Catch up on adding the last item flow's row count + current_item_rows = current_item_rows + math.ceil(current_items_in_table_count / items_per_row) + current_items_in_table_count = 0 + + max_item_rows = math.max(current_item_rows, max_item_rows) + current_item_rows = 0 + else + flow_subgroups = group_flow_cache[group_id] + subgroup_tables = modal_elements.groups[group_id].subgroup_tables + end + + local subgroup_name = item_proto.subgroup.name + local table_subgroup = subgroup_table_cache[subgroup_name] + local subgroup_table = nil + + if table_subgroup == nil then + table_subgroup = flow_subgroups.add{type="table", column_count=items_per_row, + style="filter_slot_table"} + table_subgroup.style.horizontally_stretchable = true + subgroup_table_cache[subgroup_name] = table_subgroup + + subgroup_tables[subgroup_name] = {} + subgroup_table = subgroup_tables[subgroup_name] + + current_item_rows = current_item_rows + math.ceil(current_items_in_table_count / items_per_row) + current_items_in_table_count = 0 + else + subgroup_table = subgroup_tables[subgroup_name] + end + + current_items_in_table_count = current_items_in_table_count + 1 + + local item_name = item_proto.name + local existing_product = existing_products[item_name] + local button_style = (existing_product) and "flib_slot_button_red" or "flib_slot_button_default" + + local name = (item_proto.temperature) and item_proto.base_name or item_proto.name + local elem_tooltip = (item_proto.type ~= "entity") and {type=item_proto.type, name=name} or nil + + local button_item = table_subgroup.add{type="sprite-button", sprite=item_proto.sprite, style=button_style, + tags={mod="fp", on_gui_click="select_picker_item", item_id=item_proto.id, + category_id=item_proto.category_id, enabled=(existing_product == nil)}, + tooltip=item_proto.tooltip, elem_tooltip=elem_tooltip, mouse_button_filter={"left"}} + + -- Figure out the translated name here so search doesn't have to repeat the work for every character + local translated_name = (translations) and translations[item_proto.type][item_name] or nil + translated_name = (translated_name) and translated_name:lower() or item_name + subgroup_table[{name=item_name, translated_name=translated_name}] = button_item + end + end + + -- Catch up on addding the last item flow and groups row counts + current_item_rows = current_item_rows + math.ceil(current_items_in_table_count / items_per_row) + max_item_rows = math.max(current_item_rows, max_item_rows) + frame_filters.style.natural_height = max_item_rows * 40 + (2*12) + + -- Select the previously selected item group if possible + local group_to_select, previous_selection = 1, ui_state.last_selected_picker_group + if previous_selection ~= nil and modal_elements.groups[previous_selection] ~= nil then + group_to_select = previous_selection + end + select_item_group(ui_state.modal_data, group_to_select) +end + + +-- ** PICKER DIALOG ** +local function set_appropriate_focus(modal_data) + if modal_data.amount_defined_by == "amount" then + util.gui.select_all(modal_data.modal_elements["item_amount_textfield"]) + else -- "belts"/"lanes" + util.gui.select_all(modal_data.modal_elements["belt_amount_textfield"]) + end +end + +-- Is only called when defined_by ~= "amount" +local function sync_amounts(modal_data) + local modal_elements = modal_data.modal_elements + + local belt_amount = util.gui.parse_expression_field(modal_elements.belt_amount_textfield) + if belt_amount == nil then + modal_elements.item_amount_textfield.text = "" + else + local belt_proto = modal_data.belt_proto + local throughput = belt_proto.throughput * ((modal_data.lob == "belts") and 1 or 0.5) + local item_amount = belt_amount * throughput * modal_data.timescale + modal_elements.item_amount_textfield.text = util.format.number(item_amount, 6) + end +end + +local function set_belt_proto(modal_data, belt_proto) + modal_data.belt_proto = belt_proto + + local modal_elements = modal_data.modal_elements + modal_elements.item_amount_textfield.enabled = (belt_proto == nil) + modal_elements.belt_amount_textfield.enabled = (belt_proto ~= nil) + + if belt_proto == nil then + modal_elements.belt_choice_button.elem_value = nil + modal_elements.belt_amount_textfield.text = "" + modal_data.amount_defined_by = "amount" + else + -- Might double set the choice button, but it doesn't matter + modal_elements.belt_choice_button.elem_value = belt_proto.name + modal_data.amount_defined_by = modal_data.lob + + local item_amount = util.gui.parse_expression_field(modal_elements.item_amount_textfield) + if item_amount ~= nil then + local throughput = belt_proto.throughput * ((modal_data.lob == "belts") and 1 or 0.5) + local belt_amount = item_amount / throughput / modal_data.timescale + modal_elements.belt_amount_textfield.text = util.format.number(belt_amount, 6) + end + sync_amounts(modal_data) + end +end + +local function set_item_proto(modal_data, item_proto) + local modal_elements = modal_data.modal_elements + modal_data.item_proto = item_proto + + local item_choice_button = modal_elements.item_choice_button + item_choice_button.sprite = (item_proto) and item_proto.sprite or nil + if item_proto then + local name = (item_proto.temperature) and item_proto.base_name or item_proto.name + item_choice_button.elem_tooltip = (item_proto.type ~= "entity") and {type=item_proto.type, name=name} or nil + item_choice_button.tooltip = item_proto.tooltip + end + + -- Disable definition by belt for fluids + local is_fluid = item_proto and item_proto.type == "fluid" + modal_elements.belt_choice_button.enabled = (not is_fluid) + + -- Clear the belt-related fields if needed + if is_fluid then set_belt_proto(modal_data, nil) end +end + +local function update_dialog_submit_button(modal_elements) + local item_choice_button = modal_elements.item_choice_button + local item_amount = util.gui.parse_expression_field(modal_elements.item_amount_textfield) + + local message = nil + if item_choice_button.sprite == "" then + message = {"fp.picker_issue_select_item"} + elseif item_amount == nil then + -- The item amount will be filled even if the item is defined_by ~= "amount" + message = {"fp.picker_issue_enter_amount"} + end + + modal_dialog.set_submit_button_state(modal_elements, (message == nil), message) +end + + +local function add_item_pane(parent_flow, modal_data, item_category, item) + local function create_flow() + local flow = parent_flow.add{type="flow", direction="horizontal"} + flow.style.vertical_align = "center" + flow.style.horizontal_spacing = 8 + flow.style.bottom_margin = 6 + return flow + end + + local modal_elements = modal_data.modal_elements + local defined_by = (item) and item.defined_by or "amount" + modal_data.amount_defined_by = defined_by + + local flow_amount = create_flow() + flow_amount.add{type="label", caption={"fp.pu_" .. item_category, 1}} + + local item_choice_button = flow_amount.add{type="sprite-button", style="fp_sprite-button_inset"} + item_choice_button.style.right_margin = 12 + modal_elements["item_choice_button"] = item_choice_button + + flow_amount.add{type="label", caption={"fp.amount"}} + + local item_amount = (item and defined_by == "amount") and + tostring(item.required_amount * modal_data.timescale) or "" + local amount_width = 90 + local textfield_amount = flow_amount.add{type="textfield", text=item_amount, + tags={mod="fp", on_gui_text_changed="picker_item_amount", on_gui_confirmed="picker_amount", + width=amount_width}, tooltip={"fp.expression_textfield"}} + textfield_amount.style.width = amount_width + modal_elements["item_amount_textfield"] = textfield_amount + + + local flow_belts = create_flow() + local label = flow_belts.add{type="label", caption={"fp.amount_by", {"fp.pl_" .. modal_data.lob:sub(1, -2), 2}}} + label.style.right_margin = 6 + + local belt_amount = (item and defined_by ~= "amount") and tostring(item.required_amount) or "" + local belt_width = 86 + local textfield_belts = flow_belts.add{type="textfield", text=belt_amount, + tags={mod="fp", on_gui_text_changed="picker_belt_amount", on_gui_confirmed="picker_amount", + width=belt_width}, tooltip={"fp.expression_textfield"}} + textfield_belts.style.width = belt_width + modal_elements["belt_amount_textfield"] = textfield_belts + + flow_belts.add{type="label", caption="x"} + + local belt_filter = {{filter="type", type="transport-belt"}, {filter="hidden", invert=true, mode="and"}} + local choose_belt_button = flow_belts.add{type="choose-elem-button", elem_type="entity", + tags={mod="fp", on_gui_elem_changed="picker_choose_belt"}, elem_filters=belt_filter, + style="fp_sprite-button_inset"} + modal_elements["belt_choice_button"] = choose_belt_button + + + local item_proto = (item) and item.proto or nil + set_item_proto(modal_data, item_proto) + + local belt_proto = (defined_by ~= "amount") and item.belt_proto or nil + set_belt_proto(modal_data, belt_proto) + + if (item) then set_appropriate_focus(modal_data) + else modal_elements.search_textfield.focus() end + update_dialog_submit_button(modal_elements) +end + + +local function handle_item_pick(player, tags, _) + local modal_data = util.globals.modal_data(player) + local item_proto = prototyper.util.find("items", tags.item_id, tags.category_id) + + if not tags.enabled then + util.cursor.create_flying_text(player, {"fp.picker_already_selected", item_proto.localised_name}) + return + end + + set_item_proto(modal_data, item_proto) -- no need for sync in this case + + set_appropriate_focus(modal_data) + update_dialog_submit_button(modal_data.modal_elements) +end + +local function handle_belt_pick(player, _, event) + local belt_name = event.element.elem_value + local belt_proto = prototyper.util.find("belts", belt_name, nil) + + local modal_data = util.globals.modal_data(player) + set_belt_proto(modal_data, belt_proto) -- syncs amounts itself + + set_appropriate_focus(modal_data) + update_dialog_submit_button(modal_data.modal_elements) +end + + +local function open_picker_dialog(player, modal_data) + local preferences = util.globals.preferences(player) + + if modal_data.item_id then modal_data.item = OBJECT_INDEX[modal_data.item_id] end + modal_data.timescale = preferences.timescale + modal_data.lob = preferences.belts_or_lanes + + local content_frame = modal_data.modal_elements.content_frame + content_frame.style.minimal_width = 325 + content_frame.style.bottom_padding = 6 + add_item_pane(content_frame, modal_data, modal_data.item_category, modal_data.item) + + -- The item picker only needs to show when adding a new item + if modal_data.item_id == nil then + local auxiliary_flow = modal_data.modal_elements.auxiliary_flow + local picker_content_frame = auxiliary_flow.add{type="frame", direction="vertical", style="inside_deep_frame"} + picker_content_frame.style.top_margin = 8 + add_item_picker(picker_content_frame, player) + end +end + +local function close_picker_dialog(player, action) + local player_table = util.globals.player_table(player) + local ui_state = player_table.ui_state + local modal_data = ui_state.modal_data --[[@as table]] + local factory = util.context.get(player, "Factory") --[[@as Factory]] + + if action == "submit" then + local defined_by = modal_data.amount_defined_by + local relevant_textfield_name = ((defined_by == "amount") and "item" or "belt") .. "_amount_textfield" + local relevant_amount = util.gui.parse_expression_field(modal_data.modal_elements[relevant_textfield_name]) or 0 + if defined_by == "amount" then + relevant_amount = relevant_amount / modal_data.timescale + relevant_amount = math.max(relevant_amount, MAGIC_NUMBERS.margin_of_error*10) + end + + local refresh_scope = "factory" + if modal_data.item ~= nil then -- ie. this is an edit + modal_data.item.defined_by = defined_by + modal_data.item.required_amount = relevant_amount + modal_data.item.belt_proto = modal_data.belt_proto + else + local item_proto = modal_data.item_proto + local top_level_item = Product.init(item_proto) + top_level_item.defined_by = defined_by + top_level_item.required_amount = relevant_amount + top_level_item.belt_proto = modal_data.belt_proto + + if modal_data.create_factory then -- if this flag is set, create a factory to put the item into + local translations = player_table.translation_tables + local translated_name = (translations) and translations[item_proto.type][item_proto.name] or "" + local icon = (not player_table.preferences.attach_factory_products) + and "[img=" .. top_level_item.proto.sprite .. "] " or "" + factory = factory_list.add_factory(player, (icon .. translated_name)) + end + + factory:insert(top_level_item) + refresh_scope = "all" -- need to refresh factory list too + end + + solver.update(player, factory) + main_dialog.toggle_districts_view(player, true) + util.raise.refresh(player, refresh_scope) + + elseif action == "delete" then + factory:remove(modal_data.item) + solver.update(player, factory) + util.raise.refresh(player, "factory") + end + + -- Remember selected group so it can be re-applied when the dialog is re-opened + ui_state.last_selected_picker_group = modal_data.selected_group_id +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "select_picker_item_group", + handler = (function(player, tags, _) + local modal_data = util.globals.modal_data(player) + select_item_group(modal_data, tags.group_id) + end) + }, + { + name = "select_picker_item", + handler = handle_item_pick + } + }, + on_gui_elem_changed = { + { + name = "picker_choose_belt", + handler = handle_belt_pick + } + }, + on_gui_text_changed = { + { + name = "picker_item_amount", + handler = (function(player, _, event) + util.gui.update_expression_field(event.element) + update_dialog_submit_button(util.globals.modal_elements(player)) + end) + }, + { + name = "picker_belt_amount", + handler = (function(player, _, event) + local modal_data = util.globals.modal_data(player) + util.gui.update_expression_field(event.element) + sync_amounts(modal_data) -- defined_by ~= "amount" + update_dialog_submit_button(modal_data.modal_elements) + end) + } + }, + on_gui_confirmed = { + { + name = "picker_amount", + handler = (function(player, _, event) + local confirmed = util.gui.confirm_expression_field(event.element) + if confirmed then util.raise.close_dialog(player, "submit") end + end) + } + } +} + +listeners.dialog = { + dialog = "picker", + metadata = (function(modal_data) + local action = (modal_data.item_id) and {"fp.edit"} or {"fp.add"} + return { + caption = {"", action, " ", {"fp.pl_" .. modal_data.item_category, 1}}, + search_handler_name = (not modal_data.item_id) and "search_picker_items" or nil, + disable_scroll_pane = true, + show_submit_button = true, + show_delete_button = (modal_data.item_id ~= nil) + } + end), + open = open_picker_dialog, + close = close_picker_dialog +} + +listeners.global = { + search_picker_items = search_picker_items +} + +return { listeners } diff --git a/modfiles/ui/dialogs/porter_dialog.lua b/modfiles/ui/dialogs/porter_dialog.lua new file mode 100644 index 000000000..2ad85476f --- /dev/null +++ b/modfiles/ui/dialogs/porter_dialog.lua @@ -0,0 +1,359 @@ +-- ** LOCAL UTIL ** +local function set_dialog_submit_button(modal_elements, enabled, action_to_take) + local message = (not enabled) and {"fp.importer_issue_" .. action_to_take} or nil + modal_dialog.set_submit_button_state(modal_elements, enabled, message) +end + +-- Sets the state of either the export_factories- or dialog_submit-button +local function set_relevant_submit_button(modal_elements, dialog_type, enabled) + if dialog_type == "export" then + modal_elements.export_button.enabled = enabled + else -- dialog_type == "import" + set_dialog_submit_button(modal_elements, enabled, "select_factory") + end +end + + +-- Sets the slave checkboxes after the master one has been clicked +local function set_all_checkboxes(player, checkbox_state) + local ui_state = util.globals.ui_state(player) + local modal_elements = ui_state.modal_data.modal_elements + + for _, checkbox in pairs(modal_elements.factory_checkboxes) do + if checkbox.enabled then checkbox.state = checkbox_state end + end + + set_relevant_submit_button(modal_elements, ui_state.modal_dialog_type, checkbox_state) +end + +-- Sets the master checkbox to the appropriate state after a slave one is changed +local function adjust_after_checkbox_click(player, _, _) + local ui_state = util.globals.ui_state(player) + local modal_elements = ui_state.modal_data.modal_elements + + local checked_element_count, unchecked_element_count = 0, 0 + for _, checkbox in pairs(modal_elements.factory_checkboxes) do + if checkbox.state == true then checked_element_count = checked_element_count + 1 + elseif checkbox.enabled then unchecked_element_count = unchecked_element_count + 1 end + end + + modal_elements.master_checkbox.state = (unchecked_element_count == 0) + set_relevant_submit_button(modal_elements, ui_state.modal_dialog_type, (checked_element_count > 0)) +end + + +-- Adds a flow containing a textfield and a button +local function add_textfield_and_button(modal_elements, dialog_type, button_first, button_enabled) + local flow = modal_elements.content_frame.add{type="flow", direction="horizontal"} + flow.style.vertical_align = "center" + + local function add_button() + local button = flow.add{type="sprite-button", tags={mod="fp", on_gui_click=(dialog_type .. "_factories")}, + style="flib_tool_button_light_green", tooltip={"fp." .. dialog_type .. "_button_tooltip"}, + sprite="utility/" .. dialog_type, enabled=button_enabled, mouse_button_filter={"left"}} + modal_elements[dialog_type .. "_button"] = button + end + + local function add_textfield() + local tags = (dialog_type == "import") + and {mod="fp", on_gui_text_changed="import_string", on_gui_confirmed="confirm_import"} or nil + local textfield = flow.add{type="textfield", tags=tags} + textfield.style.width = 0 -- needs to be set to 0 so stretching works + textfield.style.minimal_width = 280 + textfield.style.horizontally_stretchable = true + textfield.lose_focus_on_confirm = true + + if button_first then textfield.style.left_margin = 6 + else textfield.style.right_margin = 6 end + + modal_elements[dialog_type .. "_textfield"] = textfield + end + + if button_first then add_button(); add_textfield() + else add_textfield(); add_button() end +end + + +-- Initializes the factories table by adding it and its header +local function setup_factories_table(modal_elements, add_location) + modal_elements.factory_checkboxes = {} -- setup for later use in add_to_factories_table + + local frame_factories = modal_elements.content_frame.add{type="frame", style="deep_frame_in_shallow_frame"} + modal_elements.factories_frame = frame_factories + + local scroll_pane = frame_factories.add{type="scroll-pane", style="mods_scroll_pane"} + scroll_pane.style.maximal_height = 450 -- I hate that I have to set this, seemingly + + local table_columns = { + [2] = {caption={"fp.u_factory"}, alignment="left", margin={0, 100, 0, 4}}, + [3] = {caption={"fp.status"}} + } + if add_location then table_columns[4] = {caption={"fp.location"}} end + + local table_factories = scroll_pane.add{type="table", style="table_with_selection", + column_count=(table_size(table_columns) + 1)} + table_factories.style.horizontally_stretchable = true + modal_elements.factories_table = table_factories + + -- Add master checkbox in any case + local checkbox_master = table_factories.add{type="checkbox", state=false, + tags={mod="fp", on_gui_checked_state_changed="toggle_porter_master_checkbox"}} + modal_elements.master_checkbox = checkbox_master + + for column_nr, table_column in pairs(table_columns) do + table_factories.style.column_alignments[column_nr] = table_column.alignment or "center" + + local label_column = table_factories.add{type="label", caption=table_column.caption, style="heading_2_label"} + label_column.style.margin = table_column.margin or {0, 4} + end +end + +-- Adds a row to the factories table +local function add_to_factories_table(modal_elements, factory, enable_checkbox, attach_products) + local table_factories = modal_elements.factories_table + + local checkbox = table_factories.add{type="checkbox", state=false, enabled=(enable_checkbox or factory.valid), + tags={mod="fp", on_gui_checked_state_changed="toggle_porter_checkbox"}} + + local label_flow = table_factories.add{type="flow", direction="horizontal"} + label_flow.style.maximal_width = 350 + label_flow.add{type="label", caption=factory:tostring(attach_products, true)} + label_flow.add{type="empty-widget", style="flib_horizontal_pusher"} + + local validity_caption = (factory.valid) and {"fp.valid"} or {"fp.error_message", {"fp.invalid"}} + table_factories.add{type="label", caption=validity_caption} + + if table_factories.column_count == 4 then -- if location column is present + local location = (factory.archived) and "archive" or "factory" + table_factories.add{type="label", caption={"fp.u_" .. location}} + end + + modal_elements.factory_checkboxes[factory.id] = checkbox +end + + +-- Tries importing the given string, showing the resulting factories-table, if possible +local function import_factories(player, _, _) + local player_table = util.globals.player_table(player) + local attach_factory_products = player_table.preferences.attach_factory_products + local modal_data = player_table.ui_state.modal_data ---@cast modal_data -nil + local modal_elements = modal_data.modal_elements + local content_frame = modal_elements.content_frame + local textfield_export_string = modal_elements.import_textfield + + local import_table, error = util.porter.process_export_string(textfield_export_string.text) + + local function add_info_label(caption) + local label_info = content_frame.add{type="label", caption=caption} + label_info.style.single_line = false + label_info.style.bottom_margin = 4 + label_info.style.width = 330 + modal_elements.info_label = label_info + end + + if not modal_elements.porter_line then + local line = content_frame.add{type="line", direction="horizontal"} + line.style.margin = {6, 0, 6, 0} + modal_elements.porter_line = line + end + + if modal_elements.info_label then modal_elements.info_label.destroy() end + if modal_elements.factories_frame then modal_elements.factories_frame.destroy() end + + if error ~= nil then + add_info_label({"fp.error_message", {"fp.importer_" .. error}}) + util.gui.select_all(textfield_export_string) + else + ---@cast import_table -nil + + add_info_label({"fp.import_instruction_2"}) + setup_factories_table(modal_elements, false) + modal_data.factories = {} + + local any_invalid_factories = true + for _, factory in ipairs(import_table.factories) do + factory.archived = false + add_to_factories_table(modal_elements, factory, true, attach_factory_products) + modal_data.factories[factory.id] = factory + any_invalid_factories = any_invalid_factories or (not factory.valid) + end + + if any_invalid_factories then + modal_data.export_modset = import_table.export_modset + + local diff_tooltip = util.porter.format_modset_diff(import_table.export_modset) + if diff_tooltip ~= "" then + modal_elements.info_label.caption = {"fp.info_label", {"fp.import_instruction_2"}} + modal_elements.info_label.tooltip = diff_tooltip + end + end + + modal_elements.master_checkbox.state = true + set_all_checkboxes(player, true) + end + + set_dialog_submit_button(modal_elements, (error == nil), "import_string") + modal_elements.modal_frame.force_auto_center() +end + +-- Exports the currently selected factories and puts the resulting string into the textbox +local function export_factories(player, _, _) + local modal_data = util.globals.modal_data(player) + local modal_elements = modal_data.modal_elements + local factories_to_export = {} + + for factory_id, checkbox in pairs(modal_elements.factory_checkboxes) do + if checkbox.state == true then + local factory = modal_data.factories[factory_id] + table.insert(factories_to_export, factory) + end + end + local export_string = util.porter.generate_export_string(factories_to_export) + + modal_elements.export_textfield.text = export_string + util.gui.select_all(modal_elements.export_textfield) +end + + +local function open_import_dialog(_, modal_data) + local modal_elements = modal_data.modal_elements + set_dialog_submit_button(modal_elements, false, "import_string") + + add_textfield_and_button(modal_elements, "import", false, false) + util.gui.select_all(modal_elements.import_textfield) +end + +-- Imports the selected factories into the player's current District +local function close_import_dialog(player, action) + if action == "submit" then + local modal_data = util.globals.modal_data(player) ---@cast modal_data -nil + local district = util.context.get(player, "District") --[[@as District]] + + local first_factory = nil + for factory_id, checkbox in pairs(modal_data.modal_elements.factory_checkboxes) do + if checkbox.state == true then + local factory = modal_data.factories[factory_id] + if not factory.valid then factory.last_valid_modset = modal_data.export_modset end + district:insert(factory) + + solver.update(player, factory) + first_factory = first_factory or factory + end + end + ---@cast first_factory Factory + + main_dialog.toggle_districts_view(player, true) + util.context.set(player, first_factory) + util.raise.refresh(player, "all") + end +end + + +-- ** EVENTS ** +local import_listeners = {} + +import_listeners.gui = { + on_gui_click = { + { + name = "import_factories", + timeout = 20, + handler = import_factories + } + }, + on_gui_text_changed = { + { + name = "import_string", + handler = (function(player, _, event) + local button_import = util.globals.modal_elements(player).import_button + button_import.enabled = (string.len(event.element.text) > 0) + end) + } + }, + on_gui_confirmed = { + { + name = "confirm_import", + handler = (function(player, _, event) + if event.element.text ~= "" then import_factories(player) end + end) + } + } +} + +import_listeners.dialog = { + dialog = "import", + metadata = (function(_) return { + caption = {"", {"fp.import"}, " ", {"fp.pl_factory", 1}}, + subheader_text = {"fp.import_instruction_1"}, + disable_scroll_pane = true, + show_submit_button = true + } end), + open = open_import_dialog, + close = close_import_dialog +} + + +local function open_export_dialog(player, modal_data) + local attach_factory_products = util.globals.preferences(player).attach_factory_products + local district = util.context.get(player, "District") --[[@as District]] + local modal_elements = modal_data.modal_elements + + setup_factories_table(modal_elements, true) + modal_data.factories = {} + + local valid_factory_found = false + for factory in district:iterator() do + add_to_factories_table(modal_elements, factory, false, attach_factory_products) + modal_data.factories[factory.id] = factory + valid_factory_found = valid_factory_found or factory.valid + end + modal_elements.master_checkbox.enabled = valid_factory_found + + add_textfield_and_button(modal_elements, "export", true, false) + modal_elements.export_textfield.parent.style.top_margin = 6 +end + + +-- ** EVENTS ** +local export_listeners = {} + +export_listeners.gui = { + on_gui_click = { + { + name = "export_factories", + timeout = 20, + handler = export_factories + } + } +} + +export_listeners.dialog = { + dialog = "export", + metadata = (function(_) return { + caption = {"", {"fp.export"}, " ", {"fp.pl_factory", 1}}, + subheader_text = {"fp.info_label", {"fp.export_instruction"}}, + subheader_tooltip = {"fp.export_instruction_tt"}, + disable_scroll_pane = true + } end), + open = open_export_dialog +} + + +-- ** SHARED ** +local porter_listeners = {} + +porter_listeners.gui = { + on_gui_checked_state_changed = { + { + name = "toggle_porter_master_checkbox", + handler = (function(player, _, event) + set_all_checkboxes(player, event.element.state) + end) + }, + { + name = "toggle_porter_checkbox", + handler = adjust_after_checkbox_click + } + } +} + +return { import_listeners, export_listeners, porter_listeners } diff --git a/modfiles/ui/dialogs/preferences_dialog.lua b/modfiles/ui/dialogs/preferences_dialog.lua new file mode 100644 index 000000000..88dbdbabd --- /dev/null +++ b/modfiles/ui/dialogs/preferences_dialog.lua @@ -0,0 +1,406 @@ +-- ** LOCAL UTIL ** +local function add_preference_box(content_frame, type) + local bordered_frame = content_frame.add{type="frame", direction="vertical", style="fp_frame_bordered_stretch"} + local title_flow = bordered_frame.add{type="flow", direction="horizontal", name="title_flow"} + title_flow.style.vertical_align = "center" + + local caption = {"fp.info_label", {"fp.preference_".. type .. "_title"}} + local tooltip = {"fp.preference_".. type .. "_title_tt"} + title_flow.add{type="label", caption=caption, tooltip=tooltip, style="caption_label"} + + return bordered_frame +end + +local function refresh_defaults_table(player, modal_elements, type, category_id) + local gui_id = (category_id) and (type .. "-" .. category_id) or type + local table_prototypes = modal_elements[gui_id] + table_prototypes.clear() + + local prototypes = storage.prototypes[type] + if category_id then prototypes = prototypes[category_id].members end + local default = defaults.get(player, type, category_id) + + for prototype_id, prototype in ipairs(prototypes) do + local selected = (default.proto.id == prototype_id) + local style = (selected) and "flib_slot_button_green_small" or "flib_slot_button_default_small" + local elem_type = (default.quality) and prototype.elem_type .. "-with-quality" or prototype.elem_type + local quality = (default.quality) and default.quality.name or nil + local tooltip = {type=elem_type, name=prototype.name, quality=quality} + + table_prototypes.add{type="sprite-button", sprite=prototype.sprite, style=style, elem_tooltip=tooltip, + tags={mod="fp", on_gui_click="select_preference_default", type=type, prototype_name=prototype.name, + category_id=category_id}, quality=quality, mouse_button_filter={"left"}} + end + + return #prototypes +end + +local function refresh_views_table(player) + local view_preferences = util.globals.preferences(player).item_views + local views_table = util.globals.modal_elements(player).views_table + local views = util.globals.ui_state(player).views_data.views + + local function add_move_button(parent, index, direction, enabled) + local move_up_button = parent.add{type="sprite-button", sprite="fp_arrow_" .. direction, + tags={mod="fp", on_gui_click="move_view", index=index, direction=direction}, + enabled=enabled, style="fp_sprite-button_move", mouse_button_filter={"left"}} + move_up_button.style.size = {20, 18} + move_up_button.style.padding = 0 + end + + local active_view_count = 0 + for _, view_preference in ipairs(view_preferences.views) do + if view_preference.enabled then active_view_count = active_view_count + 1 end + end + + views_table.clear() + for index, view_preference in ipairs(view_preferences.views) do + local view_data = views[view_preference.name] + + local enabled = (active_view_count < 4 or view_preference.enabled) and + (active_view_count > 1 or not view_preference.enabled) + views_table.add{type="checkbox", state=view_preference.enabled, enabled=enabled, + tags={mod="fp", on_gui_checked_state_changed="toggle_view", name=view_preference.name}} + + local flow_name = views_table.add{type="flow", direction="horizontal"} + flow_name.add{type="label", caption=view_data.caption, tooltip=view_data.tooltip} + flow_name.style.horizontally_stretchable = true + + local flow_move = views_table.add{type="flow", direction="horizontal"} + flow_move.style.horizontal_spacing = 0 + add_move_button(flow_move, index, "up", (index > 1)) + add_move_button(flow_move, index, "down", (index < #view_preferences.views)) + end +end + + +local function add_checkboxes_box(preferences, content_frame, type, preference_names) + local preference_box = add_preference_box(content_frame, type) + local flow_checkboxes = preference_box.add{type="flow", direction="vertical"} + flow_checkboxes.style.right_padding = 16 + + for _, pref_name in ipairs(preference_names) do + local identifier = type .. "_" .. pref_name + local caption = {"fp.info_label", {"fp.preference_" .. identifier}} + local tooltip ={"fp.preference_" .. identifier .. "_tt"} + flow_checkboxes.add{type="checkbox", state=preferences[pref_name], caption=caption, tooltip=tooltip, + tags={mod="fp", on_gui_checked_state_changed="toggle_preference", type=type, name=pref_name}} + end + + return preference_box +end + +local function add_dropdowns(preferences, parent_flow) + local function add_dropdown(name, items, selected_index) + local flow = parent_flow.add{type="flow", direction="horizontal"} + flow.style.top_margin = 4 + + flow.add{type="label", caption={"fp.info_label", {"fp.preference_dropdown_" .. name}}, + tooltip={"fp.preference_dropdown_" .. name .. "_tt"}} + flow.add{type="empty-widget", style="flib_horizontal_pusher"} + flow.add{type="drop-down", items=items, selected_index=selected_index, style="fp_drop-down_slim", + tags={mod="fp", on_gui_selection_state_changed="choose_preference", name=name}} + end + + local width_items, width_index = {}, nil + for index, value in pairs(PRODUCTS_PER_ROW_OPTIONS) do + width_items[index] = {"", value .. " ", {"fp.pl_product", 2}} + if value == preferences.products_per_row then width_index = index end + end + add_dropdown("products_per_row", width_items, width_index) + + local height_items, height_index = {}, nil + for index, value in pairs(FACTORY_LIST_ROWS_OPTIONS) do + height_items[index] = {"", value .. " ", {"fp.pl_factory", 2}} + if value == preferences.factory_list_rows then height_index = index end + end + add_dropdown("factory_list_rows", height_items, height_index) + + local compact_items, compact_index = {}, nil + for index, value in pairs(COMPACT_WIDTH_PERCENTAGE) do + compact_items[index] = {"", value .. " %"} + if value == preferences.compact_width_percentage then compact_index = index end + end + add_dropdown("compact_width_percentage", compact_items, compact_index) +end + + +local function add_views_box(player, content_frame, modal_elements) + local preference_box = add_preference_box(content_frame, "views") + + local label = preference_box.add{type="label", caption={"fp.preference_pick_views"}} + label.style.bottom_margin = 4 + + local frame_views = preference_box.add{type="frame", style="deep_frame_in_shallow_frame"} + local table_views = frame_views.add{type="table", style="table_with_selection", column_count=3} + modal_elements["views_table"] = table_views + + refresh_views_table(player) +end + + +local function add_default_proto_box(player, content_frame, type, category_id, addition) + local modal_elements = util.globals.modal_elements(player) + local preference_box = add_preference_box(content_frame, "default_" .. type) + + local frame = preference_box.add{type="frame", direction="horizontal", style="fp_frame_light_slots_small"} + local gui_id = (category_id) and (type .. "-" .. category_id) or type + modal_elements[gui_id] = frame.add{type="table", column_count=8, style="fp_table_slots_small"} + local prototype_count = refresh_defaults_table(player, modal_elements, type, category_id) + + if addition == "lanes_or_belts" then + preference_box.title_flow.add{type="empty-widget", style="flib_horizontal_pusher"} + local belts_or_lanes = util.globals.preferences(player).belts_or_lanes + local switch_state = (belts_or_lanes == "belts") and "left" or "right" + preference_box.title_flow.add{type="switch", switch_state=switch_state, + tooltip={"fp.preference_belts_or_lanes_tt"}, + tags={mod="fp", on_gui_switch_state_changed="choose_belts_or_lanes"}, + left_label_caption={"fp.pu_belt", 2}, right_label_caption={"fp.pu_lane", 2}} + + elseif addition == "quality_picker" and MULTIPLE_QUALITIES then + preference_box.title_flow.add{type="empty-widget", style="flib_horizontal_pusher"} + local default_quality = defaults.get(player, type, category_id).quality + local tags = {mod="fp", on_gui_selection_state_changed="select_preference_quality", + type=type, category_id=category_id} + util.gui.add_quality_dropdown(preference_box.title_flow, default_quality.id, tags) + end + + -- This is inefficient, but it's fine + if not MULTIPLE_QUALITIES and prototype_count == 1 then preference_box.destroy() end +end + + +local function handle_checkbox_preference_change(player, tags, event) + local preference_name = tags.name + util.globals.preferences(player)[preference_name] = event.element.state + + if tags.type == "production" or preference_name == "show_floor_items" then + util.raise.refresh(player, "production") + + elseif preference_name == "ingredient_satisfaction" then + if event.element.state == true then -- only recalculate if enabled + local realm = util.globals.player_table(player).realm + for district in realm:iterator() do + for factory in district:iterator() do + solver.determine_ingredient_satisfaction(factory) + end + end + end + util.raise.refresh(player, "production") + + elseif preference_name == "attach_factory_products" or preference_name == "skip_factory_naming" then + util.raise.refresh(player, "factory_list") + + elseif preference_name == "show_gui_button" then + util.gui.toggle_mod_gui(player) + end +end + +local function handle_dropdown_preference_change(player, tags, event) + local selected_index = event.element.selected_index + local preferences = util.globals.preferences(player) + + if tags.name == "products_per_row" then + preferences.products_per_row = PRODUCTS_PER_ROW_OPTIONS[selected_index] + util.globals.modal_data(player).rebuild = true + elseif tags.name == "factory_list_rows" then + preferences.factory_list_rows = FACTORY_LIST_ROWS_OPTIONS[selected_index] + util.globals.modal_data(player).rebuild = true + elseif tags.name == "compact_width_percentage" then + preferences.compact_width_percentage = COMPACT_WIDTH_PERCENTAGE[selected_index] + util.globals.modal_data(player).rebuild_compact = true + end +end + +local function handle_view_toggle(player, tags, _) + local view_preferences = util.globals.preferences(player).item_views + for index, view_preference in ipairs(view_preferences.views) do + if view_preference.name == tags.name then + view_preference.enabled = not view_preference.enabled + -- Select a valid view if the current one is disabled + if not view_preference.enabled and view_preferences.selected_index == index then + item_views.cycle_views(player, "standard") + end + break + end + end + + item_views.refresh_interface(player) + refresh_views_table(player) + + util.raise.refresh(player, "factory") +end + +local function handle_view_move(player, tags, _) + local view_preferences = util.globals.preferences(player).item_views + local view_preference = table.remove(view_preferences.views, tags.index) + local new_index = (tags.direction == "up") and (tags.index-1) or (tags.index+1) + table.insert(view_preferences.views, new_index, view_preference) + + item_views.rebuild_interface(player) -- rebuild because of the move + refresh_views_table(player) + + util.raise.refresh(player, "factory") +end + +local function handle_bol_change(player, _, event) + local player_table = util.globals.player_table(player) + local defined_by = (event.element.switch_state == "left") and "belts" or "lanes" + + player_table.preferences.belts_or_lanes = defined_by + + item_views.rebuild_data(player) + item_views.rebuild_interface(player) + refresh_views_table(player) + + solver.update(player, nil) + util.raise.refresh(player, "all") +end + +local function handle_default_prototype_change(player, tags, _) + local data_type, category_id = tags.type, tags.category_id + + local current_default = defaults.get(player, data_type, category_id) + local quality_name = (current_default.quality) and current_default.quality.name or nil + local default_data = {prototype=tags.prototype_name, quality=quality_name} + defaults.set(player, data_type, default_data, category_id) + + local modal_elements = util.globals.modal_elements(player) + refresh_defaults_table(player, modal_elements, data_type, category_id) + + item_views.rebuild_data(player) + item_views.rebuild_interface(player) + refresh_views_table(player) + + util.raise.refresh(player, "all") +end + +local function handle_prototype_quality_change(player, tags, event) + local data_type, category_id = tags.type, tags.category_id + -- Get the quality_proto by using the index as the quality level + local quality_proto = storage.prototypes.qualities[event.element.selected_index] + + local current_default = defaults.get(player, data_type, category_id) + local default_data = {prototype=current_default.proto.name, quality=quality_proto.name} + defaults.set(player, data_type, default_data, category_id) + + local modal_elements = util.globals.modal_elements(player) + refresh_defaults_table(player, modal_elements, data_type, category_id) + + item_views.rebuild_data(player) + item_views.rebuild_interface(player) + + util.raise.refresh(player, "all") +end + + +local function open_preferences_dialog(player, modal_data) + local preferences = util.globals.preferences(player) + local modal_elements = modal_data.modal_elements + + -- Left side + local left_content_frame = modal_elements.content_frame + + local general_preference_names = {"show_gui_button", "skip_factory_naming", "attach_factory_products", + "prefer_matrix_solver", "show_floor_items", "ingredient_satisfaction", "ignore_barreling_recipes", + "ignore_recycling_recipes"} + local general_box = add_checkboxes_box(preferences, left_content_frame, "general", general_preference_names) + + general_box.add{type="line", direction="horizontal"}.style.margin = {4, 0, 2, 0} + add_dropdowns(preferences, general_box) + + local production_preference_names = {"done_column", "percentage_column", "line_comment_column"} + add_checkboxes_box(preferences, left_content_frame, "production", production_preference_names) + + left_content_frame.add{type="empty-widget", style="flib_vertical_pusher"} + local support_frame = left_content_frame.add{type="frame", direction="vertical", style="fp_frame_bordered_stretch"} + support_frame.style.top_padding = 8 + support_frame.add{type="label", caption={"fp.preferences_support"}} + + -- Right side + local right_content_frame = modal_elements.secondary_frame + add_views_box(player, right_content_frame, modal_elements) + right_content_frame.add{type="empty-widget", style="flib_vertical_pusher"} + add_default_proto_box(player, right_content_frame, "belts", nil, "lanes_or_belts") + add_default_proto_box(player, right_content_frame, "pumps", nil, "quality_picker") + add_default_proto_box(player, right_content_frame, "wagons", 1, "quality_picker") -- cargo-wagon + add_default_proto_box(player, right_content_frame, "wagons", 2, "quality_picker") -- fluid-wagon +end + +local function close_preferences_dialog(player, _) + local ui_state = util.globals.ui_state(player) + if ui_state.modal_data.rebuild then + main_dialog.rebuild(player, true) + ui_state.modal_data = {} -- fix as rebuild deletes the table + elseif ui_state.modal_data.rebuild_compact then + compact_dialog.rebuild(player, false) + end +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "select_preference_default", + handler = handle_default_prototype_change + }, + { + name = "move_view", + handler = handle_view_move + } + }, + on_gui_checked_state_changed = { + { + name = "toggle_preference", + handler = handle_checkbox_preference_change + }, + { + name = "toggle_view", + handler = handle_view_toggle + } + }, + on_gui_selection_state_changed = { + { + name = "choose_preference", + handler = handle_dropdown_preference_change + }, + { + name = "select_preference_quality", + handler = handle_prototype_quality_change + }, + }, + on_gui_switch_state_changed = { + { + name = "choose_belts_or_lanes", + handler = handle_bol_change + } + }, +} + +listeners.dialog = { + dialog = "preferences", + metadata = (function(_) return { + caption = {"fp.preferences"}, + secondary_frame = true, + reset_handler_name = "reset_preferences" + } end), + open = open_preferences_dialog, + close = close_preferences_dialog +} + +listeners.global = { + reset_preferences = (function(player) + local player_table = util.globals.player_table(player) + player_table.preferences = nil + reload_preferences(player_table) + -- Pretty heavy way to reset, but it's very simple + player_table.ui_state.modal_data.rebuild = true + util.raise.close_dialog(player, "cancel") + util.raise.open_dialog(player, {dialog="preferences"}) + end) +} + +return { listeners } diff --git a/modfiles/ui/dialogs/recipe_dialog.lua b/modfiles/ui/dialogs/recipe_dialog.lua new file mode 100644 index 000000000..2fece0ffd --- /dev/null +++ b/modfiles/ui/dialogs/recipe_dialog.lua @@ -0,0 +1,422 @@ +local Line = require("backend.data.Line") + +-- ** LOCAL UTIL ** +-- Serves the dual-purpose of determining the appropriate settings for the recipe picker filter +-- and finding any recipes that produce the given prototype +local function match_recipes(player, modal_data, proto) + local force_recipes, force_technologies = player.force.recipes, player.force.technologies + local preferences = util.globals.preferences(player) + + local relevant_recipes = {} + local user_disabled_recipe = false + local counts = {disabled = 0, hidden = 0, disabled_hidden = 0} + + local map = RECIPE_MAPS[modal_data.production_type][proto.category_id][proto.id] + if map ~= nil then -- this being nil means that the item has no recipes + for recipe_id, _ in pairs(map) do + local recipe = prototyper.util.find("recipes", recipe_id, nil) + local force_recipe = force_recipes[recipe.name] + + if recipe.custom then -- Add custom recipes by default + table.insert(relevant_recipes, {proto=recipe, enabled=true}) + -- These are always enabled and non-hidden, so no need to tally them + -- They can also not be disabled by user preference + + elseif force_recipe ~= nil then -- only add recipes that exist on the current force + local user_disabled = (preferences.ignore_barreling_recipes and recipe.barreling) + or (preferences.ignore_recycling_recipes and recipe.recycling) + user_disabled_recipe = user_disabled_recipe or user_disabled + + if not user_disabled then -- only add recipes that are not disabled by the user + local recipe_enabled, recipe_hidden = force_recipe.enabled, recipe.hidden + local recipe_should_show = recipe.enabled_from_the_start or recipe_enabled + + -- If the recipe is not enabled, it has to be made sure that there is at + -- least one enabled technology that could potentially enable it + if not recipe_should_show and recipe.enabling_technologies ~= nil then + for _, technology_name in pairs(recipe.enabling_technologies) do + local force_tech = force_technologies[technology_name] + if force_tech and (force_tech.enabled or force_tech.visible_when_disabled) then + recipe_should_show = true + break + end + end + end + + if recipe_should_show then + table.insert(relevant_recipes, {proto=recipe, enabled=recipe_enabled}) + + if not recipe_enabled and recipe_hidden then counts.disabled_hidden = counts.disabled_hidden + 1 + elseif not recipe_enabled then counts.disabled = counts.disabled + 1 + elseif recipe_hidden then counts.hidden = counts.hidden + 1 end + end + end + end + end + end + + -- Set filters to try and show at least one recipe, should one exist, incorporating user preferences + local filters = {} + local user_prefs = preferences.recipe_filters + local relevant_recipes_count = #relevant_recipes + + if relevant_recipes_count - counts.disabled - counts.hidden - counts.disabled_hidden > 0 then + filters.disabled = user_prefs.disabled or false + filters.hidden = user_prefs.hidden or false + elseif relevant_recipes_count - counts.hidden - counts.disabled_hidden > 0 then + filters.disabled = true + filters.hidden = user_prefs.hidden or false + else + filters.disabled = true + filters.hidden = true + end + + -- Return result, format: return recipe, error-message, filters + if relevant_recipes_count == 0 then + local error = (user_disabled_recipe) and {"fp.error_no_enabled_recipe"} or {"fp.error_no_relevant_recipe"} + return nil, error, nil + else + return relevant_recipes, nil, filters + end +end + +-- Tries to add the given recipe to the current floor, then exiting the modal dialog +local function attempt_adding_line(player, recipe_id, modal_data) + local recipe_proto = prototyper.util.find("recipes", recipe_id, nil) + local line = Line.init(recipe_proto, modal_data.production_type) + + -- If finding a machine fails, this line is invalid + if line:change_machine_to_default(player) == false then + util.messages.raise(player, "error", {"fp.error_no_compatible_machine"}, 1) + else + local floor = util.context.get(player, "Floor") --[[@as Floor]] + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local relative_object = OBJECT_INDEX[modal_data.add_after_line_id] --[[@as LineObject]] + floor:insert(line, relative_object, "next") -- if not relative, insert uses last line + + log("[FP] RecipeDialog: Creating suggestions for recipe: " .. recipe_proto.name) + local last_recipe_items = {} + for _, p in ipairs(recipe_proto.products) do + local item_proto = prototyper.util.find("items", p.name, p.type) + if item_proto then + table.insert(last_recipe_items, item_proto) + end + end + for _, i in ipairs(recipe_proto.ingredients) do + local item_proto = prototyper.util.find("items", i.name, i.type) + if item_proto then + table.insert(last_recipe_items, item_proto) + end + end + factory.last_recipe_items = last_recipe_items + + log("[FP] RecipeDialog: Set " .. #last_recipe_items .. " items from last recipe for factory '" .. factory.name .. "'.") + local item_names = "" + for _, item in ipairs(last_recipe_items) do item_names = item_names .. item.name .. ", " end + log("[FP] RecipeDialog: Items are: " .. item_names) + + local recipe_name = recipe_proto.localised_name + if not (recipe_proto.custom or player.force.recipes[recipe_proto.name].enabled) then + util.messages.raise(player, "warning", {"fp.warning_recipe_disabled", recipe_name}, 2) + end + + if not line:get_surface_compatibility().overall then + util.messages.raise(player, "warning", {"fp.warning_surface_not_compatible", recipe_name}, 2) + end + + -- Set machine and beacon up as their default + line.machine:reset(player) + line:setup_beacon(player) + + -- Set temperature on ingredient that this recipe fulfills + if modal_data.temperature then + if modal_data.line_id then + local origin_line = OBJECT_INDEX[modal_data.line_id] + local fluid_name = modal_data.base_fluid.name + origin_line.temperatures[fluid_name] = modal_data.temperature + elseif modal_data.fuel_id then + OBJECT_INDEX[modal_data.fuel_id].temperature = modal_data.temperature + end + end + + solver.update(player) + util.raise.refresh(player, "factory") + end +end + + +local function create_filter_box(modal_data) + local content_frame = modal_data.modal_elements.content_frame + local bordered_frame = content_frame.add{type="frame", direction="vertical", style="fp_frame_bordered_stretch"} + bordered_frame.style.left_padding = 12 + + local table_filters = bordered_frame.add{type="table", column_count=2} + table_filters.style.horizontal_spacing = 16 + + local label_filters = table_filters.add{type="label", caption={"fp.show"}} + label_filters.style.top_margin = 2 + + local flow_filter_switches = table_filters.add{type="flow", direction="vertical"} + util.gui.switch.add_on_off(flow_filter_switches, "toggle_recipe_filter", {filter_name="disabled"}, + modal_data.filters.disabled, {"fp.unresearched_recipes"}, nil, false) + util.gui.switch.add_on_off(flow_filter_switches, "toggle_recipe_filter", {filter_name="hidden"}, + modal_data.filters.hidden, {"fp.hidden_recipes"}, nil, false) + + if modal_data.temperature then + bordered_frame.add{type="line", direction="horizontal"} + local flow_temperature = bordered_frame.add{type="flow", direction="horizontal"} + flow_temperature.style.vertical_align = "center" + flow_temperature.add{type="label", caption={"fp.compatible_temperatures"}} + + local annotation = flow_temperature.add{type="label", caption=modal_data.annotation} + annotation.style.left_margin = 16 + + local table_temperatures = bordered_frame.add{type="table", column_count=#modal_data.applicable_values} + table_temperatures.style.horizontal_spacing = 0 + table_temperatures.style.top_margin = 8 + + for index, temperature in pairs(modal_data.applicable_values) do + local toggled = temperature == modal_data.temperature + table_temperatures.add{type="button", caption={"fp.temperature_value", temperature}, + tags={mod="fp", on_gui_click="change_recipe_temperature", temperature=temperature}, + style="fp_button_push", toggled=toggled, mouse_button_filter={"left"}} + end + end +end + +local function create_recipe_group_box(modal_data, relevant_group) + local modal_elements = modal_data.modal_elements + local bordered_frame = modal_elements.content_frame.add{type="frame", style="fp_frame_bordered_stretch"} + bordered_frame.style.padding = 8 + + local next_index = #modal_elements.groups + 1 + modal_elements.groups[next_index] = {name=relevant_group.proto.name, frame=bordered_frame, recipe_buttons={}} + local recipe_buttons = modal_elements.groups[next_index].recipe_buttons + + local flow_group = bordered_frame.add{type="flow", direction="horizontal"} + flow_group.style.vertical_align = "center" + + local group_sprite = flow_group.add{type="sprite-button", sprite=("item-group/" .. relevant_group.proto.name), + tooltip=relevant_group.proto.localised_name, style="transparent_slot"} + group_sprite.style.size = 64 + group_sprite.style.right_margin = 16 + + flow_group.add{type="empty-widget", style="flib_horizontal_pusher"} + local frame_recipes = flow_group.add{type="frame", direction="horizontal", style="fp_frame_light_slots"} + frame_recipes.style.width = MAGIC_NUMBERS.recipes_per_row * 40 + local table_recipes = frame_recipes.add{type="table", column_count=MAGIC_NUMBERS.recipes_per_row, + style="slot_table"} + + for _, recipe in pairs(relevant_group.recipes) do + local recipe_proto = recipe.proto + local recipe_name = recipe_proto.name + + local style = "flib_slot_button_green" + if not recipe.enabled then style = "flib_slot_button_yellow" + elseif recipe_proto.hidden then style = "flib_slot_button_default" end + + local button_tags = {mod="fp", on_gui_click="pick_recipe", recipe_proto_id=recipe_proto.id} + local button_recipe = nil + + button_recipe = table_recipes.add{type="sprite-button", tags=button_tags, style=style, + sprite=recipe_proto.sprite, mouse_button_filter={"left"}} + if recipe_proto.custom then button_recipe.tooltip = recipe_proto.tooltip + else button_recipe.elem_tooltip = {type="recipe", name=recipe_name} end + + -- Figure out the translated name here so search doesn't have to repeat the work for every character + local translations = modal_data.translations + local translated_name = (translations) and translations["recipe"][recipe_name] or nil + translated_name = (translated_name) and translated_name:lower() or recipe_name + recipe_buttons[{name=recipe_name, translated_name=translated_name, hidden=recipe_proto.hidden}] = button_recipe + end +end + +local function build_dialog_structure(modal_data) + local modal_elements = modal_data.modal_elements + local content_frame = modal_elements.content_frame + content_frame.clear() + + create_filter_box(modal_data) + + local label_warning = content_frame.add{type="label", caption={"fp.error_message", {"fp.no_recipe_found"}}} + label_warning.style.font = "heading-2" + label_warning.style.margin = {8, 0, 0, 8} + modal_elements.warning_label = label_warning + + local recipe_groups = {} + for _, recipe in pairs(modal_data.result) do + local group_name = recipe.proto.group.name + recipe_groups[group_name] = recipe_groups[group_name] or {proto=recipe.proto.group, recipes={}} + recipe_groups[group_name].recipes[recipe.proto.name] = recipe + end + modal_data.recipe_groups = recipe_groups -- used by filter + + modal_elements.groups = {} + for _, group in ipairs(ORDERED_RECIPE_GROUPS) do + local relevant_group = modal_data.recipe_groups[group.name] + + -- Only actually create this group if it contains any relevant recipes + if relevant_group ~= nil then create_recipe_group_box(modal_data, relevant_group) end + end +end + +local function apply_recipe_filter(player, search_term) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local disabled, hidden = modal_data.filters.disabled, modal_data.filters.hidden + + local any_recipe_visible, desired_scroll_pane_height = false, 64+24 + if modal_data.temperature then desired_scroll_pane_height = desired_scroll_pane_height + 70 end + + for _, group in ipairs(modal_data.modal_elements.groups) do + local group_data = modal_data.recipe_groups[group.name] + local any_group_recipe_visible = false + + for recipe_data, button in pairs(group.recipe_buttons) do + local recipe_name = recipe_data.name + local recipe_enabled = group_data.recipes[recipe_name].enabled + + -- Can only get to this if translations are complete, as the textfield is disabled otherwise + local found = (search_term == recipe_name) or string.find(recipe_data.translated_name, search_term, 1, true) + local visible = found and (disabled or recipe_enabled) and (hidden or not recipe_data.hidden) or false + + button.visible = visible + any_group_recipe_visible = any_group_recipe_visible or visible + end + + group.frame.visible = any_group_recipe_visible + any_recipe_visible = any_recipe_visible or any_group_recipe_visible + + local button_table_height = math.ceil(table_size(group.recipe_buttons) / MAGIC_NUMBERS.recipes_per_row) * 40 + local additional_height = math.max(88, button_table_height + 24) + 4 + desired_scroll_pane_height = desired_scroll_pane_height + additional_height + end + + modal_data.modal_elements.warning_label.visible = not any_recipe_visible + + local scroll_pane_height = math.min(desired_scroll_pane_height, modal_data.dialog_maximal_height - 80) + modal_data.modal_elements.content_frame.style.height = scroll_pane_height +end + + +local function handle_filter_change(player, tags, event) + local boolean_state = util.gui.switch.convert_to_boolean(event.element.switch_state) + util.globals.modal_data(player).filters[tags.filter_name] = boolean_state + util.globals.preferences(player).recipe_filters[tags.filter_name] = boolean_state + + apply_recipe_filter(player, "") +end + +local function apply_temperature(player, temperature) + local modal_data = util.globals.modal_data(player) --[[@as table]] + modal_data.temperature = temperature + + local name = modal_data.base_fluid.name .. "-" .. temperature + local proto = prototyper.util.find("items", name, "fluid") + local result, _, filters = match_recipes(player, modal_data, proto) -- can't error + modal_data.result = result + modal_data.filters = filters +end + + +-- Checks whether the dialog needs to be created at all +local function recipe_early_abort_check(player, modal_data) + local proto = prototyper.util.find("items", modal_data.product_id, modal_data.category_id) + + local base_fluid = (proto.type == "fluid" and proto.temperature == nil) + if base_fluid then return false end -- proceed to opening the dialog right away + + -- Result is either the single possible recipe_id, or a table of relevant recipes + local result, error, filters = match_recipes(player, modal_data, proto) + + if error ~= nil then + util.messages.raise(player, "error", error, 1) + return true -- signal that the dialog does not need to actually be opened + + else + if #result == 1 then -- if one relevant recipe is found, try it straight away + attempt_adding_line(player, result[1].proto.id, modal_data) + return true -- idem above + + else -- Otherwise, save the relevant data for the dialog opener + modal_data.result = result + modal_data.filters = filters + return false -- signal that the dialog should be opened + end + end +end + +-- Handles populating the recipe dialog +local function open_recipe_dialog(player, modal_data) + -- At this point, we're sure there's more than one recipe choice + if modal_data.result == nil then -- this is a base_fluid dialog + modal_data.base_fluid = prototyper.util.find("items", modal_data.product_id, modal_data.category_id) + + local object = OBJECT_INDEX[modal_data.line_id or modal_data.fuel_id] + local temperature_data = (modal_data.line_id) and object.temperature_data[modal_data.base_fluid.name] + or object.temperature_data + + modal_data.annotation = temperature_data.annotation + modal_data.applicable_values = temperature_data.applicable_values + apply_temperature(player, modal_data.applicable_values[1]) + end + + modal_data.translations = util.globals.player_table(player).translation_tables + build_dialog_structure(modal_data) + apply_recipe_filter(player, "") + modal_data.modal_elements.search_textfield.focus() +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "pick_recipe", + timeout = 20, + handler = (function(player, tags, _) + local modal_data = util.globals.modal_data(player) + attempt_adding_line(player, tags.recipe_proto_id, modal_data) + util.raise.close_dialog(player, "cancel") + end) + }, + { + name = "change_recipe_temperature", + handler = (function(player, tags, _) + apply_temperature(player, tags.temperature) + + local modal_data = util.globals.modal_data(player) + build_dialog_structure(modal_data) + apply_recipe_filter(player, "") + end) + } + }, + on_gui_switch_state_changed = { + { + name = "toggle_recipe_filter", + handler = handle_filter_change + } + } +} + +listeners.dialog = { + dialog = "recipe", + metadata = (function(modal_data) + local product_proto = prototyper.util.find("items", modal_data.product_id, modal_data.category_id) + return { + caption = {"", {"fp.add"}, " ", {"fp.pl_recipe", 1}}, + subheader_text = {"fp.recipe_instruction", {"fp." .. modal_data.production_type}, + product_proto.localised_name}, + search_handler_name = "apply_recipe_filter" + } + end), + early_abort_check = recipe_early_abort_check, + open = open_recipe_dialog +} + +listeners.global = { + apply_recipe_filter = apply_recipe_filter +} + +return { listeners } diff --git a/modfiles/ui/dialogs/utility_dialog.lua b/modfiles/ui/dialogs/utility_dialog.lua new file mode 100644 index 000000000..7400502d8 --- /dev/null +++ b/modfiles/ui/dialogs/utility_dialog.lua @@ -0,0 +1,537 @@ +-- ** LOCAL UTIL ** +-- Adds a box with title and optional scope switch for the given type of utility +local function add_utility_box(player, modal_elements, parent_name, type, show_tooltip, show_switch) + local bordered_frame = modal_elements[parent_name].add{type="frame", direction="vertical", + style="fp_frame_bordered_stretch"} + modal_elements[type .. "_box"] = bordered_frame + + local flow_title_bar = bordered_frame.add{type="flow", direction="horizontal"} + flow_title_bar.style.vertical_align = "center" + flow_title_bar.style.margin = {2, 0, 4, 0} + + -- Title + local caption = (show_tooltip) and {"fp.info_label", {"fp.utility_title_".. type}} or {"fp.utility_title_".. type} + local tooltip = (show_tooltip) and {"fp.utility_title_" .. type .. "_tt"} + local label_title = flow_title_bar.add{type="label", caption=caption, tooltip=tooltip, style="caption_label"} + label_title.style.top_margin = -2 + + -- Empty flow for custom controls + flow_title_bar.add{type="empty-widget", style="flib_horizontal_pusher"} + local flow_custom = flow_title_bar.add{type="flow"} + flow_custom.style.right_margin = 12 + + -- Scope switch + local scope_switch = nil + if show_switch then + local utility_scope = util.globals.preferences(player).utility_scopes[type] + local switch_state = (utility_scope == "Factory") and "left" or "right" + scope_switch = flow_title_bar.add{type="switch", switch_state=switch_state, + tags={mod="fp", on_gui_switch_state_changed="utility_change_scope", utility_type=type}, + left_label_caption={"fp.pu_factory", 1}, right_label_caption={"fp.pu_floor", 1}} + end + + return bordered_frame, flow_custom, scope_switch +end + + +local utility_structures = {} + +function utility_structures.components(player, modal_data) + local preferences = util.globals.preferences(player) + local scope = preferences.utility_scopes.components + local skip_done = (preferences.done_column == true) + local modal_elements = modal_data.modal_elements + + if modal_elements.components_box == nil then + local components_box, custom_flow, scope_switch = add_utility_box(player, modal_data.modal_elements, + "content_frame", "components", true, true) + modal_elements.components_box = components_box + modal_elements.scope_switch = scope_switch + + local function action_button(sprite, action) + local button = custom_flow.add{type="sprite-button", sprite=sprite, tags={mod="fp", on_gui_click=action}, + style="fp_sprite-button_rounded_sprite", mouse_button_filter={"left"}} + button.style.size = 29 + button.style.padding = 0 + return button + end + modal_elements.combinator_button = action_button("item/constant-combinator", "utility_item_combinator") + modal_elements.request_button = action_button("item/logistic-robot", "utility_request_items") + + local table_components = components_box.add{type="table", column_count=2} + table_components.style.horizontal_spacing = 24 + table_components.style.vertical_spacing = 8 + + local function add_component_row(type) + table_components.add{type="label", caption={"fp.pu_" .. type, 2}, style="semibold_label"} + + local flow = table_components.add{type="flow", direction="horizontal"} + modal_elements["components_" .. type .. "_flow"] = flow + end + + add_component_row("machine") + add_component_row("module") + end + + local relevant_object = util.context.get(player, scope) + if scope == "Factory" then relevant_object = relevant_object--[[@as Factory]].top_floor end + local component_data = relevant_object--[[@as Floor]]:get_component_data(skip_done, nil) + + local function refresh_component_flow(type) + local component_row = modal_elements["components_" .. type .. "_flow"] + component_row.clear() + + local main_inventory = (player.character) and player.character.get_main_inventory() or nil + local frame_components = component_row.add{type="frame", direction="horizontal", style="fp_frame_light_slots"} + local table_components = frame_components.add{type="table", column_count=10, style="filter_slot_table"} + + for _, component in pairs(component_data[type .. "s"]) do + if component.amount > 0 then + local proto, quality_proto, required_amount = component.proto, component.quality_proto, component.amount + local item_id = {name = proto.name, quality = quality_proto.name} + local amount_in_inventory = (main_inventory) and main_inventory.get_item_count(item_id) or 0 + local missing_amount = required_amount - amount_in_inventory + + if missing_amount > 0 then + table.insert(modal_data.missing_items, { + type = "item", + name = proto.name, + quality = quality_proto.name, + comparator = "=", + count = missing_amount, + required_count = required_amount + }) + end + + local button_style = nil + if amount_in_inventory == 0 then button_style = "flib_slot_button_red" + elseif missing_amount > 0 then button_style = "flib_slot_button_yellow" + else button_style = "flib_slot_button_green" end + + local title_line = (not quality_proto.always_show) and {"fp.tt_title",proto.localised_name} + or {"fp.tt_title_with_note", proto.localised_name, quality_proto.rich_text} + local tooltip = {"fp.components_needed_tt", title_line, amount_in_inventory, required_amount} + + local category_id = (proto.data_type == "items") and proto.category_id + or prototyper.util.find("items", nil, "item").id + local proto_id = (proto.data_type == "items") and proto.id + or prototyper.util.find("items", proto.name, "item").id + table_components.add{type="sprite-button", sprite=proto.sprite, number=required_amount, tooltip=tooltip, + tags={mod="fp", on_gui_click="utility_craft_items", category_id=category_id, item_id=proto_id, + missing_amount=missing_amount}, quality=quality_proto.name, style=button_style, + mouse_button_filter={"left-and-right"}} + end + end + + if not next(table_components.children_names) then + frame_components.visible = false + local label = component_row.add{type="label", caption={"fp.no_components_needed", {"fp.pl_" .. type, 2}}} + label.style.margin = {10, 0} + end + end + + modal_data.missing_items = {} -- a flat structure works because there is no overlap between machines and modules + refresh_component_flow("machine") + refresh_component_flow("module") + + local any_missing_items = (next(modal_data.missing_items) ~= nil) + local no_items_necessary = {"fp.utility_no_items_necessary", {"fp.pl_" .. scope:lower(), 1}} + local function configure_button(name) + local button = modal_elements[name .. "_button"] + button.enabled = any_missing_items + button.tooltip = (any_missing_items) and {"fp.utility_" .. name .. "_tt"} or no_items_necessary + end + configure_button("combinator") + configure_button("request") +end + +function utility_structures.blueprints(player, modal_data) + local modal_elements = modal_data.modal_elements + local blueprints = util.context.get(player, "Factory").blueprints + local blueprint_limit = MAGIC_NUMBERS.blueprint_limit + + if modal_elements.blueprints_box == nil then + local blueprints_box = add_utility_box(player, modal_elements, "content_frame", "blueprints", true, false) + blueprints_box.style.margin = {4, 0} + modal_elements["blueprints_box"] = blueprints_box + + local frame_blueprints = blueprints_box.add{type="frame", direction="horizontal", style="fp_frame_light_slots"} + local table_blueprints = frame_blueprints.add{type="table", column_count=blueprint_limit, + style="filter_slot_table"} + table_blueprints.style.width = blueprint_limit * 40 + modal_elements["blueprints_table"] = table_blueprints + end + + local table_blueprints = modal_elements["blueprints_table"] + table_blueprints.clear() + + local function format_signal(signal) + -- signal.type is nil if it's really "item", plus we need to translate the virtual type + local type = (signal.type == "virtual") and "virtual-signal" or "item" + return (type .. "/" .. signal.name) + end + + local blueprint = modal_data.utility_inventory[1] -- re-usable inventory slot + for index, blueprint_string in pairs(blueprints) do + blueprint.import_stack(blueprint_string) + local blueprint_book = blueprint.is_blueprint_book + + local tooltip = {"", (blueprint.label or "Blueprint"), "\n", MODIFIER_ACTIONS["act_on_blueprint"].tooltip} + local sprite = (blueprint_book) and "item/blueprint-book" or "item/blueprint" + local button = table_blueprints.add{type="sprite-button", sprite=sprite, tooltip=tooltip, + tags={mod="fp", on_gui_click="act_on_blueprint", index=index}, mouse_button_filter={"left-and-right"}} + + local icons = (not blueprint_book) and blueprint.preview_icons + or blueprint.get_inventory(defines.inventory.item_main)[1].preview_icons + if icons then -- this is jank-hell + local icon_count = #icons + local flow = button.add{type="flow", direction="horizontal", ignored_by_interaction=true} + local top_margin = (blueprint_book) and 4 or 7 + + if icon_count == 1 then + local sprite_icon = flow.add{type="sprite", sprite=format_signal(icons[1].signal)} + sprite_icon.style.margin = {top_margin, 0, 0, 7} + else + flow.style.padding = {4, 0, 0, 3} + local table = flow.add{type="table", column_count=2} + table.style.cell_padding = -3 + if icon_count == 2 then table.style.top_margin = top_margin end + for _, icon in pairs(icons) do + table.add{type="sprite", sprite=format_signal(icon.signal)} + end + end + end + + blueprint.clear() + end + + if #blueprints < blueprint_limit then + local button_add = table_blueprints.add{type="sprite-button", sprite="utility/add", + tags={mod="fp", on_gui_click="utility_store_blueprint"}, style="fp_sprite-button_inset", + mouse_button_filter={"left"}} + button_add.style.padding = 4 + button_add.style.margin = 4 + end +end + +function utility_structures.notes(player, modal_data) + local utility_box = add_utility_box(player, modal_data.modal_elements, "content_frame", "notes", false, false) + + local notes = util.context.get(player, "Factory").notes + local text_box = utility_box.add{type="text-box", text=notes, + tags={mod="fp", on_gui_text_changed="factory_notes"}} + text_box.style.vertically_stretchable = true + text_box.style.minimal_height = 320 + text_box.style.width = 480 + text_box.word_wrap = true +end + +function utility_structures.productivity_boni(player, modal_data) + local current_factory = util.context.get(player, "Factory") --[[@as Factory]] + local attach_factory_products = util.globals.preferences(player).attach_factory_products + + if not modal_data.modal_elements["productivity_boni_table"] then + local boni_box = add_utility_box(player, modal_data.modal_elements, "secondary_frame", + "productivity_boni", true, false) + + local flow_import = boni_box.add{type="flow", direction="horizontal"} + flow_import.style.vertical_align = "center" + flow_import.style.bottom_margin = 8 + flow_import.add{type="label", caption={"fp.import_from"}, style="bold_label"} + flow_import.add{type="empty-widget", style="flib_horizontal_pusher"} + + local factory_names = {} + modal_data.factory_index = {} -- used to find the factory later + for factory in current_factory.parent:iterator() do + if factory.id ~= current_factory.id then + local factory_name = factory:tostring(attach_factory_products, true) + table.insert(factory_names, factory_name) + table.insert(modal_data.factory_index, factory.id) -- will match dropdown index + end + end + local enabled = (#factory_names > 0) + local dropdown_factory = flow_import.add{type="drop-down", items=factory_names, enabled=enabled} + dropdown_factory.style.maximal_width = 225 + modal_data.modal_elements["factory_dropdown"] = dropdown_factory + + flow_import.add{type="sprite-button", tags={mod="fp", on_gui_click="import_productivity_boni"}, + style="flib_tool_button_light_green", tooltip={"fp.import_from_tt"}, enabled=enabled, + sprite="utility/check_mark", mouse_button_filter={"left"}} + + + local table = boni_box.add{type="table", column_count=3} + table.style.column_alignments[2] = "center" + table.style.column_alignments[3] = "center" + table.style.horizontal_spacing = 16 + modal_data.modal_elements["productivity_boni_table"] = table + + boni_box.add{type="empty-widget", style="flib_vertical_pusher"} + end + local table = modal_data.modal_elements["productivity_boni_table"] + table.clear() + + table.add{type="label", caption={"fp.pu_recipe", 1}, style="bold_label"} + table.add{type="label", caption={"fp.current"}, style="bold_label"} + table.add{type="label", caption={"fp.custom"}, style="bold_label"} + + for recipe_name in pairs(PRODUCTIVITY_RECIPES) do + local recipe_proto = prototyper.util.find("recipes", recipe_name, nil) --[[@as FPRecipePrototype]] + local caption = (recipe_name == "custom-mining") + and {"", "[img=utility/mining_drill_productivity_bonus_modifier_icon] ", {"fp.mining_recipes"}} + or {"", "[recipe=" .. recipe_name .. "] ", recipe_proto.localised_name} + table.add{type="label", caption=caption}.style.width = 250 + + local productivity = util.get_recipe_productivity(player.force, recipe_name) + local percentage = ("%+d"):format(math.floor((productivity * 100) + 0.5)) .. "%" + table.add{type="label", caption=percentage} + + local current_bonus = current_factory.productivity_boni[recipe_name] + local current_percentage = (current_bonus) and current_bonus * 100 or nil + local textfield_bonus = table.add{type="textfield", text=current_percentage, + tags={mod="fp", on_gui_text_changed="productivity_bonus", recipe_name=recipe_name}} + util.gui.setup_numeric_textfield(textfield_bonus, false, false) + textfield_bonus.style.width = 52 + end +end + + +local function handle_scope_change(player, tags, event) + local utility_scope = (event.element.switch_state == "left") and "Factory" or "Floor" + util.globals.preferences(player).utility_scopes[tags.utility_type] = utility_scope + + local modal_data = util.globals.modal_data(player) + utility_structures.components(player, modal_data) +end + +local function handle_item_request(player, _, _) + local fly_text = util.cursor.create_flying_text + + if not player.force.character_logistic_requests then + fly_text(player, {"fp.utility_logistics_not_researched"}) + elseif player.character == nil then -- happens when the editor is active for example + fly_text(player, {"fp.utility_logistics_no_character"}) + else + local requester_point = player.character.get_requester_point() -- will exist at this point + local new_section = requester_point.add_section() + + local missing_items = util.globals.modal_data(player).missing_items + for index, item in pairs(missing_items) do + new_section.set_slot(index, { + value = { + name = item.name, + quality = item.quality, + comparator = item.comparator + }, + min = item.required_count + }) + end + + fly_text(player, {"fp.utility_logistics_request_set"}) + end +end + +local function handle_item_handcraft(player, tags, event) + local fly_text = util.cursor.create_flying_text + if not player.character then fly_text(player, {"fp.utility_crafting_no_character"}); return end + + local permissions = player.permission_group + local forbidden = (permissions and not permissions.allows_action(defines.input_action.craft)) + if forbidden then fly_text(player, {"fp.utility_no_crafting"}); return end + + local desired_amount = (event.button == defines.mouse_button_type.right) and 5 or 1 + local amount_to_craft = math.min(desired_amount, tags.missing_amount) + + if amount_to_craft <= 0 then fly_text(player, {"fp.utility_no_demand"}); return end + + local recipes = RECIPE_MAPS["produce"][tags.category_id][tags.item_id] + if not recipes then fly_text(player, {"fp.utility_no_recipe"}); return end + + local success = false + for recipe_id, _ in pairs(recipes) do + local recipe_name = prototyper.util.find("recipes", recipe_id, nil).name + local craftable_amount = player.get_craftable_count(recipe_name) + + if craftable_amount > 0 then + success = true + local crafted_amount = math.min(amount_to_craft, craftable_amount) + player.begin_crafting{count=crafted_amount, recipe=recipe_name, silent=true} + amount_to_craft = amount_to_craft - crafted_amount + break + end + end + if not success then fly_text(player, {"fp.utility_no_resources"}); end +end + +local function handle_inventory_change(player) + local ui_state = util.globals.ui_state(player) + + if ui_state.modal_dialog_type == "utility" then + utility_structures.components(player, ui_state.modal_data) + end +end + + +local function store_blueprint(player, _, _) + local fly_text = util.cursor.create_flying_text + + if player.is_cursor_empty() then + fly_text(player, {"fp.utility_cursor_empty"}); return + end + local cursor = player.cursor_stack + if not (cursor.is_blueprint or cursor.is_blueprint_book) then + if cursor.valid_for_read then + fly_text(player, {"fp.utility_no_blueprint"}); return + else + fly_text(player, {"fp.utility_blueprint_from_library"}); return + end + end + if cursor.is_blueprint then + if not cursor.is_blueprint_setup() then fly_text(player, {"fp.utility_blueprint_not_setup"}); return end + else -- blueprint book + local inventory = cursor.get_inventory(defines.inventory.item_main) + if inventory.is_empty() then fly_text(player, {"fp.utility_blueprint_book_empty"}); return end + end + + local factory = util.context.get(player, "Factory") --[[@as Factory]] + table.insert(factory.blueprints, cursor.export_stack()) + fly_text(player, {"fp.utility_blueprint_stored"}); + player.clear_cursor() -- doesn't delete blueprint, but puts it back in the inventory + + utility_structures.blueprints(player, util.globals.modal_data(player)) +end + +local function handle_blueprint_click(player, tags, action) + local blueprints = util.context.get(player, "Factory").blueprints + + if action == "pick_up" then + player.cursor_stack.import_stack(blueprints[tags.index]) + util.raise.close_dialog(player, "cancel") + main_dialog.toggle(player) + + elseif action == "delete" then + table.remove(blueprints, tags.index) + utility_structures.blueprints(player, util.globals.modal_data(player)) + end +end + + +local function import_productivity_boni(player, _, event) + local modal_data = util.globals.modal_data(player) --[[@as table]] + local selected_index = modal_data.modal_elements.factory_dropdown.selected_index + local export_factory = OBJECT_INDEX[modal_data.factory_index[selected_index]] --[[@as Factory]] + if not export_factory then return end -- dropdown starts blank + + local import_factory = util.context.get(player, "Factory") --[[@as Factory]] + import_factory.productivity_boni = ftable.deep_copy(export_factory.productivity_boni) + + utility_structures.productivity_boni(player, modal_data) + modal_data.recalculate = true + + util.cursor.create_flying_text(player, {"fp.utility_productivity_imported"}) +end + + +local function open_utility_dialog(player, modal_data) + modal_data.utility_inventory = game.create_inventory(1) -- used for blueprint decoding + + -- Left side + utility_structures.components(player, modal_data) + utility_structures.blueprints(player, modal_data) + utility_structures.notes(player, modal_data) + + -- Right side + utility_structures.productivity_boni(player, modal_data) +end + +local function close_utility_dialog(player, _) + local modal_data = util.globals.modal_data(player) --[[@as table]] + if modal_data.recalculate then + local factory = util.context.get(player, "Factory") --[[@as Factory]] + solver.update(player, factory) + util.raise.refresh(player, "factory") + end + modal_data.utility_inventory.destroy() +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "utility_item_combinator", + timeout = 20, + handler = (function(player, _, _) + local missing_items = util.globals.modal_data(player).missing_items + util.cursor.set_item_combinator(player, missing_items) + util.raise.close_dialog(player, "cancel") + main_dialog.toggle(player) + end) + }, + { + name = "utility_request_items", + timeout = 20, + handler = handle_item_request + }, + { + name = "utility_craft_items", + handler = handle_item_handcraft + }, + { + name = "utility_store_blueprint", + handler = store_blueprint + }, + { + name = "act_on_blueprint", + actions_table = { + pick_up = {shortcut="left", show=true}, + delete = {shortcut="control-right", show=true} + }, + handler = handle_blueprint_click + }, + { + name = "import_productivity_boni", + handler = import_productivity_boni + } + }, + on_gui_switch_state_changed = { + { + name = "utility_change_scope", + handler = handle_scope_change + } + }, + on_gui_text_changed = { + { + name = "factory_notes", + handler = (function(player, _, event) + util.context.get(player, "Factory").notes = event.element.text + end) + }, + { + name = "productivity_bonus", + handler = (function(player, tags, event) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local bonus = tonumber(event.element.text) -- nil if invalid or empty + factory.productivity_boni[tags.recipe_name] = (bonus) and bonus / 100 or nil + util.globals.modal_data(player).recalculate = true + end) + } + } +} + +listeners.dialog = { + dialog = "utility", + metadata = (function(_) return { + caption = {"fp.utilities"}, + secondary_frame = true + } end), + open = open_utility_dialog, + close = close_utility_dialog +} + +listeners.misc = { + on_player_main_inventory_changed = handle_inventory_change +} + +return { listeners } diff --git a/modfiles/ui/event_handler.lua b/modfiles/ui/event_handler.lua new file mode 100644 index 000000000..92e6ff5f7 --- /dev/null +++ b/modfiles/ui/event_handler.lua @@ -0,0 +1,355 @@ +-- Assembles event handlers from all the relevant files and calls them when needed + +local event_listener_names = { + "ui.base.main_dialog", "ui.base.compact_dialog", "ui.base.modal_dialog", "ui.base.calculator_dialog", + "ui.components.module_configurator", "ui.components.item_views", + "ui.dialogs.beacon_dialog", "ui.dialogs.machine_dialog", "ui.dialogs.picker_dialog", + "ui.dialogs.porter_dialog", "ui.dialogs.preferences_dialog", "ui.dialogs.recipe_dialog", + "ui.dialogs.factory_dialog", "ui.dialogs.utility_dialog", "ui.dialogs.item_dialog", + "ui.main.title_bar", "ui.main.district_info", "ui.main.factory_list", "ui.main.production_bar", + "ui.main.districts_box", "ui.main.item_boxes", "ui.main.production_box", "ui.main.production_table", + "ui.main.production_handler" +} + +local event_listeners = {} +for _, listener_path in ipairs(event_listener_names) do + for _, listener in pairs(require(listener_path)) do + table.insert(event_listeners, listener) + end +end + + +-- ** GUI EVENTS ** +-- These handlers go out to the first thing that it finds that registered for it. +-- They can register either by element name or by a pattern matching element names. +local gui_identifier_map = { + [defines.events.on_gui_click] = "on_gui_click", + [defines.events.on_gui_closed] = "on_gui_closed", + [defines.events.on_gui_confirmed] = "on_gui_confirmed", + [defines.events.on_gui_text_changed] = "on_gui_text_changed", + [defines.events.on_gui_checked_state_changed] = "on_gui_checked_state_changed", + [defines.events.on_gui_switch_state_changed] = "on_gui_switch_state_changed", + [defines.events.on_gui_selection_state_changed] = "on_gui_selection_state_changed", + [defines.events.on_gui_elem_changed] = "on_gui_elem_changed", + [defines.events.on_gui_value_changed] = "on_gui_value_changed", + [defines.events.on_gui_hover] = "on_gui_hover", + [defines.events.on_gui_leave] = "on_gui_leave" +} + +-- ** SPECIAL HANDLERS ** +local special_gui_handlers = {} + +special_gui_handlers.on_gui_closed = (function(event, _, _) + return (event.gui_type == defines.gui_type.custom and event.element.visible) +end) + +special_gui_handlers.on_gui_confirmed = (function(_, player, action_name) + if action_name then return true end -- run the standard handler if one is found + + -- Otherwise, close the currently open modal dialog if possible + if util.globals.ui_state(player).modal_dialog_type ~= nil then + util.raise.close_dialog(player, "submit") + end + return false +end) + +local gui_timeouts = { + on_gui_click = 2, + on_gui_confirmed = 20 +} + + +---@class ActionTable +---@field handler function +---@field timeout Tick +---@field actions ActionDetails +---@field shortcuts { string: ActionDetails } +---@field tooltip LocalisedString + +---@class ActionDetails +---@field name string +---@field limitations ActionLimitations +---@field shortcut_string LocalisedString +---@field show boolean + +-- Compile and format the list of GUI actions +for _, listener in pairs(event_listeners) do + for event_name, actions in pairs(listener.gui or {}) do + for _, action in pairs(actions) do + local timeout = action.timeout or gui_timeouts[event_name] -- can be nil + local action_table = {handler = action.handler, timeout = timeout} + + if event_name == "on_gui_click" and action.actions_table then + action_table.actions, action_table.shortcuts = {}, {} + -- Transform actions table into a more useable form + for action_name, modifier_action in pairs(action.actions_table) do + local action_details = { + name = action_name, + limitations = modifier_action.limitations or {}, + shortcut_string = util.actions.shortcut_string(modifier_action.shortcut), + show = modifier_action.show + } + table.insert(action_table.actions, action_details) + + if modifier_action.shortcut then + action_table.shortcuts[modifier_action.shortcut] = action_details + end + end + action_table.tooltip = util.actions.generate_tooltip(action_table.actions) + end + + if MODIFIER_ACTIONS[action.name] then error("Duplicate action: " .. action.name) end + MODIFIER_ACTIONS[action.name] = action_table + end + end +end + + +local mouse_click_map = { + [defines.mouse_button_type.left] = "left", + [defines.mouse_button_type.right] = "right", + [defines.mouse_button_type.middle] = "middle" +} +local function convert_click_to_string(event) + local modifier_click = mouse_click_map[event.button] + if event.shift then modifier_click = "shift-" .. modifier_click end + if event.alt then modifier_click = "alt-" .. modifier_click end + if event.control then modifier_click = "control-" .. modifier_click end + return modifier_click +end + +local function handle_gui_event(event) + if not event.element then return end + + -- GUI events always have an associated player + local player = game.get_player(event.player_index) ---@cast player -nil + + -- Guard against an event being called before the player is initialized + if not storage.players[event.player_index] then return end + + local tags = event.element.tags + + -- Close an open context menu on any GUI click + if event.name == defines.events.on_gui_click and + not tags.on_gui_click ~= "choose_context_action" then + modal_dialog.close_context_menu(player) + end + + if tags.mod ~= "fp" then return end + + -- The event table actually contains its identifier, not its name + local event_name = gui_identifier_map[event.name] + local action_name = tags[event_name] -- could be nil + + -- If a special handler is set, it needs to return true before proceeding with the registered handlers + local special_handler = special_gui_handlers[event_name] + if special_handler and special_handler(event, player, action_name) == false then return end + + -- Special handlers need to run even without an action handler, so we + -- wait until this point to check whether there is an associated action + if not action_name then return end -- meaning this event type has no action on this element + local action_table = MODIFIER_ACTIONS[action_name] or {} + + -- Check if rate limiting allows this action to proceed + if util.actions.rate_limited(player, event.tick, action_name, action_table.timeout) then return end + + -- Special modifier handling for on_gui_click if configured + if event_name == "on_gui_click" and action_table.actions then + local click = convert_click_to_string(event) + + if click == "right" then + modal_dialog.open_context_menu(player, tags, action_name, + action_table.actions, event.cursor_display_location) + else + local modifier_action = action_table.shortcuts[click] + if not modifier_action then return end -- meaning the used modifiers do not have an associated action + + local active_limitations = util.actions.current_limitations(player) + if util.actions.allowed(modifier_action.limitations, active_limitations) then + action_table.handler(player, tags, modifier_action.name) + end + end + else + action_table.handler(player, tags, event) -- gets event as third parameter + end + + -- Only refresh messages if the event wasn't a hover event + if event_name ~= "on_gui_hover" and event_name ~= "on_gui_leave" then util.messages.refresh(player) end +end + +-- Register all the GUI events from the identifier map +for event_id, _ in pairs(gui_identifier_map) do script.on_event(event_id, handle_gui_event) end + + + +-- ** DIALOG EVENTS ** +-- These custom events handle opening and closing modal dialogs +local dialog_event_cache = {} +-- Compile the list of dialog actions +for _, listener in pairs(event_listeners) do + if listener.dialog then + dialog_event_cache[listener.dialog.dialog] = listener.dialog + end +end + +local function apply_metadata_overrides(base, overrides) + for k, v in pairs(overrides) do + local base_v = base[k] + if type(base_v) == "table" and type(v) == "table" then + apply_metadata_overrides(base_v, v) + else + base[k] = v + end + end +end + +local function handle_dialog_event(event) + -- Guard against an event being called before the player is initialized + if not storage.players[event.player_index] then return end + + -- These custom events always have an associated player + local player = game.get_player(event.player_index) ---@cast player -nil + local ui_state = util.globals.ui_state(player) + + -- Check if the action is allowed to be carried out by rate limiting + if util.actions.rate_limited(player, event.tick, event.name, 20) then return end + + if event.name == CUSTOM_EVENTS.open_modal_dialog then + local listener = dialog_event_cache[event.metadata.dialog] + + local metadata = event.metadata + if listener.metadata ~= nil then -- collect additional metadata + local additional_metadata = listener.metadata(metadata.modal_data) + apply_metadata_overrides(metadata, additional_metadata) + end + + modal_dialog.enter(player, metadata, listener.open, listener.early_abort_check) + + elseif event.name == CUSTOM_EVENTS.close_modal_dialog then + local modal_dialog_type = ui_state.modal_dialog_type + if modal_dialog_type == nil then return end + + local listener = dialog_event_cache[modal_dialog_type] + modal_dialog.exit(player, event.action, event.skip_opened, listener.close) + end +end + +-- Register all the misc events from the identifier map +local dialog_events = {CUSTOM_EVENTS.open_modal_dialog, CUSTOM_EVENTS.close_modal_dialog} +for _, event_id in pairs(dialog_events) do script.on_event(event_id, handle_dialog_event) end + + + +-- ** MISC EVENTS ** +-- These events call every handler that has subscribed to it by id or name. The difference to GUI events +-- is that multiple handlers can be registered to the same event, and there is no standard handler. +local misc_identifier_map = { + -- Standard events + [defines.events.on_gui_opened] = "on_gui_opened", + [defines.events.on_player_display_resolution_changed] = "on_player_display_resolution_changed", + [defines.events.on_player_display_scale_changed] = "on_player_display_scale_changed", + [defines.events.on_singleplayer_init] = "on_singleplayer_init", + [defines.events.on_multiplayer_init] = "on_multiplayer_init", + [defines.events.on_player_selected_area] = "on_player_selected_area", + [defines.events.on_player_cursor_stack_changed] = "on_player_cursor_stack_changed", + [defines.events.on_player_main_inventory_changed] = "on_player_main_inventory_changed", + [defines.events.on_lua_shortcut] = "on_lua_shortcut", + + -- Keyboard shortcuts + ["fp_toggle_interface"] = "fp_toggle_interface", + ["fp_toggle_compact_view"] = "fp_toggle_compact_view", + ["fp_toggle_pause"] = "fp_toggle_pause", + ["fp_refresh_production"] = "fp_refresh_production", + ["fp_up_floor"] = "fp_up_floor", + ["fp_top_floor"] = "fp_top_floor", + ["fp_toggle_fold_out_subfloors"] = "fp_toggle_fold_out_subfloors", + ["fp_cycle_production_views"] = "fp_cycle_production_views", + ["fp_reverse_cycle_production_views"] = "fp_reverse_cycle_production_views", + ["fp_confirm_dialog"] = "fp_confirm_dialog", + ["fp_confirm_gui"] = "fp_confirm_gui", + ["fp_focus_searchfield"] = "fp_focus_searchfield", + ["fp_toggle_calculator"] = "fp_toggle_calculator", + + [CUSTOM_EVENTS.build_gui_element] = "build_gui_element", + [CUSTOM_EVENTS.refresh_gui_element] = "refresh_gui_element" +} + +local misc_timeouts = { + fp_confirm_dialog = 20, + fp_confirm_gui = 20, + fp_refresh_production = 20 +} + +-- ** SPECIAL HANDLERS ** +local special_misc_handlers = {} + +special_misc_handlers.on_gui_opened = (function(event) + -- This should only fire when a UI not associated with FP is opened, so FP's dialogs can close properly + return (event.gui_type ~= defines.gui_type.custom or not event.element or event.element.tags.mod ~= "fp") +end) + + +local misc_event_cache = {} +-- Compile the list of misc handlers +for _, listener in pairs(event_listeners) do + if listener.misc then + for event_name, handler in pairs(listener.misc) do + misc_event_cache[event_name] = misc_event_cache[event_name] or { + registered_handlers = {}, + special_handler = special_misc_handlers[event_name], + timeout = misc_timeouts[event_name] + } + + table.insert(misc_event_cache[event_name].registered_handlers, handler) + end + end +end + + +local function handle_misc_event(event) + local event_name = event.input_name or event.name -- also handles keyboard shortcuts + local string_name = misc_identifier_map[event_name] + local event_handlers = misc_event_cache[string_name] + if not event_handlers then return end -- make sure the given event is even handled + + -- Guard against an event being called before the player is initialized + if not storage.players[event.player_index] then return end + + -- We'll assume every one of the events has a player attached + local player = game.get_player(event.player_index) ---@cast player -nil + + -- Close context menu on any keyboard shortcut + if event.input_name then modal_dialog.close_context_menu(player) end + + -- Check if the action is allowed to be carried out by rate limiting + if util.actions.rate_limited(player, event.tick, event_name, event_handlers.timeout) then return end + + -- If a special handler is set, it needs to return true before proceeding with the registered handlers + local special_handler = event_handlers.special_handler + if special_handler and special_handler(event) == false then return end + + for _, registered_handler in pairs(event_handlers.registered_handlers) do + registered_handler(player, event) -- send actual event + end + + -- Only refresh messages if this event was a keyboard shortcut + if event.input_name then util.messages.refresh(player) end +end + +-- Register all the misc events from the identifier map +for event_id, _ in pairs(misc_identifier_map) do script.on_event(event_id, handle_misc_event) end + + +-- ** GLOBAL HANDLERS ** +-- In some situations, you need to be able to refer to a function indirectly by string name. +-- As functions can't be stored in storage, these need to be collected and stored in a central placem +-- so code that wants to call them knows where to find them. This collects and stores these functions. +for _, listener in pairs(event_listeners) do + if listener.global then + for name, handler in pairs(listener.global) do + GLOBAL_HANDLERS[name] = handler + end + end +end + +-- These are not registered as events, instead just made available to call directly diff --git a/modfiles/ui/main/district_info.lua b/modfiles/ui/main/district_info.lua new file mode 100644 index 000000000..d277c0d8a --- /dev/null +++ b/modfiles/ui/main/district_info.lua @@ -0,0 +1,81 @@ +-- ** LOCAL UTIL ** +local function refresh_district_info(player) + local ui_state = util.globals.ui_state(player) + if ui_state.main_elements.main_frame == nil then return end + + local district = util.context.get(player, "District") --[[@as District]] + local district_info_elements = ui_state.main_elements.district_info + + district_info_elements.name_label.caption = district.name + + if MULTIPLE_PLANETS then + district_info_elements.location_sprite.sprite = district.location_proto.sprite + district_info_elements.location_sprite.tooltip = district.location_proto.tooltip + end + + district_info_elements.districts_button.toggled = ui_state.districts_view +end + +local function build_district_info(player) + local main_elements = util.globals.main_elements(player) + main_elements.district_info = {} + + local parent_flow = main_elements.flows.left_vertical + local frame = parent_flow.add{type="frame", style="inside_shallow_frame"} + frame.style.size = {MAGIC_NUMBERS.list_width, MAGIC_NUMBERS.district_info_height} + local flow_horizontal = frame.add{type="flow", direction="horizontal"} + flow_horizontal.style.padding = {4, 4} + flow_horizontal.style.vertical_align = "center" + + flow_horizontal.add{type="label", caption={"", {"fp.pu_district", 1}, ": "}, style="subheader_caption_label"} + local label_name = flow_horizontal.add{type="label", style="bold_label"} + label_name.style.maximal_width = (MULTIPLE_PLANETS) and 120 or 190 + main_elements.district_info["name_label"] = label_name + + if MULTIPLE_PLANETS then + flow_horizontal.add{type="label", caption={"", {"fp.on"}, ": "}, style="subheader_caption_label"} + local button_sprite = flow_horizontal.add{type="sprite"} + button_sprite.style.size = 24 + button_sprite.style.stretch_image_to_widget_size = true + main_elements.district_info["location_sprite"] = button_sprite + end + + flow_horizontal.add{type="empty-widget", style="flib_horizontal_pusher"} + local button_districts = flow_horizontal.add{type="sprite-button", sprite="fp_panel", + tooltip={"fp.view_districts"}, tags={mod="fp", on_gui_click="toggle_districts_view"}, + style="tool_button", auto_toggle=true, mouse_button_filter={"left"}} + button_districts.style.padding = -4 + main_elements.district_info["districts_button"] = button_districts + + refresh_district_info(player) +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "toggle_districts_view", + handler = (function(player, _, _) + main_dialog.toggle_districts_view(player) + util.raise.refresh(player, "production") + end) + } + } +} + +listeners.misc = { + build_gui_element = (function(player, event) + if event.trigger == "main_dialog" then + build_district_info(player) + end + end), + refresh_gui_element = (function(player, event) + local triggers = {district_info=true, all=true} + if triggers[event.trigger] then refresh_district_info(player) end + end) +} + +return { listeners } diff --git a/modfiles/ui/main/districts_box.lua b/modfiles/ui/main/districts_box.lua new file mode 100644 index 000000000..c1c6dd359 --- /dev/null +++ b/modfiles/ui/main/districts_box.lua @@ -0,0 +1,360 @@ +-- ** LOCAL UTIL ** +local function save_district_name(player, tags, _) + local main_elements = util.globals.main_elements(player) + local district_elements = main_elements.districts_box[tags.district_id] + + local district = OBJECT_INDEX[tags.district_id] --[[@as District]] + district.name = district_elements.name_textfield.text + district_elements.name_label.caption = district.name -- saves the refresh + + district_elements.edit_flow.visible = false + district_elements.name_flow.visible = true + + util.raise.refresh(player, "district_info") +end + +local function change_district_location(player, tags, event) + local district = OBJECT_INDEX[tags.district_id] --[[@as District]] + local location_proto_id = event.element.selected_index + district.location_proto = prototyper.util.find("locations", location_proto_id, nil) --[[@as FPLocationPrototype]] + + for factory in district:iterator() do + factory.top_floor:reset_surface_compatibility() + solver.update(player, factory) + end + util.raise.refresh(player, "all") +end + + +local function handle_item_button_click(player, tags, action) + local item = OBJECT_INDEX[tags.item_id] + + if action == "copy" then -- copy as SimpleItems makes most sense + local copyable_item = {class="SimpleItem", proto=item.proto, amount=item.abs_diff} + util.clipboard.copy(player, copyable_item) + + elseif action == "add_to_cursor" then + util.cursor.handle_item_click(player, item.proto, item.abs_diff) + + elseif action == "factoriopedia" then + local name = (item.proto.temperature) and item.proto.base_name or item.proto.name + player.open_factoriopedia_gui(prototypes[item.proto.type][name]) + end +end + + +local function build_items_flow(player, parent, district) + local items_flow = parent.add{type="flow", direction="horizontal"} + items_flow.style.padding = {6, 12, 12, 12} + + local preferences = util.globals.preferences(player) + local column_count = (preferences.products_per_row * 4) / 2 + + local function build_item_flow(category) + local item_flow = items_flow.add{type="flow", direction="vertical"} + item_flow.add{type="label", caption={"fp.pu_" .. category, 2}, style="caption_label"} + + local item_frame = item_flow.add{type="frame", style="slot_button_deep_frame"} + item_frame.style.width = column_count * MAGIC_NUMBERS.item_button_size + item_frame.style.minimal_height = MAGIC_NUMBERS.item_button_size + local table_items = item_frame.add{type="table", column_count=column_count, style="filter_slot_table"} + + return table_items + end + + items_flow.add{type="empty-widget", style="flib_horizontal_pusher"} + local prod_table = build_item_flow("product") + items_flow.add{type="empty-widget", style="flib_horizontal_pusher"} + items_flow.add{type="empty-widget", style="flib_horizontal_pusher"} + local ingr_table = build_item_flow("ingredient") + items_flow.add{type="empty-widget", style="flib_horizontal_pusher"} + + local action_tooltip = MODIFIER_ACTIONS["act_on_district_item"].tooltip + local tooltips = util.globals.ui_state(player).tooltips + + local color_map = { + production = {half="flib_slot_button_cyan", full="flib_slot_button_blue"}, + consumption = {half="flib_slot_button_yellow", full="flib_slot_button_red"} + } + + for item in district.item_set:iterator() do + local diff_string, amount_tooltip = item_views.process_item(player, item, item.abs_diff, nil) + + local total_amount = item[item.overall].amount + local total_string, total_tooltip = item_views.process_item(player, item, total_amount, nil) + + local title_line = {"fp.tt_title", item.proto.localised_name} + local diff_line = {"fp.item_amount_" .. item.overall, amount_tooltip} + local total_line = {"fp.item_amount_total", total_tooltip} + local tooltip = {"", title_line, diff_line, total_line, "\n", action_tooltip} + + local colors = color_map[item.overall] + local style = (item.abs_diff ~= total_amount) and colors.half or colors.full + + local relevant_table = (item.overall == "production") and prod_table or ingr_table + local button = relevant_table.add{type="sprite-button", number=diff_string, style=style, + sprite=item.proto.sprite, tags={mod="fp", on_gui_click="act_on_district_item", + item_id=item.id, on_gui_hover="set_tooltip", context="districts_box"}, + raise_hover_events=true, mouse_button_filter={"left-and-right"}} + tooltips.districts_box[button.index] = tooltip + end + + local max_count = math.max(#prod_table.children, #ingr_table.children) + local height = math.ceil(max_count / column_count) * MAGIC_NUMBERS.item_button_size + prod_table.style.height = height; ingr_table.style.height = height +end + +local function build_district_frame(player, district, location_items) + district:refresh() -- refreshes its data if necessary + + local elements = util.globals.main_elements(player).districts_box + elements[district.id] = {} + + local window_frame = elements.main_flow.add{type="frame", direction="vertical", style="inside_shallow_frame"} + local subheader = window_frame.add{type="frame", direction="horizontal", style="subheader_frame"} + subheader.style.top_padding = 6 + + -- Interaction buttons + local function create_move_button(flow, direction) + local enabled = (direction == "next" and district.next ~= nil) or + (direction == "previous" and district.previous ~= nil) + local up_down = (direction == "next") and "down" or "up" + local tooltip = {"", {"fp.move_object", {"fp.pl_district", 1}, {"fp." .. up_down}}} + local move_button = flow.add{type="sprite-button", enabled=enabled, sprite="fp_arrow_" .. up_down, + tags={mod="fp", on_gui_click="move_district", direction=direction, district_id=district.id}, + style="fp_sprite-button_move", tooltip=tooltip, mouse_button_filter={"left"}} + move_button.style.size = {18, 14} + move_button.style.padding = -1 + end + + local move_flow = subheader.add{type="flow", direction="vertical"} + move_flow.style.vertical_spacing = 0 + move_flow.style.left_margin = 2 + create_move_button(move_flow, "previous") + create_move_button(move_flow, "next") + + local selected = util.context.get(player, "District").id == district.id + local selection_caption = (selected) and {"fp.u_selected"} or {"fp.u_select"} + local select_button = subheader.add{type="button", caption=selection_caption, style="list_box_item", + tags={mod="fp", on_gui_click="select_district", district_id=district.id}, + enabled=(not selected), mouse_button_filter={"left"}} + select_button.style.font = "default-bold" + select_button.style.width = 72 + select_button.style.padding = {0, 4} + select_button.style.horizontal_align = "center" + + -- Name + subheader.add{type="label", caption={"", {"fp.name"}, ": "}, style="subheader_caption_label"} + + local flow_name = subheader.add{type="flow", direction="horizontal"} + flow_name.style.vertical_align = "center" + elements[district.id]["name_flow"] = flow_name + local label_name = flow_name.add{type="label", caption=district.name, style="bold_label"} + elements[district.id]["name_label"] = label_name + flow_name.add{type="sprite-button", style="mini_button_aligned_to_text_vertically_when_centered", + tags={mod="fp", on_gui_click="edit_district_name", district_id=district.id}, sprite="utility/rename_icon", + tooltip={"fp.edit_name"}, mouse_button_filter={"left"}} + + local flow_edit = subheader.add{type="flow", direction="horizontal", visible=false} + flow_edit.style.vertical_align = "center" + elements[district.id]["edit_flow"] = flow_edit + local textfield_name = flow_edit.add{type="textfield", text=district.name, icon_selector=true, + tags={mod="fp", on_gui_confirmed="confirm_district_name", district_id=district.id}} + textfield_name.style.width = 160 + elements[district.id]["name_textfield"] = textfield_name + flow_edit.add{type="sprite-button", style="mini_button_aligned_to_text_vertically_when_centered", + tags={mod="fp", on_gui_click="save_district_name", district_id=district.id}, sprite="utility/rename_icon", + tooltip={"fp.save_name"}, mouse_button_filter={"left"}} + + -- Location + if MULTIPLE_PLANETS then + local label_location = subheader.add{type="label", caption={"", {"fp.pu_location", 1}, ": "}, + tooltip={"fp.location_tt"}, style="subheader_caption_label"} + label_location.style.left_margin = 8 + -- Using the location id for the index works because the location prototypes are in id order + subheader.add{type="drop-down", items=location_items, selected_index=district.location_proto.id, + tags={mod="fp", on_gui_selection_state_changed="change_district_location", district_id=district.id}} + end + + -- Power & Pollution + local label_power = subheader.add{type="label", caption=util.format.SI_value(district.power, "W", 3), + style="bold_label", tooltip={"", {"fp.u_power"}, ": ", util.format.SI_value(district.power, "W", 5)}} + label_power.style.left_margin = 24 + subheader.add{type="label", caption="|"} + subheader.add{type="label", caption=util.format.SI_value(district.emissions, "E/m", 3), style="bold_label", + tooltip=util.gui.format_emissions(district.emissions, district)} + + -- Item toggle + subheader.add{type="empty-widget", style="flib_horizontal_pusher"} + local sprite = (district.collapsed) and "fp_expand" or "fp_collapse" + local items_toggle = subheader.add{type="sprite-button", sprite=sprite, + tags={mod="fp", on_gui_click="toggle_district_items", district_id=district.id}, + style="tool_button", tooltip={"fp.toggle_district_items_tt"}, mouse_button_filter={"left"}} + + -- Delete button + local delete_toggle = subheader.add{type="sprite-button", sprite="utility/trash", style="tool_button_red", + tags={mod="fp", on_gui_click="delete_district_toggle", district_id=district.id}, + enabled=(district.parent:count() > 1), mouse_button_filter={"left"}} + elements[district.id]["delete_toggle"] = delete_toggle + local delete_confirm = subheader.add{type="sprite-button", sprite="utility/check_mark", + tags={mod="fp", on_gui_click="delete_district_confirm", district_id=district.id}, + style="flib_tool_button_light_green", visible=false, mouse_button_filter={"left"}} + delete_confirm.style.padding = 0 + elements[district.id]["delete_confirm"] = delete_confirm + + if not district.collapsed then + build_items_flow(player, window_frame, district) + end +end + +local function refresh_districts_box(player) + local player_table = util.globals.player_table(player) + + local main_elements = player_table.ui_state.main_elements + if main_elements.main_frame == nil then return end + + local visible = player_table.ui_state.districts_view + local main_flow = main_elements.districts_box.main_flow + main_flow.parent.visible = visible + if not visible then return end + + main_flow.clear() + local location_items = {} + for _, proto in pairs(storage.prototypes.locations) do + table.insert(location_items, {"", "[img=" .. proto.sprite .. "] ", proto.localised_name}) + end + + util.globals.ui_state(player).tooltips.districts_box = {} + for district in player_table.realm:iterator() do + build_district_frame(player, district, location_items) + end +end + +local function build_districts_box(player) + local main_elements = util.globals.main_elements(player) + main_elements.districts_box = {} + + local parent_flow = main_elements.flows.right_vertical + local scroll_pane = parent_flow.add{type="scroll-pane", style="flib_naked_scroll_pane_no_padding"} + scroll_pane.style.top_margin = -2 + scroll_pane.style.extra_right_margin_when_activated = -12 + local flow_vertical = scroll_pane.add{type="flow", direction="vertical"} + flow_vertical.style.vertical_spacing = MAGIC_NUMBERS.frame_spacing + main_elements.districts_box["main_flow"] = flow_vertical + + refresh_districts_box(player) +end + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "move_district", + timeout = 10, + handler = (function(player, tags, event) + local district = OBJECT_INDEX[tags.district_id] --[[@as District]] + local spots_to_shift = (event.control) and 5 or ((not event.shift) and 1 or nil) + district.parent:shift(district, tags.direction, spots_to_shift) + + util.raise.refresh(player, "districts_box") + end) + }, + { + name = "select_district", + handler = (function(player, tags, _) + local selected_district = OBJECT_INDEX[tags.district_id] --[[@as District]] + util.context.set(player, selected_district) + main_dialog.toggle_districts_view(player) + util.raise.refresh(player, "all") + end) + }, + { + name = "edit_district_name", + handler = (function(player, tags, _) + local main_elements = util.globals.main_elements(player) + local district_elements = main_elements.districts_box[tags.district_id] + district_elements.name_flow.visible = false + district_elements.edit_flow.visible = true + end) + }, + { + name = "save_district_name", + handler = save_district_name + }, + { + name = "toggle_district_items", + handler = (function(player, tags, _) + local district = OBJECT_INDEX[tags.district_id] --[[@as District]] + district.collapsed = not district.collapsed + + util.raise.refresh(player, "districts_box") + end) + }, + { + name = "delete_district_toggle", + handler = (function(player, tags, _) + local district = OBJECT_INDEX[tags.district_id] --[[@as District]] + + local main_elements = util.globals.main_elements(player) + local district_elements = main_elements.districts_box[tags.district_id] + district_elements.delete_toggle.visible = false + district_elements.delete_confirm.visible = true + end) + }, + { + name = "delete_district_confirm", + handler = (function(player, tags, _) + local district = OBJECT_INDEX[tags.district_id] --[[@as District]] + + local main_elements = util.globals.main_elements(player) + local district_elements = main_elements.districts_box[tags.district_id] + district_elements.delete_toggle.visible = true + district_elements.delete_confirm.visible = false + + -- Removal will always find an alterantive because there always exists at least one District + local adjacent_district = util.context.remove(player, district) --[[@as District]] + district.parent:remove(district) + + util.context.set(player, adjacent_district) + util.raise.refresh(player, "all") + end) + }, + { + name = "act_on_district_item", + actions_table = { + copy = {shortcut="shift-right"}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_item_button_click + }, + }, + on_gui_confirmed = { + { + name = "confirm_district_name", + handler = save_district_name + } + }, + on_gui_selection_state_changed = { + { + name = "change_district_location", + handler = change_district_location + } + }, +} + +listeners.misc = { + build_gui_element = (function(player, event) + if event.trigger == "main_dialog" then + build_districts_box(player) + end + end), + refresh_gui_element = (function(player, event) + local triggers = {districts_box=true, production=true, factory=true, all=true} + if triggers[event.trigger] then refresh_districts_box(player) end + end) +} + +return { listeners } diff --git a/modfiles/ui/main/factory_list.lua b/modfiles/ui/main/factory_list.lua new file mode 100644 index 000000000..68da0869c --- /dev/null +++ b/modfiles/ui/main/factory_list.lua @@ -0,0 +1,396 @@ +local Factory = require("backend.data.Factory") + +-- Delete factory for good and refresh interface if necessary +local function delete_factory_for_good(metadata) + local player = game.get_player(metadata.player_index) ---@cast player -nil + local factory = OBJECT_INDEX[metadata.factory_id] --[[@as Factory]] + local adjacent_factory = util.context.remove(player, factory) + + local selected_factory = util.context.get(player, "Factory") --[[@as Factory?]] + if selected_factory and selected_factory.id == factory.id then + util.context.set(player, adjacent_factory or factory.parent) + end + factory.parent:remove(factory) + + if not main_dialog.is_in_focus(player) then return end + -- Refresh all if the archive is currently open + if selected_factory and selected_factory.archived == true then + util.raise.refresh(player, "all") + else -- only need to refresh the archive button enabled state really + util.raise.refresh(player, "factory_list") + end +end + + +local function change_factory_archived(player, to_archive) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + + if to_archive or factory.parent:count({archived=true}) > 1 then + local adjacent_factory = util.context.remove(player, factory) + util.context.set(player, adjacent_factory or factory.parent, true) + end -- if it's pulling the last factory from the archive, keep the context on it + + factory.archived = to_archive + factory.parent:shift(factory, "next", nil) -- shift to end + factory.parent.needs_refresh = true + + -- Reset deletion if a deleted factory is un-archived + if not to_archive and factory.tick_of_deletion then + util.nth_tick.cancel(factory.tick_of_deletion) + factory.tick_of_deletion = nil + end + + util.raise.refresh(player, "all") +end + +local function add_factory(player, _, event) + local skip_factory_naming = util.globals.preferences(player).skip_factory_naming + + if util.xor(event.shift, skip_factory_naming) then -- go right to the item picker with automatic factory naming + util.raise.open_dialog(player, {dialog="picker", modal_data={item_id=nil, item_category="product", + create_factory=true}}) + else -- otherwise, have the user pick a factory name first + util.raise.open_dialog(player, {dialog="factory", modal_data={factory_id=nil}}) + end +end + +local function duplicate_factory(player, _, event) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local clone = factory:clone() + clone.archived = false -- always clone as unarchived + local pivot = (event.shift and not factory.archived) and factory or nil + factory.parent:insert(clone, pivot, "next") + + solver.update(player, clone) + main_dialog.toggle_districts_view(player, true) + util.context.set(player, clone) + util.raise.refresh(player, "all") +end + + +local function handle_move_factory_click(player, tags, event) + local factory = OBJECT_INDEX[tags.factory_id] --[[@as Factory]] + local spots_to_shift = (event.control) and 5 or ((not event.shift) and 1 or nil) + factory.parent:shift(factory, tags.direction, spots_to_shift) + + util.raise.refresh(player, "factory_list") +end + +local function handle_factory_click(player, tags, action) + local selected_factory = OBJECT_INDEX[tags.factory_id] --[[@as Factory]] + + if action == "select" then + local ui_state = util.globals.ui_state(player) + if ui_state.recalculate_on_factory_change then + -- This flag is set when a textfield is changed but not confirmed + ui_state.recalculate_on_factory_change = false + local previous_factory = util.context.get(player, "Factory") + solver.update(player, previous_factory) + end + + main_dialog.toggle_districts_view(player, true) + util.context.set(player, selected_factory) + util.raise.refresh(player, "all") -- refresh to update the selected factory + + elseif action == "edit" then + util.context.set(player, selected_factory) + util.raise.refresh(player, "all") -- refresh to update the selected factory + + util.raise.open_dialog(player, {dialog="factory", modal_data={factory_id=selected_factory.id}}) + + elseif action == "delete" then + util.context.set(player, selected_factory) + factory_list.delete_factory(player) + end +end + + +local function refresh_factory_list(player) + local player_table = util.globals.player_table(player) + local tooltips = player_table.ui_state.tooltips + tooltips.factory_list = {} + + local main_elements = player_table.ui_state.main_elements + if main_elements.main_frame == nil then return end + + local selected_factory = util.context.get(player, "Factory") --[[@as Factory?]] + local archived = (selected_factory) and selected_factory.archived or false + + local factory_list_elements = main_elements.factory_list + local listbox = factory_list_elements.factory_listbox + listbox.clear() + + if selected_factory ~= nil then -- only need to run this if any factory exists + local attach_factory_products = player_table.preferences.attach_factory_products + local filter = {archived = archived} + + local function create_move_button(flow, direction, factory) + local enabled = (factory.parent:find(filter, factory[direction], direction) ~= nil) + local endpoint = (direction == "next") and {"fp.bottom"} or {"fp.top"} + local up_down = (direction == "next") and "down" or "up" + local move_tooltip = (enabled) and {"", {"fp.move_object", {"fp.pl_factory", 1}, {"fp." .. up_down}}, + {"fp.move_object_instructions", endpoint}} or "" + + local move_button = flow.add{type="sprite-button", enabled=enabled, sprite="fp_arrow_" .. up_down, + tags={mod="fp", on_gui_click="move_factory", direction=direction, factory_id=factory.id, + on_gui_hover="set_tooltip", context="factory_list"}, mouse_button_filter={"left"}, + raise_hover_events=true, style="fp_sprite-button_move"} + move_button.style.size = {20, 12} + move_button.style.padding = -2 + tooltips.factory_list[move_button.index] = move_tooltip + end + + for factory in selected_factory.parent:iterator(filter) do + local selected = (selected_factory.id == factory.id) + local caption, info_tooltip = factory:tostring(attach_factory_products, false) + local tooltip = {"", info_tooltip, "\n", MODIFIER_ACTIONS["act_on_factory"].tooltip} + + local button_flow = listbox.add{type="flow", direction="horizontal"} + button_flow.style.horizontal_spacing = 0 + + local move_flow = button_flow.add{type="flow", direction="vertical"} + move_flow.style.vertical_spacing = 0 + move_flow.style.padding = {2, 0} + create_move_button(move_flow, "previous", factory) + create_move_button(move_flow, "next", factory) + + local factory_button = button_flow.add{type="button", caption=caption, toggled=selected, + tags={mod="fp", on_gui_click="act_on_factory", factory_id=factory.id, on_gui_hover="set_tooltip", + context="factory_list"}, style="list_box_item", mouse_button_filter={"left-and-right"}, + raise_hover_events=true} + factory_button.style.padding = {0, 12, 0, 4} + factory_button.style.width = MAGIC_NUMBERS.list_width - 20 + tooltips.factory_list[factory_button.index] = tooltip + end + end + + -- Set all the button states and styles appropriately + local factory_exists = (selected_factory ~= nil) + local district = util.context.get(player, "District") --[[@as District]] + local archived_factory_count = district:count({archived=true}) + + factory_list_elements.toggle_archive_button.enabled = (archived_factory_count > 0) + factory_list_elements.toggle_archive_button.style = (archived) + and "flib_selected_tool_button" or "tool_button" + + if not archived then + local factory_plural = {"fp.pl_factory", archived_factory_count} + local archive_tooltip = {"fp.action_open_archive_tt", (archived_factory_count > 0) + and {"fp.archive_filled", archived_factory_count, factory_plural} or {"fp.archive_empty"}} + factory_list_elements.toggle_archive_button.tooltip = archive_tooltip + else + factory_list_elements.toggle_archive_button.tooltip = {"fp.action_close_archive_tt"} + end + + factory_list_elements.archive_button.enabled = (factory_exists) + factory_list_elements.archive_button.sprite = (archived) + and "utility/export_slot" or "utility/import_slot" + factory_list_elements.archive_button.tooltip = (archived) + and {"fp.action_unarchive_factory"} or {"fp.action_archive_factory"} + + factory_list_elements.import_button.enabled = (not archived) + factory_list_elements.export_button.enabled = (factory_exists) + + local skip_factory_naming = util.globals.preferences(player).skip_factory_naming + factory_list_elements.add_button.enabled = (not archived) + factory_list_elements.add_button.tooltip = (skip_factory_naming) + and {"fp.action_add_factory_by_product"} or {"fp.action_add_factory_by_name"} + + factory_list_elements.edit_button.enabled = (factory_exists) + factory_list_elements.duplicate_button.enabled = (selected_factory ~= nil and selected_factory.valid) + + factory_list_elements.delete_button.enabled = (factory_exists) + local delay_in_minutes = math.floor(MAGIC_NUMBERS.factory_deletion_delay / 3600) + factory_list_elements.delete_button.tooltip = (archived) + and {"fp.action_delete_factory"} or {"fp.action_trash_factory", delay_in_minutes} +end + +local function build_factory_list(player) + local main_elements = util.globals.main_elements(player) + main_elements.factory_list = {} + + local parent_flow = main_elements.flows.left_vertical + local frame_vertical = parent_flow.add{type="frame", direction="vertical", style="inside_deep_frame"} + local row_count = util.globals.preferences(player).factory_list_rows + frame_vertical.style.height = MAGIC_NUMBERS.subheader_height + (row_count * MAGIC_NUMBERS.list_element_height) + + local subheader = frame_vertical.add{type="frame", direction="horizontal", style="subheader_frame"} + + local button_toggle_archive = subheader.add{type="sprite-button", tags={mod="fp", on_gui_click="toggle_archive"}, + sprite="fp_archive", mouse_button_filter={"left"}} + main_elements.factory_list["toggle_archive_button"] = button_toggle_archive + + local button_archive = subheader.add{type="sprite-button", tags={mod="fp", on_gui_click="archive_factory"}, + style="tool_button", mouse_button_filter={"left"}} + main_elements.factory_list["archive_button"] = button_archive + + subheader.add{type="empty-widget", style="flib_horizontal_pusher"} + + local button_import = subheader.add{type="sprite-button", sprite="utility/import", + tooltip={"fp.action_import_factory"}, style="tool_button", mouse_button_filter={"left"}, + tags={mod="fp", on_gui_click="factory_list_open_dialog", type="import"}} + main_elements.factory_list["import_button"] = button_import + + local button_export = subheader.add{type="sprite-button", sprite="utility/export", + tooltip={"fp.action_export_factory"}, style="tool_button", mouse_button_filter={"left"}, + tags={mod="fp", on_gui_click="factory_list_open_dialog", type="export"}} + main_elements.factory_list["export_button"] = button_export + + subheader.add{type="empty-widget", style="flib_horizontal_pusher"} + + local button_add = subheader.add{type="sprite-button", tags={mod="fp", on_gui_click="add_factory"}, + sprite="utility/add", style="flib_tool_button_light_green", mouse_button_filter={"left"}} + button_add.style.padding = 1 + main_elements.factory_list["add_button"] = button_add + + local button_edit = subheader.add{type="sprite-button", tags={mod="fp", on_gui_click="edit_factory"}, + sprite="utility/rename_icon", tooltip={"fp.action_edit_factory"}, style="tool_button", + mouse_button_filter={"left"}} + main_elements.factory_list["edit_button"] = button_edit + + local button_duplicate = subheader.add{type="sprite-button", tags={mod="fp", on_gui_click="duplicate_factory"}, + sprite="utility/clone", tooltip={"fp.action_duplicate_factory"}, style="tool_button", + mouse_button_filter={"left"}} + main_elements.factory_list["duplicate_button"] = button_duplicate + + local button_delete = subheader.add{type="sprite-button", tags={mod="fp", on_gui_click="delete_factory"}, + sprite="utility/trash", style="tool_button_red", mouse_button_filter={"left"}} + main_elements.factory_list["delete_button"] = button_delete + + -- This is not really a list-box, but it imitates one and allows additional features + local listbox_factories = frame_vertical.add{type="scroll-pane", style="list_box_under_subheader_scroll_pane"} + listbox_factories.style.vertically_stretchable = true + listbox_factories.style.extra_right_padding_when_activated = -12 + local flow_factories = listbox_factories.add{type="flow", direction="vertical"} + flow_factories.style.vertical_spacing = 0 + main_elements.factory_list["factory_listbox"] = flow_factories + + refresh_factory_list(player) +end + + +-- ** TOP LEVEL ** +factory_list = {} -- try to move elsewhere or smth to get rid of global variable + +-- Utility function to centralize factory creation behavior +function factory_list.add_factory(player, name) + local preferences = util.globals.preferences(player) + local factory = Factory.init(name) + if preferences.prefer_matrix_solver then factory.matrix_free_items = {} end + + local district = util.context.get(player, "District") --[[@as District]] + district:insert(factory) + + util.context.set(player, factory) + + return factory +end + +-- Utility function to centralize factory deletion behavior +function factory_list.delete_factory(player) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + if not factory then return end -- latency protection + + if factory.archived then + local adjacent_factory = util.context.remove(player, factory) + local district = factory.parent + factory.parent:remove(factory) + + util.context.set(player, adjacent_factory or district) + util.raise.refresh(player, "all") + else + local desired_tick_of_deletion = game.tick + MAGIC_NUMBERS.factory_deletion_delay + local actual_tick_of_deletion = util.nth_tick.register(desired_tick_of_deletion, + "delete_factory_for_good", {player_index=player.index, factory_id=factory.id}) + factory.tick_of_deletion = actual_tick_of_deletion + + change_factory_archived(player, true) + end +end + + +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "toggle_archive", + handler = (function(player, _, _) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local archive_open = (factory) and factory.archived or false + local district = (factory) and factory.parent or util.context.get(player, "District") + local new_factory = district:find({archived=not archive_open}) --[[@as Factory]] + + main_dialog.toggle_districts_view(player, true) + util.context.set(player, new_factory or district, true) + util.raise.refresh(player, "all") + end) + }, + { + name = "archive_factory", + timeout = 10, + handler = (function(player, _, _) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + change_factory_archived(player, (not factory.archived)) + end) + }, + { -- import/export buttons + name = "factory_list_open_dialog", + handler = (function(player, tags, _) + util.raise.open_dialog(player, {dialog=tags.type}) + end) + }, + { + name = "add_factory", + handler = add_factory + }, + { + name = "edit_factory", + handler = (function(player, _, _) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + util.raise.open_dialog(player, {dialog="factory", modal_data={factory_id=factory.id}}) + end) + }, + { + name = "duplicate_factory", + handler = duplicate_factory + }, + { + name = "delete_factory", + timeout = 10, + handler = factory_list.delete_factory + }, + { + name = "move_factory", + timeout = 10, + handler = handle_move_factory_click + }, + { + name = "act_on_factory", + actions_table = { + select = {shortcut="left", limitations={}}, + edit = {shortcut="control-left"}, + delete = {shortcut="control-right"} + }, + handler = handle_factory_click + } + } +} + +listeners.misc = { + build_gui_element = (function(player, event) + if event.trigger == "main_dialog" then + build_factory_list(player) + end + end), + refresh_gui_element = (function(player, event) + local triggers = {factory_list=true, all=true} + if triggers[event.trigger] then refresh_factory_list(player) end + end) +} + +listeners.global = { + delete_factory_for_good = delete_factory_for_good +} + +return { listeners } diff --git a/modfiles/ui/main/item_boxes.lua b/modfiles/ui/main/item_boxes.lua new file mode 100644 index 000000000..cbeaf9729 --- /dev/null +++ b/modfiles/ui/main/item_boxes.lua @@ -0,0 +1,334 @@ +local Product = require("backend.data.Product") + +-- ** LOCAL UTIL ** +local function build_item_box(player, category, column_count) + local item_boxes_elements = util.globals.main_elements(player).item_boxes + + local window_frame = item_boxes_elements.horizontal_flow.add{type="frame", direction="vertical", + style="inside_shallow_frame"} + window_frame.style.top_padding = 6 + window_frame.style.padding = {4, 12, 12, 12} + + local title_flow = window_frame.add{type="flow", direction="horizontal"} + title_flow.style.vertical_align = "center" + + local label = title_flow.add{type="label", caption={"fp.pu_" .. category, 2}, style="caption_label"} + label.style.bottom_margin = 8 + + if category == "ingredient" then + local button_combinator = title_flow.add{type="sprite-button", sprite="item/constant-combinator", + tooltip={"fp.ingredients_to_combinator_tt"}, tags={mod="fp", on_gui_click="ingredients_to_combinator"}, + visible=false, mouse_button_filter={"left"}} + button_combinator.style.size = 24 + button_combinator.style.padding = -2 + button_combinator.style.left_margin = 4 + item_boxes_elements["ingredient_combinator_button"] = button_combinator + end + + local scroll_pane = window_frame.add{type="scroll-pane", style="shallow_scroll_pane"} + scroll_pane.style.maximal_height = MAGIC_NUMBERS.item_box_max_rows * MAGIC_NUMBERS.item_button_size + + local item_frame = scroll_pane.add{type="frame", style="slot_button_deep_frame"} + item_frame.style.width = column_count * MAGIC_NUMBERS.item_button_size + + local table_items = item_frame.add{type="table", column_count=column_count, style="filter_slot_table"} + item_boxes_elements[category .. "_item_table"] = table_items +end + +local function refresh_item_box(player, factory, show_floor_items, item_category, tooltips) + local item_boxes_elements = util.globals.main_elements(player).item_boxes + + local table_items = item_boxes_elements[item_category .. "_item_table"] + table_items.clear() + + if factory == nil or not factory.valid then + item_boxes_elements["ingredient_combinator_button"].visible = false + return 0 + end + + local floor = (show_floor_items) and util.context.get(player, "Floor") or factory.top_floor + local action = (item_category == "product" and show_floor_items and floor.level > 1) + and "act_on_floor_product" or ("act_on_top_level_" .. item_category) + local real_products = (item_category == "product" and (not show_floor_items or floor.level == 1)) + local action_tooltip = MODIFIER_ACTIONS[action].tooltip + + local table_item_count = 0 + local default_style = (item_category == "byproduct") and "flib_slot_button_red" or "flib_slot_button_default" + + local function build_item(item, index) + local required_amount = (item.class == "Product") and item:get_required_amount() or nil + local amount, number_tooltip = item_views.process_item(player, item, required_amount, nil) + if amount == -1 then return end -- an amount of -1 means it was below the margin of error + + local style = default_style + local satisfaction_line = "" ---@type LocalisedString + if item.class == "Product" and amount ~= nil and amount ~= "0" then + local satisfied_percentage = (item.amount / required_amount) * 100 + local percentage_string = util.format.number(satisfied_percentage, 3) + satisfaction_line = {"", "\n", {"fp.bold_label", (percentage_string .. "%")}, " ", {"fp.satisfied"}} + + if percentage_string == "0" then style = "flib_slot_button_red" + elseif percentage_string == "100" then style = "flib_slot_button_green" + else style = "flib_slot_button_yellow" end + elseif item.proto.type == "entity" then + style = "flib_slot_button_transparent" + end + + local name_line = {"fp.tt_title", item.proto.localised_name} + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + local tooltip = {"", name_line, number_line, satisfaction_line, "\n", action_tooltip} + + local button = table_items.add{type="sprite-button", number=amount, style=style, sprite=item.proto.sprite, + tags={mod="fp", on_gui_click=action, item_category=item_category, item_id=item.id, item_index=index, + on_gui_hover="set_tooltip", context="item_boxes"}, mouse_button_filter={"left-and-right"}, + raise_hover_events=true} + tooltips.item_boxes[button.index] = tooltip + table_item_count = table_item_count + 1 + end + + if real_products then + for product in factory:iterator() do + build_item(product, nil) + end + else + for index, item in pairs(floor[item_category .. "s"]) do + build_item(item, index) + end + end + + if real_products then -- meaning allow the user to add items of this type + local button = table_items.add{type="sprite-button", sprite="utility/add", enabled=(not factory.archived), + tags={mod="fp", on_gui_click="add_top_level_item", item_category=item_category}, + tooltip={"", {"fp.add"}, " ", {"fp.pl_" .. item_category, 1}, "\n", {"fp.shift_to_paste"}}, + style="fp_sprite-button_inset", mouse_button_filter={"left"}} + button.style.padding = 4 + button.style.margin = 4 + table_item_count = table_item_count + 1 + end + + if item_category == "ingredient" then + item_boxes_elements["ingredient_combinator_button"].visible = (table_item_count > 0) + end + + local table_rows_required = math.ceil(table_item_count / table_items.column_count) + return table_rows_required +end + + +local function handle_item_add(player, tags, event) + if event.shift then -- paste + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local dummy_product = Product.init({}) + util.clipboard.dummy_paste(player, dummy_product, factory) + else + util.raise.open_dialog(player, {dialog="picker", modal_data={item_id=nil, item_category=tags.item_category}}) + end +end + +local function handle_item_button_click(player, tags, action) + local show_floor_items = util.globals.preferences(player).show_floor_items + + local item = nil + if tags.item_id then + item = OBJECT_INDEX[tags.item_id] + else + -- Need to get items from the right floor depending on display settings + local floor = (show_floor_items) and util.context.get(player, "Floor") + or util.context.get(player, "Factory").top_floor + item = floor[tags.item_category .. "s"][tags.item_index] + end + + if action == "add_recipe" then + local floor = util.context.get(player, "Floor") --[[@as Floor]] + if floor.level > 1 and not show_floor_items then + local message = {"fp.error_no_main_recipe_on_subfloor"} + util.messages.raise(player, "error", message, 1) + else + local production_type = (tags.item_category == "byproduct") and "consume" or "produce" + util.raise.open_dialog(player, {dialog="recipe", modal_data={production_type=production_type, + category_id=item.proto.category_id, product_id=item.proto.id}}) + end + + elseif action == "edit" then + util.raise.open_dialog(player, {dialog="picker", + modal_data={item_id=item.id, item_category=tags.item_category}}) + + elseif action == "move_left" or action == "move_right" then + local direction = (action == "move_left") and "previous" or "next" + item.parent:shift(item, direction, 1) + util.raise.refresh(player, "item_boxes") + + elseif action == "copy" then + local copyable_item = {class="SimpleItem", proto=item.proto, amount=item.amount} + util.clipboard.copy(player, copyable_item) + + elseif action == "paste" then + util.clipboard.paste(player, item) + + elseif action == "delete" then + local factory = util.context.get(player, "Factory") --[[@as Factory]] + factory:remove(item) + solver.update(player, factory) + util.raise.refresh(player, "all") -- make sure product icons are updated + + elseif action == "add_to_cursor" then + local amount = (item.class == "Product") and item:get_required_amount() or item.amount + util.cursor.handle_item_click(player, item.proto, amount) + + elseif action == "factoriopedia" then + local name = (item.proto.temperature) and item.proto.base_name or item.proto.name + player.open_factoriopedia_gui(prototypes[item.proto.type][name]) + end +end + + +local function put_ingredients_into_cursor(player, _, _) + local preferences = util.globals.preferences(player) + local relevant_floor = (preferences.show_floor_items) and util.context.get(player, "Floor") + or util.context.get(player, "Factory").top_floor --[[@as Floor]] + + local ingredient_filters = {} + for _, ingredient in pairs(relevant_floor.ingredients) do + local amount = ingredient.amount * preferences.timescale + if amount > MAGIC_NUMBERS.margin_of_error then + table.insert(ingredient_filters, { + type = ingredient.proto.type, + name = ingredient.proto.name, + quality = "normal", + comparator = "=", + count = math.ceil(amount) + }) + end + end + util.cursor.set_item_combinator(player, ingredient_filters) + + main_dialog.toggle(player) +end + + +local function refresh_item_boxes(player) + local player_table = util.globals.player_table(player) + + local main_elements = player_table.ui_state.main_elements + if main_elements.main_frame == nil then return end + + local visible = not player_table.ui_state.districts_view + main_elements.item_boxes.horizontal_flow.visible = visible + if not visible then return end + + local factory = util.context.get(player, "Factory") --[[@as Factory?]] + local show_floor_items = player_table.preferences.show_floor_items + + local tooltips = player_table.ui_state.tooltips + tooltips.item_boxes = {} + + local prow_count = refresh_item_box(player, factory, show_floor_items, "product", tooltips) + local brow_count = refresh_item_box(player, factory, show_floor_items, "byproduct", tooltips) + local irow_count = refresh_item_box(player, factory, show_floor_items, "ingredient", tooltips) + + local maxrow_count = math.max(prow_count, math.max(brow_count, irow_count)) + local actual_row_count = math.min(math.max(maxrow_count, 1), MAGIC_NUMBERS.item_box_max_rows) + local item_table_height = actual_row_count * MAGIC_NUMBERS.item_button_size + + -- Set the heights for both the visible frame and the scroll pane containing it + local item_boxes_elements = main_elements.item_boxes + item_boxes_elements.product_item_table.parent.style.minimal_height = item_table_height + item_boxes_elements.product_item_table.parent.parent.style.minimal_height = item_table_height + item_boxes_elements.byproduct_item_table.parent.style.minimal_height = item_table_height + item_boxes_elements.byproduct_item_table.parent.parent.style.minimal_height = item_table_height + item_boxes_elements.ingredient_item_table.parent.style.minimal_height = item_table_height + item_boxes_elements.ingredient_item_table.parent.parent.style.minimal_height = item_table_height +end + +local function build_item_boxes(player) + local main_elements = util.globals.main_elements(player) + main_elements.item_boxes = {} + + local parent_flow = main_elements.flows.right_vertical + local flow_horizontal = parent_flow.add{type="flow", direction="horizontal"} + flow_horizontal.style.horizontal_spacing = MAGIC_NUMBERS.frame_spacing + main_elements.item_boxes["horizontal_flow"] = flow_horizontal + + local products_per_row = util.globals.preferences(player).products_per_row + build_item_box(player, "product", products_per_row) + build_item_box(player, "byproduct", products_per_row) + build_item_box(player, "ingredient", products_per_row * 2) + + refresh_item_boxes(player) +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "add_top_level_item", + handler = handle_item_add + }, + { + name = "act_on_top_level_product", + actions_table = { + add_recipe = {shortcut="left", limitations={archive_open=false}, show=true}, + edit = {shortcut="control-left", limitations={archive_open=false}, show=true}, + delete = {shortcut="control-right", limitations={archive_open=false}}, + move_left = {limitations={archive_open=false}}, + move_right = {limitations={archive_open=false}}, + copy = {shortcut="shift-right"}, + paste = {shortcut="shift-left", limitations={archive_open=false}}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_item_button_click + }, + { + name = "act_on_top_level_byproduct", + actions_table = { + add_recipe = {shortcut="left", limitations={archive_open=false, matrix_active=true}, show=true}, + copy = {shortcut="shift-right"}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_item_button_click + }, + { + name = "act_on_top_level_ingredient", + actions_table = { + add_recipe = {shortcut="left", limitations={archive_open=false}, show=true}, + copy = {shortcut="shift-right"}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_item_button_click + }, + { + name = "act_on_floor_product", + actions_table = { + copy = {shortcut="shift-right"}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_item_button_click + }, + { + name = "ingredients_to_combinator", + timeout = 20, + handler = put_ingredients_into_cursor + } + } +} + +listeners.misc = { + build_gui_element = (function(player, event) + if event.trigger == "main_dialog" then + build_item_boxes(player) + end + end), + refresh_gui_element = (function(player, event) + local triggers = {item_boxes=true, production=true, factory=true, all=true} + if triggers[event.trigger] then refresh_item_boxes(player) end + end) +} + +return { listeners } diff --git a/modfiles/ui/main/production_bar.lua b/modfiles/ui/main/production_bar.lua new file mode 100644 index 000000000..459c02dfd --- /dev/null +++ b/modfiles/ui/main/production_bar.lua @@ -0,0 +1,199 @@ +local District = require("backend.data.District") + +-- ** LOCAL UTIL ** +local function refresh_production(player, _, _) + local ui_state = util.globals.ui_state(player) + if ui_state.districts_view then + local realm = util.globals.player_table(player).realm + for district in realm:iterator() do district:refresh() end + util.raise.refresh(player, "districts_box") + else + local factory = util.context.get(player, "Factory") + if factory and factory.valid then + solver.update(player, factory) + util.raise.refresh(player, "factory") + end + end +end + + +local function refresh_production_bar(player) + local ui_state = util.globals.ui_state(player) + local factory = util.context.get(player, "Factory") --[[@as Factory?]] + + if ui_state.main_elements.main_frame == nil then return end + local production_bar_elements = ui_state.main_elements.production_bar + + local districts_view = ui_state.districts_view + local factory_valid = factory ~= nil and factory.valid + + production_bar_elements.factory_flow.visible = (not districts_view) + production_bar_elements.district_flow.visible = districts_view + + local valid_factory_selected = (factory and factory.valid) or false + + if not districts_view then + -- Power + Emissions + production_bar_elements.power_emissions_flow.visible = valid_factory_selected + if valid_factory_selected then + local top_floor = factory.top_floor + local label_power = production_bar_elements.power_label + label_power.caption = {"fp.bold_label", util.format.SI_value(top_floor.power, "W", 3)} + label_power.tooltip = {"", {"fp.u_power"}, ": ", util.format.SI_value(top_floor.power, "W", 5)} + + local label_emissions = production_bar_elements.emissions_label + label_emissions.caption = {"fp.bold_label", util.format.SI_value(top_floor.emissions, "E/m", 3)} + label_emissions.tooltip = util.gui.format_emissions(top_floor.emissions, factory.parent) + end + + -- Validity label + local invalid_factory_selected = (factory and not factory.valid) or false + production_bar_elements.validity_label.visible = invalid_factory_selected + end + + production_bar_elements.timescale_switch.visible = valid_factory_selected + + ui_state.main_elements.views_flow.visible = factory_valid +end + + +local function build_production_bar(player) + local ui_state = util.globals.ui_state(player) + local main_elements = ui_state.main_elements + main_elements.production_bar = {} + + local parent_flow = main_elements.flows.right_vertical + local subheader = parent_flow.add{type="frame", direction="horizontal", style="inside_deep_frame"} + subheader.style.padding = {6, 4} + subheader.style.height = MAGIC_NUMBERS.subheader_height + + local button_refresh = subheader.add{type="sprite-button", tags={mod="fp", on_gui_click="refresh_production"}, + sprite="utility/refresh", style="tool_button", tooltip={"fp.refresh_production"}, mouse_button_filter={"left"}} + button_refresh.style.top_margin = -2 + main_elements.production_bar["refresh_button"] = button_refresh + + + -- Factory bar + local flow_factory = subheader.add{type="flow", direction="horizontal"} + main_elements.production_bar["factory_flow"] = flow_factory + + local label_factory = flow_factory.add{type="label", caption={"fp.pu_factory", 1}, style="frame_title"} + label_factory.style.padding = {-1, 8} + + local flow_power_emissions = flow_factory.add{type="flow", direction="horizontal"} + flow_power_emissions.style.margin = {3, 0, 0, 12} + main_elements.production_bar["power_emissions_flow"] = flow_power_emissions + local label_power_value = flow_power_emissions.add{type="label"} + main_elements.production_bar["power_label"] = label_power_value + flow_power_emissions.add{type="label", caption="|"} + local label_emissions_value = flow_power_emissions.add{type="label"} + main_elements.production_bar["emissions_label"] = label_emissions_value + + local label_invalid = flow_factory.add{type="label", caption={"fp.invalid"}, style="bold_red_label"} + label_invalid.style.top_margin = 4 + main_elements.production_bar["validity_label"] = label_invalid + + -- District bar + local flow_districts = subheader.add{type="flow", direction="horizontal"} + main_elements.production_bar["district_flow"] = flow_districts + + local label_districts = flow_districts.add{type="label", caption={"fp.pu_district", 2}, style="frame_title"} + label_districts.style.padding = {-1, 8} + + local button_add = flow_districts.add{type="button", caption={"fp.add_district"}, style="fp_button_green", + tags={mod="fp", on_gui_click="add_district"}, mouse_button_filter={"left"}} + button_add.style.height = 26 + button_add.style.left_margin = 12 + button_add.style.minimal_width = 0 + + -- Shared bar + subheader.add{type="empty-widget", style="flib_horizontal_pusher"} + + local flow_timescale = subheader.add{type="flow", direction="horizontal"} + flow_timescale.style.margin = {4, 16, 0, 0} + + local switch_state = (util.globals.preferences(player).timescale == 1) and "left" or "right" + local switch_timescale = flow_timescale.add{type="switch", tooltip={"fp.timescale_tt"}, switch_state=switch_state, + left_label_caption={"", "/", {"fp.second"}}, right_label_caption={"", "/", {"fp.minute"}}, + tags={mod="fp", on_gui_switch_state_changed="toggle_timescale"}} + switch_timescale.style.margin = {0, 4} + main_elements.production_bar["timescale_switch"] = switch_timescale + + local flow_views = subheader.add{type="flow", direction="horizontal"} + main_elements["views_flow"] = flow_views + + refresh_production_bar(player) +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "refresh_production", + timeout = 20, + handler = (function(player, _, event) + if DEV_ACTIVE and not event.shift then -- implicit mod reload for easier development + util.gui.reset_player(player) -- destroys all FP GUIs + util.gui.toggle_mod_gui(player) -- fixes the mod gui button after its been destroyed + game.reload_mods() -- toggle needs to be delayed by a tick since the reload is not instant + game.print("Mods reloaded") + util.nth_tick.register((game.tick + 1), "interface_toggle", {player_index=player.index}) + util.nth_tick.register((game.tick + 2), "refresh_production", {player_index=player.index}) + else + refresh_production(player, nil, nil) + end + end) + }, + { + name = "add_district", + handler = (function(player, _, _) + local realm = util.globals.player_table(player).realm + local new_district = District.init() + realm:insert(new_district) + util.context.set(player, new_district) + util.raise.refresh(player, "all") + end) + } + }, + on_gui_switch_state_changed = { + { + name = "toggle_timescale", + handler = (function(player, _, event) + local new_timescale = (event.element.switch_state == "left") and 1 or 60 + util.globals.preferences(player).timescale = new_timescale + + item_views.rebuild_data(player) + item_views.rebuild_interface(player) + util.raise.refresh(player, "factory") + end) + } + } +} + +listeners.misc = { + fp_refresh_production = (function(player, _, _) + if main_dialog.is_in_focus(player) then refresh_production(player, nil, nil) end + end), + + build_gui_element = (function(player, event) + if event.trigger == "main_dialog" then + build_production_bar(player) + end + end), + refresh_gui_element = (function(player, event) + local triggers = {production_bar=true, production=true, factory=true, all=true} + if triggers[event.trigger] then refresh_production_bar(player) end + end) +} + +listeners.global = { + refresh_production = (function(metadata) + local player = game.get_player(metadata.player_index) + refresh_production(player, nil, nil) + end) +} + +return { listeners } diff --git a/modfiles/ui/main/production_box.lua b/modfiles/ui/main/production_box.lua new file mode 100644 index 000000000..374773d7f --- /dev/null +++ b/modfiles/ui/main/production_box.lua @@ -0,0 +1,457 @@ +local Line = require("backend.data.Line") +local matrix_engine = require("backend.calculation.matrix_engine") + +-- ** LOCAL UTIL ** +local function refresh_paste_button(player) + local main_elements = util.globals.main_elements(player) + if not main_elements.production_box then return end + local factory = util.context.get(player, "Factory") --[[@as Factory?]] + + local line_copied = util.clipboard.check_classes(player, {Floor=true, Line=true}) + main_elements.production_box.paste_button.visible = (factory ~= nil and line_copied) or false +end + +local function refresh_solver_frame(player) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local main_elements = util.globals.main_elements(player) + local solver_flow = main_elements.solver_flow + solver_flow.clear() + + local factory_data = solver.generate_factory_data(player, factory) + + local matrix_metadata = matrix_engine.get_matrix_solver_metadata(factory_data) + if matrix_metadata.num_rows == 0 then return end -- skip if there are no active lines + local linear_dependence_data = matrix_engine.get_linear_dependence_data(factory_data, matrix_metadata) + local num_needed_free_items = matrix_metadata.num_rows - matrix_metadata.num_cols + #matrix_metadata.free_items + + if next(linear_dependence_data.linearly_dependent_recipes) then + main_elements.solver_frame.visible = true + + local caption = {"fp.error_message", {"fp.info_label", {"fp.linearly_dependent_recipes"}}} + solver_flow.add{type="label", caption=caption, tooltip={"fp.linearly_dependent_recipes_tt"}, style="bold_label"} + local flow_recipes = solver_flow.add{type="flow", direction="horizontal"} + + for _, recipe_proto in pairs(linear_dependence_data.linearly_dependent_recipes) do + local sprite = flow_recipes.add{type="sprite", sprite=recipe_proto.sprite, + tooltip=recipe_proto.localised_name, resize_to_sprite=true} + sprite.style.size = 36 + sprite.style.stretch_image_to_widget_size = true + end + + elseif num_needed_free_items ~= 0 then + main_elements.solver_frame.visible = true + + local function build_item_flow(flow, status, items) + for _, proto in pairs(items) do + local tooltip = {"fp.turn_" .. status, proto.localised_name} + local color = (status == "unrestricted") and "green" or "default" + flow.add{type="sprite-button", sprite=proto.sprite, tooltip=tooltip, + tags={mod="fp", on_gui_click="switch_matrix_item", status=status, type=proto.type, name=proto.name}, + style="flib_slot_button_" .. color .. "_small", mouse_button_filter={"left"}} + end + end + + local needs_choice = (#linear_dependence_data.allowed_free_items > 0) + local item_count = 0 + + if needs_choice then + local caption = {"fp.error_message", {"fp.info_label", {"fp.choose_unrestricted_items"}}} + local tooltip = {"fp.choose_unrestricted_items_tt", num_needed_free_items, + {"fp.pl_item", num_needed_free_items}} + solver_flow.add{type="label", caption=caption, tooltip=tooltip, style="bold_label"} + else + solver_flow.add{type="label", caption={"fp.info_label", {"fp.unrestricted_items_balanced"}}, + tooltip={"fp.unrestricted_items_balanced_tt"}, style="bold_label"} + end + + local flow_unrestricted = solver_flow.add{type="flow", direction="horizontal"} + build_item_flow(flow_unrestricted, "unrestricted", matrix_metadata.free_items) + item_count = item_count + #matrix_metadata.free_items + + if needs_choice then + log("[FP] SolverUI: Needs choice. Processing items.") + local last_recipe_items_to_display = {} + local other_items = {} + local allowed_free_items = linear_dependence_data.allowed_free_items + + local function base_name(item) + if (item.base_name ~= nil) then return item.base_name + else return item.name + end + end + + local last_recipe_map = {} + for _, item in ipairs(factory.last_recipe_items or {}) do + last_recipe_map[base_name(item)] = true + end + + for _, item in ipairs(allowed_free_items) do + if last_recipe_map[base_name(item)] then + table.insert(last_recipe_items_to_display, item) + else + table.insert(other_items, item) + end + end + log("[FP] SolverUI: Separated into " .. #last_recipe_items_to_display .. " from last recipe and " .. #other_items .. " other items.") + + if #last_recipe_items_to_display > 0 then + solver_flow.add{type="label", caption={"fp.last_recipe_items"}, style="bold_label"} + local flow_priority = solver_flow.add{type="flow", direction="horizontal"} + build_item_flow(flow_priority, "constrained", last_recipe_items_to_display) + item_count = item_count + #last_recipe_items_to_display + end + + if #other_items > 0 then + if #last_recipe_items_to_display > 0 then + solver_flow.add{type="label", caption={"fp.other_items"}, style="bold_label"} + end + local flow_constrained = solver_flow.add{type="flow", direction="horizontal"} + build_item_flow(flow_constrained, "constrained", other_items) + item_count = item_count + #other_items + end + end + + -- This is some total bullshit because extra_bottom_padding_when_activated doesn't work + local total_width = 180 + (4 * 12) + (item_count * 40) + local interface_width = util.globals.ui_state(player).main_dialog_dimensions.width + local box_width = interface_width - MAGIC_NUMBERS.list_width + solver_flow.style.bottom_padding = (total_width > box_width) and 16 or 4 + else + log("[FP] SolverUI: Solver is balanced.") + end +end + + +local function change_floor(player, destination) + if util.context.ascend_floors(player, destination) then + -- Only refresh if the floor was indeed changed + util.raise.refresh(player, "production") + end +end + +local function toggle_fold_out_subfloors(player) + local preferences = util.globals.preferences(player) + preferences.fold_out_subfloors = not preferences.fold_out_subfloors + util.raise.refresh(player, "production_detail") +end + +local function handle_solver_change(player, _, event) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local new_solver = (event.element.switch_state == "left") and "traditional" or "matrix" + + if new_solver == "matrix" then + factory.matrix_free_items = {} -- activate the matrix solver + else + factory.matrix_free_items = nil -- disable the matrix solver + factory.linearly_dependant = false + end + + main_dialog.toggle_districts_view(player, true) + solver.update(player, factory) + util.raise.refresh(player, "factory") +end + +local function repair_factory(player, _, _) + -- This function can only run is a factory is selected and invalid + local factory = util.context.get(player, "Factory") --[[@as Factory]] + factory:repair(player) + + main_dialog.toggle_districts_view(player, true) + solver.update(player, factory) + util.raise.refresh(player, "all") -- needs the full refresh to reset factory list buttons +end + +local function paste_line(player, _, _) + local floor = util.context.get(player, "Floor") --[[@as Floor]] + + local dummy_line = Line.init({}, "produce") + util.clipboard.dummy_paste(player, dummy_line, floor) +end + +local function switch_matrix_item(player, tags, _) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + + if tags.status == "unrestricted" then + for index, item in pairs(factory.matrix_free_items) do + if item.type == tags.type and item.name == tags.name then + table.remove(factory.matrix_free_items, index) + break + end + end + else -- "constrained" + local item_proto = prototyper.util.find("items", tags.name, tags.type) + table.insert(factory.matrix_free_items, item_proto) + end + + solver.update(player, factory) + util.raise.refresh(player, "factory") +end + + +local function refresh_production_box(player) + local ui_state = util.globals.ui_state(player) + local preferences = util.globals.preferences(player) + local factory = util.context.get(player, "Factory") --[[@as Factory?]] + local floor = util.context.get(player, "Floor") --[[@as Floor?]] + + if ui_state.main_elements.main_frame == nil then return end + local production_box_elements = ui_state.main_elements.production_box + + local visible = not ui_state.districts_view + production_box_elements.vertical_frame.visible = visible + if not visible then return end + + local factory_valid = factory ~= nil and factory.valid + local any_lines_present = factory_valid and not factory.archived and floor:count() > 0 + local current_level = (factory_valid) and floor.level or 1 + + production_box_elements.level_label.caption = (not factory_valid) and "" + or {"fp.bold_label", {"", {"fp.level"}, " ", current_level}} + + production_box_elements.floor_up_button.visible = factory_valid + production_box_elements.floor_up_button.enabled = (current_level > 1) + + production_box_elements.floor_top_button.visible = factory_valid + production_box_elements.floor_top_button.enabled = (current_level > 1) + + production_box_elements.fold_out_subfloors_button.visible = factory_valid + production_box_elements.fold_out_subfloors_button.toggled = preferences.fold_out_subfloors + + production_box_elements.solver_flow.visible = factory_valid + if factory_valid then + local matrix_solver_active = (factory.matrix_free_items ~= nil) + local switch_state = (matrix_solver_active) and "right" or "left" + production_box_elements.solver_choice_switch.switch_state = switch_state + production_box_elements.solver_choice_switch.enabled = (not factory.archived) + end + + production_box_elements.utility_dialog_button.enabled = factory_valid + + production_box_elements.instruction_label.visible = false + if factory == nil then + production_box_elements.instruction_label.caption = {"fp.production_instruction_factory"} + production_box_elements.instruction_label.visible = true + elseif factory_valid and not factory.archived and not any_lines_present then + if factory:count() == 0 then + production_box_elements.instruction_label.caption = {"fp.production_instruction_product"} + production_box_elements.instruction_label.visible = true + else + production_box_elements.instruction_label.caption = {"fp.production_instruction_recipe"} + production_box_elements.instruction_label.visible = true + end + end + + local invalid_factory_selected = (factory and not factory.valid) or false + production_box_elements.repair_flow.visible = invalid_factory_selected + + if invalid_factory_selected then + local last_modset = util.porter.format_modset_diff(factory.last_valid_modset) + production_box_elements.diff_label.tooltip = last_modset + end + + refresh_paste_button(player) + + ui_state.main_elements.solver_frame.visible = false + if any_lines_present and factory.matrix_free_items then + refresh_solver_frame(player) + end +end + +local function build_production_box(player) + local main_elements = util.globals.main_elements(player) + main_elements.production_box = {} + + local parent_flow = main_elements.flows.right_vertical + local frame_vertical = parent_flow.add{type="frame", direction="vertical", style="inside_deep_frame"} + main_elements.production_box["vertical_frame"] = frame_vertical + + -- Subheader + local subheader = frame_vertical.add{type="frame", direction="horizontal", style="subheader_frame"} + subheader.style.top_padding = 4 + local flow_production = subheader.add{type="flow", direction="horizontal"} + + local button_utility_dialog = flow_production.add{type="sprite-button", tooltip={"fp.utility_dialog_tt"}, + tags={mod="fp", on_gui_click="open_utility_dialog"}, sprite="flib_settings_black", style="tool_button", + mouse_button_filter={"left"}} + button_utility_dialog.style.padding = 1 + main_elements.production_box["utility_dialog_button"] = button_utility_dialog + + local label_production = flow_production.add{type="label", caption={"fp.u_production"}, style="frame_title"} + label_production.style.padding = {0, 8} + + local label_level = flow_production.add{type="label"} + label_level.style.margin = {5, 6, 0, 4} + main_elements.production_box["level_label"] = label_level + + local button_floor_up = flow_production.add{type="sprite-button", sprite="fp_arrow_line_up", + tooltip={"fp.floor_up_tt"}, tags={mod="fp", on_gui_click="change_floor", destination="up"}, + style="fp_sprite-button_rounded_icon", mouse_button_filter={"left"}} + button_floor_up.style.top_margin = 2 + main_elements.production_box["floor_up_button"] = button_floor_up + + local button_floor_top = flow_production.add{type="sprite-button", sprite="fp_arrow_line_bar_up", + tooltip={"fp.floor_top_tt"}, tags={mod="fp", on_gui_click="change_floor", destination="top"}, + style="fp_sprite-button_rounded_icon", mouse_button_filter={"left"}} + button_floor_top.style.padding = {3, 2, 1, 2} + button_floor_top.style.top_margin = 2 + main_elements.production_box["floor_top_button"] = button_floor_top + + local button_fold_out_subfloors = flow_production.add{type="sprite-button", sprite="fp_fold_out_subfloors", + tooltip={"fp.fold_out_subfloors_tt"}, tags={mod="fp", on_gui_click="toggle_fold_out_subfloors"}, + style="fp_sprite-button_rounded_icon", mouse_button_filter={"left"}, auto_toggle=true} + button_fold_out_subfloors.style.margin = {2, 0, 0, 16} + main_elements.production_box["fold_out_subfloors_button"] = button_fold_out_subfloors + + flow_production.add{type="empty-widget", style="flib_horizontal_pusher"} + + local flow_solver = flow_production.add{type="flow", direction="horizontal"} + flow_solver.style.horizontal_spacing = 12 + flow_solver.style.margin = {4, 8, 0, 0} + main_elements.production_box["solver_flow"] = flow_solver + flow_solver.add{type="label", caption={"fp.info_label", {"fp.solver_choice"}}, style="bold_label", + tooltip={"fp.solver_choice_tt"}} + local switch_solver_choice = flow_solver.add{type="switch", + right_label_caption={"fp.solver_choice_matrix"}, left_label_caption={"fp.solver_choice_traditional"}, + tags={mod="fp", on_gui_switch_state_changed="solver_choice_changed"}} + main_elements.production_box["solver_choice_switch"] = switch_solver_choice + + + -- Main scrollpane + local scroll_pane_production = frame_vertical.add{type="scroll-pane", style="flib_naked_scroll_pane_no_padding"} + scroll_pane_production.style.extra_right_padding_when_activated = 0 + scroll_pane_production.style.bottom_padding = 12 + scroll_pane_production.style.extra_bottom_padding_when_activated = -12 + main_elements.production_box["production_scroll_pane"] = scroll_pane_production + + -- Instruction label + local label_instruction = frame_vertical.add{type="label", style="bold_label"} + label_instruction.style.margin = 16 + main_elements.production_box["instruction_label"] = label_instruction + + -- Repair panel + local flow_repair = frame_vertical.add{type="flow", direction="vertical"} + flow_repair.style.margin = 12 + flow_repair.style.width = 380 + main_elements.production_box["repair_flow"] = flow_repair + + local label_repair = flow_repair.add{type="label", caption={"fp.warning_with_icon", {"fp.factory_needs_repair"}}} + label_repair.style.single_line = false + + local flow_actions = flow_repair.add{type="flow", direction="horizontal"} + flow_actions.style.top_margin = 8 + local label_diff = flow_actions.add{type="label", caption={"fp.modset_differences"}, style="bold_label"} + main_elements.production_box["diff_label"] = label_diff + flow_actions.add{type="empty-widget", style="flib_horizontal_pusher"} + local button_repair = flow_actions.add{type="button", tags={mod="fp", on_gui_click="repair_factory"}, + caption={"fp.repair_factory"}, mouse_button_filter={"left"}} + button_repair.style.minimal_width = 0 + button_repair.style.right_margin = 16 + button_repair.style.height = 22 + button_repair.style.padding = {0, 4} + + -- Paste button + local button_paste = frame_vertical.add{type="button", caption={"fp.paste_line"}, tooltip={"fp.paste_line_tt"}, + style="rounded_button", tags={mod="fp", on_gui_click="paste_line"}, mouse_button_filter={"left"}} + button_paste.style.margin = 12 + button_paste.style.minimal_width = 0 + main_elements.production_box["paste_button"] = button_paste + + frame_vertical.add{type="empty-widget", style="flib_vertical_pusher"} + frame_vertical.add{type="empty-widget", style="flib_horizontal_pusher"} + + -- Bottom UI for messages & solver + local scroll_pane_messages = frame_vertical.add{type="scroll-pane", vertical_scroll_policy="never", + visible=false, style="flib_naked_scroll_pane_no_padding"} + main_elements["messages_frame"] = scroll_pane_messages + + local line_messages = scroll_pane_messages.add{type="line", direction="horizontal"} + line_messages.style.margin = -1 -- hack around some scrollpane styling issues + + local flow_messages = scroll_pane_messages.add{type="flow", direction="vertical"} + flow_messages.style.padding = {0, 12, 6, 12} + main_elements["messages_flow"] = flow_messages + + local scroll_pane_solver = frame_vertical.add{type="scroll-pane", vertical_scroll_policy="never", + visible=false, style="flib_naked_scroll_pane_no_padding"} + main_elements["solver_frame"] = scroll_pane_solver + + local line_solver = scroll_pane_solver.add{type="line", direction="horizontal"} + line_solver.style.margin = -1 -- hack around some scrollpane styling issues + + local flow_solver_options = scroll_pane_solver.add{type="flow", direction="horizontal"} + flow_solver_options.style.padding = {0, 12, 4, 12} + flow_solver_options.style.vertical_align = "center" + flow_solver_options.style.horizontal_spacing = 12 + main_elements["solver_flow"] = flow_solver_options + + refresh_production_box(player) +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "change_floor", + handler = (function(player, tags, _) + change_floor(player, tags.destination) + end) + }, + { + name = "toggle_fold_out_subfloors", + handler = toggle_fold_out_subfloors + }, + { + name = "open_utility_dialog", + handler = (function(player, _, _) + util.raise.open_dialog(player, {dialog="utility"}) + end) + }, + { + name = "repair_factory", + timeout = 20, + handler = repair_factory + }, + { + name = "paste_line", + handler = paste_line + }, + { + name = "switch_matrix_item", + handler = switch_matrix_item + } + }, + on_gui_switch_state_changed = { + { + name = "solver_choice_changed", + handler = handle_solver_change + } + } +} + +listeners.misc = { + fp_up_floor = (function(player, _, _) + if main_dialog.is_in_focus(player) then change_floor(player, "up") end + end), + fp_top_floor = (function(player, _, _) + if main_dialog.is_in_focus(player) then change_floor(player, "top") end + end), + fp_toggle_fold_out_subfloors = (function(player, _, _) + if main_dialog.is_in_focus(player) then toggle_fold_out_subfloors(player) end + end), + + build_gui_element = (function(player, event) + if event.trigger == "main_dialog" then + build_production_box(player) + end + end), + refresh_gui_element = (function(player, event) + local triggers = {production_box=true, production_detail=true, production=true, factory=true, all=true} + if triggers[event.trigger] then refresh_production_box(player) + elseif event.trigger == "paste_button" then refresh_paste_button(player) end + end) +} + +return { listeners } diff --git a/modfiles/ui/main/production_handler.lua b/modfiles/ui/main/production_handler.lua new file mode 100644 index 000000000..0015ab844 --- /dev/null +++ b/modfiles/ui/main/production_handler.lua @@ -0,0 +1,489 @@ +local Floor = require("backend.data.Floor") +local Beacon = require("backend.data.Beacon") + +-- ** LOCAL UTIL ** +local function handle_line_move_click(player, tags, event) + local line = OBJECT_INDEX[tags.line_id] ---@type Line + local floor = line.parent + + local spots_to_shift = (event.control) and 5 or ((not event.shift) and 1 or nil) + if spots_to_shift == nil and floor.level > 1 and tags.direction == "previous" then + spots_to_shift = 0 + for previous_line in floor:iterator(nil, line.previous, "previous") do + if previous_line.id ~= floor.first.id then + spots_to_shift = spots_to_shift + 1 + end + end + end + line.parent:shift(line, tags.direction, spots_to_shift) + + solver.update(player) + util.raise.refresh(player, "factory") +end + + +-- Handles any line recipe, with or without subfloor +local function handle_line_recipe_click(player, tags, action) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local line = OBJECT_INDEX[tags.line_id] + local relevant_line = (line.class == "Floor") and line.first or line + + if action == "open_subfloor" then + if relevant_line.production_type == "consume" then + util.messages.raise(player, "error", {"fp.error_no_subfloor_on_byproduct_recipes"}, 1) + return + end + + local new_context = line + if line.class == "Line" then + if factory.archived then + util.messages.raise(player, "error", {"fp.error_no_new_subfloors_in_archive"}, 1) + return + end + + local subfloor = Floor.init(line.parent.level + 1) + line.parent:replace(line, subfloor) + line.next, line.previous = nil, nil + subfloor:insert(line) + + new_context = subfloor + solver.update(player, factory) + end + + util.context.set(player, new_context) + util.raise.refresh(player, "production") + + elseif action == "copy" then + util.clipboard.copy(player, line) -- use actual line + + elseif action == "paste" then + util.clipboard.paste(player, line) -- use actual line + + elseif action == "toggle" then + relevant_line.active = not relevant_line.active + solver.update(player, factory) + util.raise.refresh(player, "factory") + + elseif action == "delete" then + local floor = line.parent + floor:remove(line, true) + + local selected_floor = util.context.get(player, "Floor") + if floor.level > selected_floor.level and floor:count() == 1 then + floor.parent:replace(floor, floor.first) + end + + solver.update(player, factory) + util.raise.refresh(player, "factory") + + elseif action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes["recipe"][relevant_line.recipe_proto.name]) + end +end + +-- Handles the defining recipe of a floor (ie. first one of a subfloor) +local function handle_floor_recipe_click(player, tags, action) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local line = OBJECT_INDEX[tags.line_id] + + if action == "copy" then + util.clipboard.copy(player, line) + + elseif action == "paste" then + util.clipboard.paste(player, line) + + elseif action == "toggle" then + line.active = not line.active + solver.update(player, factory) + util.raise.refresh(player, "factory") + + elseif action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes["recipe"][line.recipe_proto.name]) + end +end + + +local function handle_percentage_change(player, tags, event) + local line = OBJECT_INDEX[tags.line_id] + local relevant_line = (line.class == "Floor") and line.first or line + relevant_line.percentage = tonumber(event.element.text) or 100 + + util.globals.ui_state(player).recalculate_on_factory_change = true -- set flag to recalculate if necessary +end + +local function handle_percentage_confirmation(player, _, _) + util.globals.ui_state(player).recalculate_on_factory_change = false -- reset this flag as we refresh below + solver.update(player) + util.raise.refresh(player, "factory") +end + + +local function handle_machine_click(player, tags, action) + local machine = OBJECT_INDEX[tags.machine_id] + local line = machine.parent + + if action == "add_to_cursor" then + local success = util.cursor.set_entity(player, line, machine) + if success then main_dialog.toggle(player) end + + elseif action == "edit" then + util.raise.open_dialog(player, {dialog="machine", modal_data={machine_id=machine.id}}) + + elseif action == "copy" then + util.clipboard.copy(player, machine) + + elseif action == "paste" then + util.clipboard.paste(player, machine) + + elseif action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes["entity"][machine.proto.name]) + end +end + +local function handle_machine_module_add(player, tags, event) + local machine = OBJECT_INDEX[tags.machine_id] + + if event.shift then -- paste + util.clipboard.paste(player, machine) + else + util.raise.open_dialog(player, {dialog="machine", modal_data={machine_id=machine.id}}) + end +end + + +local function handle_beacon_click(player, tags, action) + local beacon = OBJECT_INDEX[tags.beacon_id] + local line = beacon.parent + + if action == "add_to_cursor" then + local success = util.cursor.set_entity(player, line, beacon) + if success then main_dialog.toggle(player) end + + elseif action == "edit" then + util.raise.open_dialog(player, {dialog="beacon", modal_data={line_id=line.id}}) + + elseif action == "copy" then + util.clipboard.copy(player, beacon) + + elseif action == "paste" then + util.clipboard.paste(player, beacon) + + elseif action == "delete" then + line:set_beacon(nil) + solver.update(player) + util.raise.refresh(player, "factory") + + elseif action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes["entity"][beacon.proto.name]) + end +end + +local function handle_beacon_add(player, tags, event) + local line = OBJECT_INDEX[tags.line_id] + + if event.shift then -- paste + local dummy_beacon = Beacon.init({}, line) + util.clipboard.paste(player, dummy_beacon) + else + util.raise.open_dialog(player, {dialog="beacon", modal_data={line_id=line.id}}) + end +end + + +local function handle_module_click(player, tags, action) + local module = OBJECT_INDEX[tags.module_id] + + if action == "edit" then + local line = module.parent.parent.parent + if module.parent.parent.class == "Machine" then + util.raise.open_dialog(player, {dialog="machine", modal_data={machine_id=line.machine.id}}) + else + util.raise.open_dialog(player, {dialog="beacon", modal_data={line_id=line.id}}) + end + + elseif action == "copy" then + util.clipboard.copy(player, module) + + elseif action == "paste" then + util.clipboard.paste(player, module) + + elseif action == "delete" then + local module_set = module.parent + module_set:remove(module) + + if module_set.parent.class == "Beacon" and module_set.module_count == 0 then + module_set.parent.parent:set_beacon(nil) + end + + module_set:normalize({effects=true}) + solver.update(player) + util.raise.refresh(player, "factory") + + elseif action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes["item"][module.proto.name]) + end +end + + +local function handle_item_click(player, tags, action) + local line = OBJECT_INDEX[tags.line_id] + local item = line[tags.item_category .. "s"][tags.item_index] + + if action == "prioritize" then + if #line.products < 2 then + util.messages.raise(player, "warning", {"fp.warning_no_prioritizing_single_product"}, 1) + else + -- Remove the priority_product if the already selected one is clicked + line.priority_product = (line.priority_product ~= item.proto) and item.proto or nil + + solver.update(player) + util.raise.refresh(player, "factory") + end + + elseif action == "add_recipe_to_end" or action == "add_recipe_below" then + if item.proto.type == "entity" then return end + local production_type = (tags.item_category == "byproduct") and "consume" or "produce" + local add_after_line_id = (action == "add_recipe_below") and line.id or nil + + local proto = item.proto + if production_type == "produce" and proto.type == "fluid" and line.class == "Line" then + local temperature = line.temperatures[item.proto.name] + if temperature then proto = prototyper.util.find("items", proto.name .. "-" .. temperature, "fluid") end + -- If a no-temperature fluid is passed, it'll show all compatible temperatures/recipes + end + + util.raise.open_dialog(player, {dialog="recipe", modal_data={line_id=line.id, + add_after_line_id=add_after_line_id, production_type=production_type, + category_id=proto.category_id, product_id=proto.id}}) + + elseif action == "edit" then + if item.proto.type ~= "fluid" then + util.cursor.create_flying_text(player, {"fp.can_only_edit_fluids"}) + return + elseif line.class ~= "Line" then + util.cursor.create_flying_text(player, {"fp.can_only_edit_lines"}) + return + end + util.raise.open_dialog(player, {dialog="item", modal_data={line_id=line.id, + category_id=item.proto.category_id, name=item.proto.name}}) + + elseif action == "copy" then + if item.proto.type == "entity" then return end + local copyable_item = {class="SimpleItem", proto=item.proto, amount=item.amount} + util.clipboard.copy(player, copyable_item) + + elseif action == "add_to_cursor" then + if item.proto.type == "entity" then return end + util.cursor.handle_item_click(player, item.proto, item.amount) + + elseif action == "factoriopedia" then + local name = item.proto.name + if item.proto.type == "entity" then name = name:gsub("custom%-", "") + elseif item.proto.temperature then name = item.proto.base_name end + player.open_factoriopedia_gui(prototypes[item.proto.type][name]) + end +end + +local function handle_fuel_click(player, tags, action) + local fuel = OBJECT_INDEX[tags.fuel_id] + local line = fuel.parent.parent + + if action == "add_recipe_to_end" or action == "add_recipe_below" then + local add_after_line_id = (action == "add_recipe_below") and line.id or nil + + local proto = prototyper.util.find("items", fuel.proto.name, fuel.proto.type) + if fuel.proto.type == "fluid" then + local temperature = fuel.temperature + if temperature then proto = prototyper.util.find("items", proto.name .. "-" .. temperature, "fluid") end + -- If a no-temperature fluid is passed, it'll show all compatible temperatures/recipes + end + + util.raise.open_dialog(player, {dialog="recipe", modal_data={fuel_id=fuel.id, + add_after_line_id=add_after_line_id, production_type="produce", + category_id=proto.category_id, product_id=proto.id}}) + + elseif action == "edit" then + if fuel.proto.type ~= "fluid" then + util.cursor.create_flying_text(player, {"fp.can_only_edit_fluids"}) + return + end + util.raise.open_dialog(player, {dialog="item", modal_data={fuel_id=fuel.id, + category_id=fuel.proto.category_id, name=fuel.proto.name}}) + + elseif action == "copy" then + util.clipboard.copy(player, fuel) + + elseif action == "paste" then + util.clipboard.paste(player, fuel) + + elseif action == "add_to_cursor" then + util.cursor.handle_item_click(player, fuel.proto, fuel.amount) + + elseif action == "factoriopedia" then + player.open_factoriopedia_gui(prototypes[fuel.proto.type][fuel.proto.name]) + end +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "move_line", + handler = handle_line_move_click + }, + { + name = "act_on_line_recipe", + actions_table = { + open_subfloor = {shortcut="left", show=true}, -- does its own archive check + copy = {shortcut="shift-right"}, + paste = {shortcut="shift-left", limitations={archive_open=false}}, + toggle = {shortcut="control-left", limitations={archive_open=false}}, + delete = {shortcut="control-right", limitations={archive_open=false}}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_line_recipe_click + }, + { + name = "act_on_floor_recipe", + actions_table = { + copy = {shortcut="shift-right"}, + paste = {shortcut="shift-left", limitations={archive_open=false}}, + toggle = {shortcut="control-left", limitations={archive_open=false}}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_floor_recipe_click + }, + { + name = "act_on_line_machine", + actions_table = { + edit = {shortcut="left", limitations={archive_open=false}, show=true}, + copy = {shortcut="shift-right"}, + paste = {shortcut="shift-left", limitations={archive_open=false}}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_machine_click + }, + { + name = "add_machine_module", + handler = handle_machine_module_add + }, + { + name = "act_on_line_beacon", + actions_table = { + edit = {shortcut="left", limitations={archive_open=false}, show=true}, + copy = {shortcut="shift-right"}, + paste = {shortcut="shift-left", limitations={archive_open=false}}, + delete = {shortcut="control-right", limitations={archive_open=false}}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_beacon_click + }, + { + name = "add_line_beacon", + handler = handle_beacon_add + }, + { + name = "act_on_line_module", + actions_table = { + edit = {shortcut="left", limitations={archive_open=false}, show=true}, + copy = {shortcut="shift-right"}, + paste = {shortcut="shift-left", limitations={archive_open=false}}, + delete = {shortcut="control-right", limitations={archive_open=false}}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_module_click + }, + { + name = "act_on_line_product", + actions_table = { + prioritize = {shortcut="left", limitations={archive_open=false, matrix_active=false}, show=true}, + copy = {shortcut="shift-right"}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = (function(player, tags, action) + tags.item_category = "product" + handle_item_click(player, tags, action) + end) + }, + { + name = "act_on_line_byproduct", + actions_table = { + add_recipe_to_end = {shortcut="left", limitations={archive_open=false, matrix_active=true}, show=true}, + add_recipe_below = {limitations={archive_open=false, matrix_active=true}}, + copy = {shortcut="shift-right"}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = (function(player, tags, action) + tags.item_category = "byproduct" + handle_item_click(player, tags, action) + end) + }, + { + name = "act_on_line_ingredient", + actions_table = { + add_recipe_to_end = {shortcut="left", limitations={archive_open=false}, show=true}, + add_recipe_below = {limitations={archive_open=false}}, + edit = {shortcut="control-left", limitations={archive_open=false}, show=true}, + copy = {shortcut="shift-right"}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = (function(player, tags, action) + tags.item_category = "ingredient" + handle_item_click(player, tags, action) + end) + }, + { + name = "act_on_line_fuel", + actions_table = { + add_recipe_to_end = {shortcut="left", limitations={archive_open=false}, show=true}, + add_recipe_below = {limitations={archive_open=false}}, + edit = {shortcut="control-left", limitations={archive_open=false}, show=true}, + copy = {shortcut="shift-right"}, + paste = {shortcut="shift-left", limitations={archive_open=false}}, + add_to_cursor = {shortcut="alt-right"}, + factoriopedia = {shortcut="alt-left"} + }, + handler = handle_fuel_click + } + }, + on_gui_checked_state_changed = { + { + name = "checkmark_line", + handler = (function(_, tags, _) + local line = OBJECT_INDEX[tags.line_id] + local relevant_line = (line.class == "Floor") and line.first or line + relevant_line.done = not relevant_line.done + end) + } + }, + on_gui_text_changed = { + { + name = "change_line_percentage", + handler = handle_percentage_change + }, + { + name = "line_comment", + handler = (function(_, tags, event) + local line = OBJECT_INDEX[tags.line_id] + local relevant_line = (line.class == "Floor") and line.first or line + relevant_line.comment = event.element.text + end) + } + }, + on_gui_confirmed = { + { + name = "set_line_percentage", + handler = handle_percentage_confirmation + } + } +} + +return { listeners } diff --git a/modfiles/ui/main/production_table.lua b/modfiles/ui/main/production_table.lua new file mode 100644 index 000000000..09e7bce31 --- /dev/null +++ b/modfiles/ui/main/production_table.lua @@ -0,0 +1,537 @@ +-- ** LOCAL UTIL ** +local function generate_metadata(player, factory) + local preferences = util.globals.preferences(player) + local tooltips = util.globals.ui_state(player).tooltips + tooltips.production_table = {} + + local metadata = { + archive_open = factory.archived, + matrix_solver_active = (factory.matrix_free_items ~= nil), + ingredient_satisfaction = preferences.ingredient_satisfaction, + fold_out_subfloors = preferences.fold_out_subfloors, + player = player, + tooltips = tooltips.production_table, + district = factory.parent, + action_tooltips = { + act_on_line_recipe = MODIFIER_ACTIONS["act_on_line_recipe"].tooltip, + act_on_floor_recipe = MODIFIER_ACTIONS["act_on_floor_recipe"].tooltip, + act_on_line_machine = MODIFIER_ACTIONS["act_on_line_machine"].tooltip, + act_on_line_beacon = MODIFIER_ACTIONS["act_on_line_beacon"].tooltip, + act_on_line_module = MODIFIER_ACTIONS["act_on_line_module"].tooltip, + act_on_line_product = MODIFIER_ACTIONS["act_on_line_product"].tooltip, + act_on_line_byproduct = MODIFIER_ACTIONS["act_on_line_byproduct"].tooltip, + act_on_line_ingredient = MODIFIER_ACTIONS["act_on_line_ingredient"].tooltip, + act_on_line_fuel = MODIFIER_ACTIONS["act_on_line_fuel"].tooltip + } + } + + return metadata +end + +local function format_effects_tooltip(tooltip) + if #tooltip > 1 then return {"", "\n\n", tooltip} + else return "" end +end + + +-- ** BUILDERS ** +local builders = {} + +function builders.move(line, parent_flow, metadata) + local function create_move_button(flow, direction, first_subfloor_line) + local enabled = not (first_subfloor_line or metadata.archive_open) + if direction == "next" and line.next == nil then enabled = false + elseif direction == "previous" then + if line.previous == nil then enabled = false + elseif line.parent.level > 1 and line.previous == line.parent.first then enabled = false end + end + + local endpoint = (direction == "next") and {"fp.bottom"} or {"fp.top"} + local up_down = (direction == "next") and "down" or "up" + local move_tooltip = (enabled) and {"", {"fp.move_object", {"fp.pl_recipe", 1}, {"fp." .. up_down}}, + {"fp.move_object_instructions", endpoint}} or "" + + local button = flow.add{type="sprite-button", style="fp_sprite-button_move", sprite="fp_arrow_" .. up_down, + tags={mod="fp", on_gui_click="move_line", direction=direction, line_id=line.id, on_gui_hover="set_tooltip", + context="production_table"}, enabled=enabled, mouse_button_filter={"left"}, raise_hover_events=true} + button.style.size = {18, 14} + button.style.padding = -1 + metadata.tooltips[button.index] = move_tooltip + end + + local move_flow = parent_flow.add{type="flow", direction="vertical"} + move_flow.style.vertical_spacing = 0 + move_flow.style.top_padding = 2 + + local first_subfloor_line = (line.parent.level > 1 and line.previous == nil) + create_move_button(move_flow, "previous", first_subfloor_line) + create_move_button(move_flow, "next", first_subfloor_line) +end + +function builders.done(line, parent_flow, metadata) + local first_subfloor_line = (line.parent.level > 1 and line.previous == nil) + if metadata.fold_out_subfloors and first_subfloor_line then return end + + local relevant_line = (line.class == "Floor") and line.first or line + parent_flow.add{type="checkbox", state=relevant_line.done, mouse_button_filter={"left"}, + tags={mod="fp", on_gui_checked_state_changed="checkmark_line", line_id=line.id}} +end + +function builders.recipe(line, parent_flow, metadata, indent) + local relevant_line = (line.class == "Floor") and line.first or line + local recipe_proto = relevant_line.recipe_proto + + parent_flow.style.vertical_align = "center" + parent_flow.style.horizontal_spacing = 3 + + parent_flow.style.left_margin = indent * 12 + local first_subfloor_line = (line.parent.level > 1 and line.previous == nil) + + local surface_compatibility = relevant_line:get_surface_compatibility() + local solver_compatibility = (metadata.matrix_solver_active or line.production_type ~= "consume") + local line_active = (relevant_line.active and surface_compatibility.overall and solver_compatibility) + local style = (line_active) and "flib_slot_button_default_small" or "flib_slot_button_red_small" + local note = (relevant_line.active) and "" or {"fp.recipe_inactive"} ---@type LocalisedString + local surface_info = {""} + + if not surface_compatibility.recipe then + table.insert(surface_info, {"fp.blocking_condition", {"fp.pl_recipe", 1}}) + end + if not surface_compatibility.machine then + table.insert(surface_info, {"fp.blocking_condition", {"fp.pl_machine", 1}}) + end + if not solver_compatibility then + table.insert(surface_info, {"fp.incompatible_solver"}) + end + + local indication = first_subfloor_line and {"fp.floor_recipe"} or "" + if line.class == "Floor" then + style = (line_active) and "flib_slot_button_blue_small" or "flib_slot_button_purple_small" + indication = {"fp.recipe_subfloor_attached"} + + -- Byproduct-consuming lines can't have subfloors, so this if branching works + elseif line.production_type == "consume" then + style = (line_active) and "flib_slot_button_yellow_small" or "flib_slot_button_orange_small" + note = {"fp.recipe_consumes_byproduct"} + end + + local first_line = (note == "") and {"fp.tt_title", recipe_proto.localised_name} + or {"fp.tt_title_with_note", recipe_proto.localised_name, note} + local action = (first_subfloor_line) and "act_on_floor_recipe" or "act_on_line_recipe" + local effects_section = (line.class == "Line") and format_effects_tooltip(relevant_line.effects_tooltip) or "" + local tooltip = {"", first_line, indication, surface_info, effects_section, "\n", metadata.action_tooltips[action]} + + local button = parent_flow.add{type="sprite-button", sprite=recipe_proto.sprite, style=style, + tags={mod="fp", on_gui_click=action, line_id=line.id, on_gui_hover="set_tooltip", context="production_table"}, + mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip +end + +function builders.percentage(line, parent_flow, metadata) + local relevant_line = (line.class == "Floor") and line.first or line + + local enabled = (not metadata.archive_open and not metadata.matrix_solver_active) + local textfield_percentage = parent_flow.add{type="textfield", text=tostring(relevant_line.percentage), + tags={mod="fp", on_gui_text_changed="change_line_percentage", on_gui_confirmed="set_line_percentage", + line_id=line.id}, enabled=enabled} + util.gui.setup_numeric_textfield(textfield_percentage, true, false) + textfield_percentage.style.horizontal_align = "center" + textfield_percentage.style.width = 55 +end + + +local function add_module_flow(parent_flow, module_set, metadata) + for module in module_set:iterator() do + local quality_proto = module.quality_proto + local title_line = (not quality_proto.always_show) and {"fp.tt_title", module.proto.localised_name} + or {"fp.tt_title_with_note", module.proto.localised_name, quality_proto.rich_text} + local number_line = {"", "\n", module.amount, " ", {"fp.pl_module", module.amount}} + local tooltip = {"", title_line, number_line, format_effects_tooltip(module.effects_tooltip), + "\n", metadata.action_tooltips["act_on_line_module"]} + + local button = parent_flow.add{type="sprite-button", sprite=module.proto.sprite, number=module.amount, + tags={mod="fp", on_gui_click="act_on_line_module", module_id=module.id, on_gui_hover="set_tooltip", + context="production_table"}, quality=quality_proto.name, style="flib_slot_button_default_small", + mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + end +end + +function builders.machine(line, parent_flow, metadata) + parent_flow.style.horizontal_spacing = 2 + + if line.class == "Floor" then -- add a button that shows the total of all machines on the subfloor + -- Machine count doesn't need any special formatting in this case because it'll always be an integer + local machine_count = line.machine_count + local tooltip = {"fp.subfloor_machine_count", machine_count, {"fp.pl_machine", machine_count}} + parent_flow.add{type="sprite-button", sprite="fp_generic_assembler", style="flib_slot_button_disabled_small", + number=machine_count, tooltip=tooltip} + else + local machine = line.machine + local machine_proto, quality_proto = machine.proto, machine.quality_proto + local count, tooltip_line = util.format.machine_count(machine.amount, false) + + local machine_limit = machine.limit + local style, note = "flib_slot_button_default_small", nil + if not metadata.matrix_solver_active and machine_limit ~= nil then + if machine.force_limit then + style = "flib_slot_button_pink_small" + note = {"fp.machine_limit_force", machine_limit} + else + style = "flib_slot_button_purple_small" + note = {"fp.machine_limit_set", machine_limit} + end + end + + if note ~= nil then table.insert(tooltip_line, {"", " - ", note}) end + local title_line = (not quality_proto.always_show) and {"fp.tt_title", machine_proto.localised_name} + or {"fp.tt_title_with_note", machine_proto.localised_name, quality_proto.rich_text} + local tooltip = {"", title_line, tooltip_line, format_effects_tooltip(machine.effects_tooltip), + "\n", metadata.action_tooltips["act_on_line_machine"]} + + local button = parent_flow.add{type="sprite-button", sprite=machine_proto.sprite, number=count, + tags={mod="fp", on_gui_click="act_on_line_machine", machine_id=machine.id, on_gui_hover="set_tooltip", + context="production_table"}, quality=quality_proto.name, style=style, + mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + + if machine:uses_effects() then + add_module_flow(parent_flow, machine.module_set, metadata) + local module_set = machine.module_set + if module_set.module_limit > module_set.module_count then + local module_tooltip = {"", {"fp.add_machine_module"}, "\n", {"fp.shift_to_paste"}} + local module_button = parent_flow.add{type="sprite-button", sprite="utility/add", + tooltip=module_tooltip, tags={mod="fp", on_gui_click="add_machine_module", machine_id=machine.id}, + style="fp_sprite-button_inset", mouse_button_filter={"left"}, enabled=(not metadata.archive_open)} + module_button.style.margin = 2 + module_button.style.padding = 4 + end + end + end +end + +function builders.beacon(line, parent_flow, metadata) + if line.class == "Floor" or not line:uses_beacon_effects() then return end + + local beacon = line.beacon + if beacon == nil then + local tooltip = {"", {"fp.add_beacon"}, "\n", {"fp.shift_to_paste"}} + local button = parent_flow.add{type="sprite-button", sprite="utility/add", tooltip=tooltip, + tags={mod="fp", on_gui_click="add_line_beacon", line_id=line.id}, style="fp_sprite-button_inset", + mouse_button_filter={"left"}, enabled=(not metadata.archive_open)} + button.style.margin = 2 + button.style.padding = 4 + else + local quality_proto = beacon.quality_proto + local title_line = (not quality_proto.always_show) and {"fp.tt_title", beacon.proto.localised_name} + or {"fp.tt_title_with_note", beacon.proto.localised_name, quality_proto.rich_text} + local plural_parameter = (beacon.amount == 1) and 1 or 2 -- needed because the amount can be decimal + local number_line = {"", "\n", beacon.amount, " ", {"fp.pl_beacon", plural_parameter}} + if beacon.total_amount then table.insert(number_line, {"", " - ", {"fp.in_total", beacon.total_amount}}) end + local tooltip = {"", title_line, number_line, format_effects_tooltip(beacon.effects_tooltip), + "\n", metadata.action_tooltips["act_on_line_beacon"]} + + local button_beacon = parent_flow.add{type="sprite-button", sprite=beacon.proto.sprite, number=beacon.amount, + tags={mod="fp", on_gui_click="act_on_line_beacon", beacon_id=beacon.id, on_gui_hover="set_tooltip", + context="production_table"}, quality=quality_proto.name, style="flib_slot_button_default_small", + mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button_beacon.index] = tooltip + + if beacon.total_amount ~= nil then -- add a graphical hint that a beacon total is set + local sprite_overlay = button_beacon.add{type="sprite", sprite="fp_white_square"} + sprite_overlay.ignored_by_interaction = true + end + + add_module_flow(parent_flow, line.beacon.module_set, metadata) + end +end + +function builders.power(line, parent_flow, metadata) + local tooltip = {"", util.format.SI_value(line.power, "W", 5), "\n", + util.gui.format_emissions(line.emissions, metadata.district)} + parent_flow.add{type="label", caption=util.format.SI_value(line.power, "W", 3), tooltip=tooltip} +end + + +local function add_catalysts(flow, line, category, metadata) + if line.class == "Floor" then return end + for _, item in pairs(line.recipe_proto.catalysts[category]) do + local item_proto = prototyper.util.find("items", item.name, item.type) --[[@as FPItemPrototype]] + + local amount, number_tooltip = item_views.process_item(metadata.player, {proto=item_proto}, + (item.amount * line.production_ratio), line.machine.amount) + local title_line = {"fp.tt_title_with_note", item_proto.localised_name, {"fp.catalyst"}} + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + + flow.add{type="sprite-button", sprite=item_proto.sprite, number=amount, + tooltip={"", title_line, number_line}, style="flib_slot_button_blue_small"} + end +end + +function builders.products(line, parent_flow, metadata) + for index, product in pairs(line.products) do + local proto = product.proto + + -- items/s/machine does not make sense for lines with subfloors, show items/s instead + local machine_count = (line.class ~= "Floor") and line.machine.amount or nil + local amount, number_tooltip = item_views.process_item(metadata.player, product, nil, machine_count) + if amount == -1 then goto skip_product end -- an amount of -1 means it was below the margin of error + + local style, note = "flib_slot_button_default_small", nil + if line.class ~= "Floor" and not metadata.matrix_solver_active then + if line.priority_product == proto then + style = "flib_slot_button_pink_small" + note = {"fp.priority_product"} + end + end + + local name_line = (note == nil) and {"fp.tt_title", proto.localised_name} + or {"fp.tt_title_with_note", proto.localised_name, note} + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + local tooltip = {"", name_line, number_line, "\n", metadata.action_tooltips["act_on_line_product"]} + + local button = parent_flow.add{type="sprite-button", sprite=proto.sprite, style=style, + tags={mod="fp", on_gui_click="act_on_line_product", line_id=line.id, item_index=index, + on_gui_hover="set_tooltip", context="production_table"}, number=amount, + mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + + ::skip_product:: + end + + add_catalysts(parent_flow, line, "products", metadata) +end + +function builders.byproducts(line, parent_flow, metadata) + for index, byproduct in pairs(line.byproducts) do + local proto = byproduct.proto + + -- items/s/machine does not make sense for lines with subfloors, show items/s instead + local machine_count = (line.class ~= "Floor") and line.machine.amount or nil + local amount, number_tooltip = item_views.process_item(metadata.player, byproduct, nil, machine_count) + if amount == -1 then goto skip_byproduct end -- an amount of -1 means it was below the margin of error + + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + local tooltip = {"", {"fp.tt_title", proto.localised_name}, number_line, + "\n", metadata.action_tooltips["act_on_line_byproduct"]} + + local button = parent_flow.add{type="sprite-button", sprite=proto.sprite, + tags={mod="fp", on_gui_click="act_on_line_byproduct", line_id=line.id, item_index=index, + on_gui_hover="set_tooltip", context="production_table"}, number=amount, + mouse_button_filter={"left-and-right"}, style="flib_slot_button_red_small", raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + + ::skip_byproduct:: + end +end + +function builders.ingredients(line, parent_flow, metadata) + for index, ingredient in pairs(line.ingredients) do + local proto = ingredient.proto + + -- items/s/machine does not make sense for lines with subfloors, show items/s instead + local machine_count = (line.class ~= "Floor") and line.machine.amount or nil + local amount, number_tooltip = item_views.process_item(metadata.player, ingredient, nil, machine_count) + if amount == -1 then goto skip_ingredient end -- an amount of -1 means it was below the margin of error + + local style = "flib_slot_button_green_small" + local satisfaction_line = "" ---@type LocalisedString + + if proto.type == "entity" then + style = "flib_slot_button_disabled_small" + elseif metadata.ingredient_satisfaction and ingredient.amount > 0 then + local satisfaction_percentage = (ingredient.satisfied_amount / ingredient.amount) * 100 + local formatted_percentage = util.format.number(satisfaction_percentage, 3) + + -- We use the formatted percentage here because it smooths out the number to 3 places + local satisfaction = tonumber(formatted_percentage) + if satisfaction <= 0 then + style = "flib_slot_button_red_small" + elseif satisfaction < 100 then + style = "flib_slot_button_yellow_small" + end -- else, it stays green + + satisfaction_line = {"", "\n", (formatted_percentage .. "%"), " ", {"fp.satisfied"}} + end + + local name_line, temperature_line = {"", {"fp.tt_title", {"", proto.localised_name}}}, "" + if proto.type == "fluid" and line.class ~= "Floor" then + local temperature_data = line.temperature_data[proto.name] -- exists for any fluid ingredient + table.insert(name_line, temperature_data.annotation) + + local temperature = line.temperatures[proto.name] + if temperature == nil then + style = "flib_slot_button_purple_small" + temperature_line = {"fp.no_temperature_configured"} + else + temperature_line = {"fp.configured_temperature", temperature} + end + end + + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + local tooltip = {"", name_line, temperature_line, number_line, satisfaction_line, + "\n", metadata.action_tooltips["act_on_line_ingredient"]} + + local button = parent_flow.add{type="sprite-button", sprite=proto.sprite, style=style, + tags={mod="fp", on_gui_click="act_on_line_ingredient", line_id=line.id, item_index=index, + on_gui_hover="set_tooltip", context="production_table"}, number=amount, + mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip + + ::skip_ingredient:: + end + + add_catalysts(parent_flow, line, "ingredients", metadata) + + if line.class ~= "Floor" and line.machine.fuel then builders.fuel(line, parent_flow, metadata) end +end + +-- This is not a standard builder function, as it gets called indirectly by the ingredient builder +function builders.fuel(line, parent_flow, metadata) + local fuel = line.machine.fuel + + local amount, number_tooltip = item_views.process_item(metadata.player, fuel, nil, line.machine.amount) + if amount == -1 then return end -- an amount of -1 means it was below the margin of error + + local satisfaction_line = "" ---@type LocalisedString + if metadata.ingredient_satisfaction and fuel.amount > 0 then + local satisfaction_percentage = (fuel.satisfied_amount / fuel.amount) * 100 + local formatted_percentage = util.format.number(satisfaction_percentage, 3) + satisfaction_line = {"", "\n", (formatted_percentage .. "%"), " ", {"fp.satisfied"}} + end + + local name_line, temperature_line = {"fp.tt_title_with_note", fuel.proto.localised_name, {"fp.pu_fuel", 1}}, "" + local style = "flib_slot_button_cyan_small" + + if fuel.proto.type == "fluid" then + local temperature_data = fuel.temperature_data -- exists for any fluid fuel + table.insert(name_line, temperature_data.annotation) + + if fuel.temperature == nil then + style = "flib_slot_button_purple_small" + temperature_line = {"fp.no_temperature_configured"} + else + temperature_line = {"fp.configured_temperature", fuel.temperature} + end + end + + local number_line = (number_tooltip) and {"", "\n", number_tooltip} or "" + local tooltip = {"", name_line, temperature_line, number_line, satisfaction_line, + "\n", metadata.action_tooltips["act_on_line_fuel"]} + + local button = parent_flow.add{type="sprite-button", sprite=fuel.proto.sprite, style=style, + tags={mod="fp", on_gui_click="act_on_line_fuel", fuel_id=fuel.id, on_gui_hover="set_tooltip", + context="production_table"}, number=amount, mouse_button_filter={"left-and-right"}, raise_hover_events=true} + metadata.tooltips[button.index] = tooltip +end + +function builders.line_comment(line, parent_flow, _) + local relevant_line = (line.class == "Floor") and line.first or line + local textfield_comment = parent_flow.add{type="textfield", text=(relevant_line.comment or ""), + tags={mod="fp", on_gui_text_changed="line_comment", line_id=line.id}} + textfield_comment.style.width = 250 + textfield_comment.lose_focus_on_confirm = true +end + + +local all_production_columns = { + -- name, caption, tooltip, alignment + {name="move", caption="", alignment="center"}, + {name="done", caption="", tooltip={"fp.column_done_tt"}, alignment="center"}, + {name="recipe", caption={"fp.pu_recipe", 1}, alignment="left"}, + {name="percentage", caption="% ", tooltip={"fp.column_percentage_tt"}, alignment="center"}, + {name="machine", caption={"fp.pu_machine", 1}, alignment="left"}, + {name="beacon", caption={"fp.pu_beacon", 1}, alignment="left"}, + {name="power", caption={"fp.u_power"}, alignment="center"}, + {name="products", caption={"fp.pu_product", 2}, alignment="left"}, + {name="byproducts", caption={"fp.pu_byproduct", 2}, alignment="left"}, + {name="ingredients", caption={"fp.pu_ingredient", 2}, alignment="left"}, + {name="line_comment", caption={"fp.column_comment"}, alignment="left"} +} + +local function refresh_production_table(player) + local main_elements = util.globals.main_elements(player) + if main_elements.main_frame == nil then return end + + -- Determine the column_count first, because not all columns are nessecarily shown + local preferences = util.globals.preferences(player) + local factory = util.context.get(player, "Factory") --[[@as Factory]] + local floor = util.context.get(player, "Floor") --[[@as Floor]] + + local factory_valid = (factory and factory.valid) + local any_lines_present = (factory_valid) and (floor:count() > 0) or false + + local scroll_pane_production = main_elements.production_box.production_scroll_pane + scroll_pane_production.visible = (factory_valid and any_lines_present) or false + if not factory_valid then return end + scroll_pane_production.clear() + + local production_columns = {} + for _, column_data in ipairs(all_production_columns) do + -- Explicit preferences comparison needed here, as both true and nil columns should be shown + -- Some mods might remove all beacons, in which case the column shouldn't be shown at all + if preferences[column_data.name .. "_column"] ~= false and (next(storage.prototypes.beacons) ~= nil) then + table.insert(production_columns, column_data) + end + end + + local table_production = scroll_pane_production.add{type="table", column_count=(#production_columns+1), + style="fp_table_production"} + table_production.style.horizontal_spacing = 12 + table_production.style.padding = {6, 0, 0, 12} + + -- Column headers + for index, column_data in ipairs(production_columns) do + local caption = (column_data.tooltip) and {"", column_data.caption, "[img=info]"} or column_data.caption + local label_column = table_production.add{type="label", caption=caption, tooltip=column_data.tooltip, + style="bold_label"} + label_column.style.bottom_margin = 6 + table_production.style.column_alignments[index] = column_data.alignment + end + + -- Add pusher to make sure the table takes all available space + table_production.add{type="empty-widget", style="flib_horizontal_pusher"} + + -- Generates some data that is relevant to several different builders + local metadata = generate_metadata(player, factory) + + -- Production lines + local function render_lines(render_floor, indent) + for line in render_floor:iterator() do + for _, column_data in ipairs(production_columns) do + local flow = table_production.add{type="flow", direction="horizontal"} + builders[column_data.name](line, flow, metadata, indent) + end + table_production.add{type="empty-widget"} + + if line.class == "Floor" and preferences.fold_out_subfloors then + render_lines(line, indent + 1) + end + end + end + + render_lines(floor, 0) +end + +local function build_production_table(player) + -- No building necessary as production_box sets everything up + refresh_production_table(player) +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.misc = { + build_gui_element = (function(player, event) + if event.trigger == "main_dialog" then + build_production_table(player) + end + end), + refresh_gui_element = (function(player, event) + local triggers = {production_table=true, production_detail=true, production=true, factory=true, all=true} + if triggers[event.trigger] then refresh_production_table(player) end + end) +} + +return { listeners } diff --git a/modfiles/ui/main/title_bar.lua b/modfiles/ui/main/title_bar.lua new file mode 100644 index 000000000..779e02b68 --- /dev/null +++ b/modfiles/ui/main/title_bar.lua @@ -0,0 +1,150 @@ +-- ** LOCAL UTIL ** +local function toggle_paused_state(player, _, _) + if not game.is_multiplayer() then + local preferences = util.globals.preferences(player) + preferences.pause_on_interface = not preferences.pause_on_interface + + local main_elements = util.globals.main_elements(player) + main_dialog.set_pause_state(player, main_elements.main_frame) + end +end + + +local function refresh_title_bar(player) + local ui_state = util.globals.ui_state(player) + if ui_state.main_elements.main_frame == nil then return end + + local factory = util.context.get(player, "Factory") --[[@as Factory?]] + local title_bar_elements = ui_state.main_elements.title_bar + + title_bar_elements.compact_button.enabled = factory ~= nil and factory.valid + title_bar_elements.pause_button.enabled = (not game.is_multiplayer()) +end + + +local function determine_left_handle_width(player) + local ui_state = util.globals.ui_state(player) + local half_total_width = ui_state.main_dialog_dimensions.width / 2 + + local half_label_width = MAGIC_NUMBERS.titlebar_label_width / 2 + local left_margins = 3 * 8 + 2 * 4 -- horizontal spacing + drag handle margin + local left_buttons_width = 3 * 24 + 2 * 8 -- button width + spacing + + return half_total_width - half_label_width - left_margins - left_buttons_width +end + +local function build_title_bar(player) + local main_elements = util.globals.main_elements(player) + main_elements.title_bar = {} + + local parent_flow = main_elements.flows.top_horizontal + local flow_title_bar = parent_flow.add{type="flow", direction="horizontal", style="frame_header_flow", + tags={mod="fp", on_gui_click="re-center_main_dialog"}} + flow_title_bar.drag_target = main_elements.main_frame + + local button_compact = flow_title_bar.add{type="sprite-button", style="fp_button_frame", + tags={mod="fp", on_gui_click="switch_to_compact_view"}, tooltip={"fp.switch_to_compact_view"}, + sprite="fp_pin", mouse_button_filter={"left"}} + main_elements.title_bar["compact_button"] = button_compact + + local preferences = util.globals.preferences(player) + local button_pause = flow_title_bar.add{type="sprite-button", sprite="fp_play", tooltip={"fp.pause_on_interface"}, + tags={mod="fp", on_gui_click="toggle_pause_game"}, auto_toggle=true, style="fp_button_frame", + toggled=(not preferences.pause_on_interface), mouse_button_filter={"left"}} + button_pause.style.padding = -1 + main_elements.title_bar["pause_button"] = button_pause + + local button_calculator = flow_title_bar.add{type="sprite-button", sprite="fp_calculator", + tooltip={"fp.open_calculator"}, style="fp_button_frame", mouse_button_filter={"left"}, + tags={mod="fp", on_gui_click="open_calculator_dialog"}} + button_calculator.style.padding = -3 + + local left_handle = flow_title_bar.add{type="empty-widget", style="flib_titlebar_drag_handle", + ignored_by_interaction=true} + left_handle.style.horizontally_stretchable = false -- necessary so the other side stretches properly + left_handle.style.width = determine_left_handle_width(player) + flow_title_bar.add{type="label", caption="Factory Planner", style="fp_label_frame_title", + ignored_by_interaction=true} + flow_title_bar.add{type="empty-widget", style="flib_titlebar_drag_handle", ignored_by_interaction=true} + + flow_title_bar.add{type="button", caption={"fp.preferences"}, style="fp_button_frame_tool", + tags={mod="fp", on_gui_click="title_bar_open_preferences"}, mouse_button_filter={"left"}} + + local separation = flow_title_bar.add{type="line", direction="vertical"} + separation.style.height = MAGIC_NUMBERS.title_bar_height - 4 + + local button_close = flow_title_bar.add{type="sprite-button", tags={mod="fp", on_gui_click="exit_main_dialog"}, + sprite="utility/close", tooltip={"fp.close_interface"}, style="fp_button_frame", + mouse_button_filter={"left"}} + button_close.style.padding = 1 + + refresh_title_bar(player) +end + + +-- ** EVENTS ** +local listeners = {} + +listeners.gui = { + on_gui_click = { + { + name = "re-center_main_dialog", + handler = (function(player, _, event) + if event.button == defines.mouse_button_type.middle then + local ui_state = util.globals.ui_state(player) + local main_frame = ui_state.main_elements.main_frame + util.gui.properly_center_frame(player, main_frame, ui_state.main_dialog_dimensions) + end + end) + }, + { + name = "switch_to_compact_view", + handler = (function(player, _, _) + local floor = util.context.get(player, "Floor") + if floor and floor.level > 1 and floor:count() == 1 then + util.context.ascend_floors(player, "up") + end + main_dialog.toggle_districts_view(player, true) + + main_dialog.toggle(player) + util.globals.ui_state(player).compact_view = true + + compact_dialog.toggle(player) + end) + }, + { + name = "exit_main_dialog", + handler = (function(player, _, _) + main_dialog.toggle(player) + end) + }, + { + name = "toggle_pause_game", + handler = toggle_paused_state + }, + { + name = "title_bar_open_preferences", + handler = (function(player, _, _) + util.raise.open_dialog(player, {dialog="preferences"}) + end) + } + } +} + +listeners.misc = { + fp_toggle_pause = (function(player, _) + if main_dialog.is_in_focus(player) then toggle_paused_state(player) end + end), + + build_gui_element = (function(player, event) + if event.trigger == "main_dialog" then + build_title_bar(player) + end + end), + refresh_gui_element = (function(player, event) + local triggers = {title_bar=true, factory=true, all=true} + if triggers[event.trigger] then refresh_title_bar(player) end + end) +} + +return { listeners } diff --git a/modfiles/util/actions.lua b/modfiles/util/actions.lua new file mode 100644 index 000000000..3cdc8f79b --- /dev/null +++ b/modfiles/util/actions.lua @@ -0,0 +1,84 @@ +local _actions = {} + +---@alias ActionLimitations { archive_open: boolean?, matrix_active: boolean? } +---@alias ActiveLimitations { archive_open: boolean, matrix_active: boolean } +---@alias ActionList { [string]: string } + +---@param player LuaPlayer +---@return ActiveLimitations +function _actions.current_limitations(player) + local factory = util.context.get(player, "Factory") --[[@as Factory?]] + return { + archive_open = (factory ~= nil) and factory.archived or false, + matrix_active = (factory ~= nil) and (factory.matrix_free_items ~= nil) or false + } +end + +---@param action_limitations ActionLimitations[] +---@param active_limitations ActiveLimitations +---@return boolean +function _actions.allowed(action_limitations, active_limitations) + -- If a particular limitation is nil, it indicates that the action is allowed regardless + -- If it is non-nil, it needs to match the current state of the limitation exactly + for limitation_name, limitation in pairs(action_limitations) do + if active_limitations[limitation_name] ~= limitation then return false end + end + return true +end + +-- Returns whether rate limiting is active for the given action, stopping it from proceeding +-- This is essentially to prevent duplicate commands in quick succession, enabled by lag +function _actions.rate_limited(player, tick, action_name, timeout) + local ui_state = util.globals.ui_state(player) + + -- If this action has no timeout, reset the last action and allow it + if timeout == nil or game.tick_paused then + ui_state.last_action = nil + return false + end + + local last_action = ui_state.last_action + -- Only disallow action under these specific circumstances + if last_action and last_action.action_name == action_name and (tick - last_action.tick) < timeout then + return true + + else -- set the last action if this action will actually be carried out + ui_state.last_action = { + action_name = action_name, + tick = tick + } + return false + end +end + + +---@param shortcut string +---@return LocalisedString? +function _actions.shortcut_string(shortcut) + if not shortcut then return nil end + local split_modifiers, modifier_string = util.split_string(shortcut, "-"), {""} + for _, modifier in pairs(ftable.slice(split_modifiers, 1, -1)) do + table.insert(modifier_string, {"", {"fp.action_" .. modifier}, " + "}) + end + table.insert(modifier_string, {"fp.action_" .. split_modifiers[#split_modifiers]}) + return {"fp.action_click", modifier_string} +end + +---@param actions ActionDetails[] +---@return LocalisedString +function _actions.generate_tooltip(actions) + local tooltip, any_hidden = {""}, false + for _, action in pairs(actions) do + if action.show then + table.insert(tooltip, {"fp.action_line", action.shortcut_string, {"fp.action_" .. action.name}}) + else + any_hidden = true + end + end + + if any_hidden then table.insert(tooltip, {"fp.action_all"}) end + + return tooltip +end + +return _actions diff --git a/modfiles/util/clipboard.lua b/modfiles/util/clipboard.lua new file mode 100644 index 000000000..ef9c35c00 --- /dev/null +++ b/modfiles/util/clipboard.lua @@ -0,0 +1,95 @@ +local unpackers = { + Product = require("backend.data.Product").unpack, + Floor = require("backend.data.Floor").unpack, + Line = require("backend.data.Line").unpack, + Machine = require("backend.data.Machine").unpack, + Beacon = require("backend.data.Beacon").unpack, + Fuel = require("backend.data.Fuel").unpack, + Module = require("backend.data.Module").unpack +} + +local _clipboard = {} + +---@alias CopyableObject Product | Floor | Line | Machine | Beacon | Module | Fuel +---@alias CopyableObjectParent Factory | Floor | Line | ModuleSet + +---@class ClipboardEntry +---@field class string +---@field packed_object PackedObject +---@field parent CopyableObjectParent? + +-- Copies the given object into the player's clipboard as a packed object +---@param player LuaPlayer +---@param object CopyableObject +function _clipboard.copy(player, object) + local player_table = util.globals.player_table(player) + player_table.clipboard = { + class = object.class, + packed_object = (object.pack ~= nil) and object:pack() or object, + parent = object.parent -- just used for unpacking, will remain a reference even if deleted elsewhere + } + + util.cursor.create_flying_text(player, {"fp.copied_into_clipboard", {"fp.pu_" .. object.class:lower(), 1}}) + util.raise.refresh(player, "paste_button") +end + +-- Tries pasting the player's clipboard content onto the given target +---@param player LuaPlayer +---@param target CopyableObject +function _clipboard.paste(player, target) + local clip = util.globals.player_table(player).clipboard + + if clip == nil then + util.cursor.create_flying_text(player, {"fp.clipboard_empty"}) + else + local clone = nil + if clip.parent then -- only real objects have parents + clone = unpackers[clip.class](clip.packed_object, clip.parent) -- always returns fresh object + clone:validate() + else + clone = ftable.shallow_copy(clip.packed_object) + end + local success, error = target:paste(clone, player) + + if success then -- objects in the clipboard are always valid since it resets on_config_changed + util.cursor.create_flying_text(player, {"fp.pasted_from_clipboard", {"fp.pu_" .. clip.class:lower(), 1}}) + + solver.update(player) + util.raise.refresh(player, "factory") + else + local object_lower, target_lower = {"fp.pl_" .. clip.class:lower(), 1}, {"fp.pl_" .. target.class:lower(), 1} + if error == "incompatible_class" then + util.cursor.create_flying_text(player, {"fp.clipboard_incompatible_class", object_lower, target_lower}) + elseif error == "incompatible" then + util.cursor.create_flying_text(player, {"fp.clipboard_incompatible", object_lower}) + elseif error == "already_exists" then + util.cursor.create_flying_text(player, {"fp.clipboard_already_exists", target_lower}) + elseif error == "no_empty_slots" then + util.cursor.create_flying_text(player, {"fp.clipboard_no_empty_slots"}) + elseif error == "recipe_irrelevant" then + util.cursor.create_flying_text(player, {"fp.clipboard_recipe_irrelevant"}) + end + end + end +end + +---@param player LuaPlayer +---@param dummy CopyableObject +---@param parent CopyableObjectParent +function _clipboard.dummy_paste(player, dummy, parent) + dummy.dummy = true + parent:insert(dummy) + _clipboard.paste(player, dummy) + local last = parent:find_last() --[[@as CopyableObject]] + if last.dummy then parent:remove(last) end +end + +---@param player LuaPlayer +---@param classes { [CopyableObject]: boolean } +---@return boolean present +function _clipboard.check_classes(player, classes) + local clip = util.globals.player_table(player).clipboard + return (clip ~= nil and classes[clip.class]) +end + +return _clipboard diff --git a/modfiles/util/context.lua b/modfiles/util/context.lua new file mode 100644 index 000000000..9a9c56a25 --- /dev/null +++ b/modfiles/util/context.lua @@ -0,0 +1,188 @@ +local _context = {} + +---@class ContextTable +---@field object_id ObjectID? +---@field cache ContextCache + +---@class ContextCache +---@field district ObjectID? DistrictID +---@field factories { [ObjectID]: ContextFactories } DistrictID -> + +---@class ContextFactories +---@field factory ObjectID? FactoryID +---@field floors { [ObjectID]: ObjectID } FactoryID -> FloorID + +---@alias ContextObject (District | Factory | Floor) + +---@param player_table PlayerTable +function _context.init(player_table) + player_table.context = { + object_id = nil, + cache = { + district = nil, + factories = {} + } + } +end + + +---@param player LuaPlayer +---@param class string +---@return ContextObject? +--- Gets the given object type by going up the hierarchy from the current context +function _context.get(player, class) + local player_table = util.globals.player_table(player) + local object = OBJECT_INDEX[player_table.context.object_id] + + repeat + if object.class == class then + return object --[[@as ContextObject]] + end + object = object.parent + until object == nil + + return nil +end + +---@param player LuaPlayer +---@param object ContextObject +---@param force_district boolean? +--- Restores the appropriate floor from context cache depending on the given object +--- This covers the happy path, extra care needs to be taken when objects were removed +function _context.set(player, object, force_district) + local context = util.globals.player_table(player).context + local cache = context.cache + + if object.class == "District" then + -- Update cache + cache.district = object.id + + if force_district then context.object_id = object.id; return end + + -- Set lowest-down existing object + local factory_cache = cache.factories[object.id] + if factory_cache and factory_cache.factory then object = OBJECT_INDEX[factory_cache.factory] + elseif object.first then object = OBJECT_INDEX[object.first.id] + else context.object_id = object.id; return end + end + + if object.class == "Factory" then + -- Update cache + local factory_cache = cache.factories[object.parent.id] + if not factory_cache then + factory_cache = { + factory = object.id, + floors = {} + } + cache.factories[object.parent.id] = factory_cache + else + factory_cache.factory = object.id + end + + -- Set lowest-down existing object + local floor_cache_id = factory_cache.floors[object.id] + if floor_cache_id then object = OBJECT_INDEX[floor_cache_id] + else object = OBJECT_INDEX[object.top_floor.id] end -- always exists + end + + if object.class == "Floor" then + -- Needs to be done first so .get() can work + context.object_id = object.id + + -- Update cache + -- Uses .get() method to move up through eventual subfloors + local factory = _context.get(player, "Factory") --[[@as Factory]] + local floors_cache = cache.factories[factory.parent.id].floors + -- The above cache is guaranteed to exist to be able to get here + floors_cache[factory.id] = object.id + end +end + +---@param player LuaPlayer +---@param object (District | Factory) +---@return ContextObject? replacement +--- Cleans up after the given object was removed and tries to find a replacement +function _context.remove(player, object) + local cache = util.globals.player_table(player).context.cache + + -- Clean up the cache from the removed object + if object.class == "District" then + if cache.district == object.id then cache.district = nil end + cache.factories[object.id] = nil + elseif object.class == "Factory" then + local factory_cache = cache.factories[object.parent.id] + if factory_cache.factory == object.id then factory_cache.factory = nil end + factory_cache.floors[object.id] = nil + end + + -- Try finding an adjacent object to return + local filter = (object.class == "Factory") and { archived = object.archived } or {} + + local previous = object.parent:find(filter, object["previous"], "previous") + if previous then return previous end + local next = object.parent:find(filter, object["next"], "next") + if next then return next end + + return nil -- none found, caller needs to sort it out +end + + +---@alias FloorDestination "up" | "top" + +---@param player LuaPlayer +---@param destination FloorDestination +---@return boolean success +function _context.ascend_floors(player, destination) + local floor = _context.get(player, "Floor") --[[@as Floor?]] + if floor == nil then return false end + + local selected_floor = nil + if destination == "up" and floor.level > 1 then + selected_floor = floor.parent + elseif destination == "top" then + local top_floor = _context.get(player, "Factory").top_floor + if top_floor ~= floor then selected_floor = top_floor end + end + + if selected_floor ~= nil then + -- Reset the subfloor we moved from if it doesn't have any additional recipes + if floor:count() == 1 then floor.parent:replace(floor, floor.first) end + + _context.set(player, selected_floor) + return true + else + return false + end +end + + +---@param player LuaPlayer +--- Clean up cache after a config change that potentially deleted objects +function _context.validate(player) + local player_table = util.globals.player_table(player) + local context = player_table.context + local cache = context.cache + + -- Using existance in OBJECT_INDEX is valid here as this is only called after a reload, + -- which implies that only objects that are still in used have been re-indexed. + + if not OBJECT_INDEX[cache.district] then cache.district = nil end + + for district_id, factory_cache in pairs(cache.factories) do + if not OBJECT_INDEX[district_id] then cache.factories[district_id] = nil end + + if not OBJECT_INDEX[factory_cache.factory] then factory_cache.factory = nil end + + for factory_id, floor_id in pairs(factory_cache.floors) do + if not (OBJECT_INDEX[factory_id] and OBJECT_INDEX[floor_id]) then + factory_cache.floors[factory_id] = nil + end + end + end + + if not (context.object_id and OBJECT_INDEX[context.object_id]) then + _context.set(player, player_table.realm.first) + end +end + +return _context diff --git a/modfiles/util/cursor.lua b/modfiles/util/cursor.lua new file mode 100644 index 000000000..1db1b3adb --- /dev/null +++ b/modfiles/util/cursor.lua @@ -0,0 +1,260 @@ +local _cursor = {} + +---@param player LuaPlayer +---@param text LocalisedString +function _cursor.create_flying_text(player, text) + player.create_local_flying_text{text=text, create_at_cursor=true} +end + + +---@param player LuaPlayer +---@param blueprint_entities BlueprintEntity[] +local function set_cursor_blueprint(player, blueprint_entities) + local script_inventory = game.create_inventory(1) + local blank_slot = script_inventory[1] + + blank_slot.set_stack{name="blueprint"} + blank_slot.set_blueprint_entities(blueprint_entities) + player.clear_cursor() + player.add_to_clipboard(blank_slot) + player.activate_paste() + script_inventory.destroy() +end + + +---@param player LuaPlayer +---@param line Line +---@param object Machine | Beacon +---@return boolean success +function _cursor.set_entity(player, line, object) + local entity_prototype = prototypes.entity[object.proto.name] + if entity_prototype.has_flag("not-blueprintable") or not entity_prototype.has_flag("player-creation") + or not object.proto.built_by_item then + _cursor.create_flying_text(player, {"fp.add_to_cursor_failed", entity_prototype.localised_name}) + return false + end + + local items_list, slot_index = {}, 0 + if object.class == "Beacon" or object.proto.effect_receiver.uses_module_effects then + local inventory = defines.inventory[object.proto.prototype_category .. "_modules"] + for module in object.module_set:iterator() do + local inventory_list = {} + for i = 1, module.amount do + table.insert(inventory_list, { + inventory = inventory, + stack = slot_index + }) + slot_index = slot_index + 1 + end + + table.insert(items_list, { + id = { + name = module.proto.name, + quality = module.quality_proto.name + }, + items = { + in_inventory = inventory_list + } + }) + end + end + + -- Put item directly into the cursor if it's simple + if #items_list == 0 and object.proto.prototype_category ~= "assembling_machine" then + player.cursor_ghost = { + name = object.proto.built_by_item.name, + quality = object.quality_proto.name + } + else -- if it's more complex, it needs a blueprint + local blueprint_entity = { + entity_number = 1, + name = object.proto.name, + position = {0, 0}, + quality = object.quality_proto.name, + items = items_list, + recipe = (object.class == "Machine") and line.recipe_proto.name or nil + } + set_cursor_blueprint(player, {blueprint_entity}) + end + + return true +end + +---@param player LuaPlayer +---@param item_filters LogisticFilter[] +function _cursor.set_item_combinator(player, item_filters) + local slot_index = 1 + for _, filter in pairs(item_filters) do + filter.count = math.max(filter.count, 1) -- make sure amounts < 1 are not excluded + filter.index = slot_index + slot_index = slot_index + 1 + end + + local blueprint_entity = { + entity_number = 1, + name = "constant-combinator", + position = {0, 0}, + control_behavior = { + sections = { + sections = { + { + index = 1, + filters = item_filters + } + } + } + } + } + + set_cursor_blueprint(player, {blueprint_entity}) +end + + +---@param player LuaPlayer +---@param blueprint_entity BlueprintEntity +---@param item_proto FPItemPrototype | FPFuelPrototype +---@param amount number +local function add_to_item_combinator(player, blueprint_entity, item_proto, amount) + local timescale = util.globals.preferences(player).timescale + local item_signals, filter_matched = {}, false + local item_name = (item_proto.temperature) and item_proto.base_name or item_proto.name + + do + if not blueprint_entity then goto skip_cursor end + if not blueprint_entity.name == "constant-combinator" then goto skip_cursor end + + local sections = blueprint_entity.control_behavior.sections + if not (sections and sections.sections and #sections.sections == 1) then goto skip_cursor end + + local section = sections.sections[1] + if section.group then goto skip_cursor end + + for _, filter in pairs(section.filters) do + if item_proto.type == (filter.type or "item") and item_name == filter.name then + filter.count = filter.count + (amount * timescale) + filter_matched = true + end + table.insert(item_signals, filter) + end + + ::skip_cursor:: + end + + if not filter_matched then + table.insert(item_signals, { + type = item_proto.type, + name = item_name, + quality = "normal", + comparator = "=", + count = math.ceil(amount * timescale) + }) + end + + _cursor.set_item_combinator(player, item_signals) +end + +---@param player LuaPlayer +---@param cursor_entity CursorEntityData +---@param item_proto FPItemPrototype +local function set_filter_on_inserter(player, cursor_entity, item_proto) + local entity_proto = (cursor_entity.type == "entity") and cursor_entity.entity + or prototypes.entity[cursor_entity.entity.name] + + if item_proto.type == "fluid" then + _cursor.create_flying_text(player, {"fp.inserter_only_filters_items"}) + return + end + + if not entity_proto.filter_count then + _cursor.create_flying_text(player, {"fp.inserter_has_no_filters"}) + return + end + + local new_filter = { + index = 1, + name = item_proto.name, + quality = "normal", + comparator = "=" + } + + if cursor_entity.type == "blueprint" then + local blueprint_entity = cursor_entity.entity + + local filter_count = #blueprint_entity.filters + if filter_count == entity_proto.filter_count then + _cursor.create_flying_text(player, {"fp.inserter_filter_limit_reached"}) + else + -- Silently drop any duplicates + for _, filter in pairs(blueprint_entity.filters) do + if filter.name == item_proto.name then return end + end + + new_filter.index = filter_count + 1 + table.insert(blueprint_entity.filters, new_filter) + set_cursor_blueprint(player, {blueprint_entity}) + end + else + set_cursor_blueprint(player, { + { + entity_number = 1, + name = entity_proto.name, + position = {0, 0}, + quality = cursor_entity.quality, + use_filters = true, + filters = { new_filter } + } + }) + end +end + + +---@alias CursorEntityType "none" | "blueprint" | "entity" +---@alias CursorEntity BlueprintEntity | LuaEntityPrototype +---@alias CursorEntityData { type: CursorEntityType, entity: CursorEntity?, quality: string? } + +---@param player LuaPlayer +---@return CursorEntityData? cursor_entity +local function parse_cursor_entity(player) + local no_entity = {type="none", entity=nil, quality=nil} + + if player.is_cursor_empty() then return no_entity end + local cursor = player.cursor_stack --[[@cast cursor -nil]] + + if cursor.is_blueprint and cursor.is_blueprint_setup() then + local entities = cursor.get_blueprint_entities() + if not (entities and #entities == 1) then return no_entity end + return {type="blueprint", entity=entities[1], quality=entities[1].quality} + else + local valid_for_read, cursor_ghost = cursor.valid_for_read, player.cursor_ghost + local prototype = (valid_for_read) and cursor.prototype or cursor_ghost.name + + local place_result = prototype.place_result + if not place_result then return no_entity end + + local quality = (valid_for_read) and cursor.quality.name or cursor_ghost.quality.name + return {type="entity", entity=place_result, quality=quality} + end +end + +---@param player LuaPlayer +---@param item_proto FPItemPrototype | FPFuelPrototype +---@param amount number +function _cursor.handle_item_click(player, item_proto, amount) + local cursor_entity = parse_cursor_entity(player) + + if cursor_entity.type == "entity" and cursor_entity.entity.type == "inserter" then + set_filter_on_inserter(player, cursor_entity, item_proto) + + elseif cursor_entity.type == "blueprint" then + local entity_proto = prototypes.entity[cursor_entity.entity.name] + if entity_proto.type == "inserter" then + set_filter_on_inserter(player, cursor_entity, item_proto) + else + add_to_item_combinator(player, cursor_entity.entity, item_proto, amount) + end + else + add_to_item_combinator(player, nil, item_proto, amount) + end +end + +return _cursor diff --git a/modfiles/util/effects.lua b/modfiles/util/effects.lua new file mode 100644 index 000000000..84f7bab5c --- /dev/null +++ b/modfiles/util/effects.lua @@ -0,0 +1,105 @@ +local _effects = {} + +---@param effect_tables ModuleEffects[] +---@return ModuleEffects +function _effects.merge(effect_tables) + local effects = ftable.shallow_copy(BLANK_EFFECTS) + for _, effect_table in pairs(effect_tables) do + for name, effect in pairs(effect_table) do + effects[name] = effects[name] + effect + end + end + return effects +end + + +local is_effect_positive = {speed=true, productivity=true, quality=true, + consumption=false, pollution=false} + +---@param name string +---@param value ModuleEffectValue +---@return boolean is_positive_effect +function _effects.is_positive(name, value) + -- Effects are considered positive if their effect is actually in the 'desirable' + -- direction, ie. positive speed, or negative pollution + return (value > 0) == is_effect_positive[name] +end + + +local upper_bound = 327.67 + +---@param effects ModuleEffects +---@param max_prod double +---@return ModuleEffects +---@return { ModuleEffectName: string } +function _effects.limit(effects, max_prod) + local indications = {} + local bounds = { + speed = {lower = -0.8, upper = upper_bound}, + productivity = {lower = 0, upper = max_prod or upper_bound}, + quality = {lower = 0, upper = upper_bound/10}, + consumption = {lower = -0.8, upper = upper_bound}, + pollution = {lower = -0.8, upper = upper_bound} + } + + -- Bound effects and note the indication if relevant + for name, effect in pairs(effects) do + if effect < bounds[name].lower then + effects[name] = bounds[name].lower + indications[name] = "[img=fp_limited_down]" + elseif effect > bounds[name].upper then + effects[name] = bounds[name].upper + indications[name] = "[img=fp_limited_up]" + end + end + + return effects, indications +end + + +---@class FormatModuleEffectsOptions +---@field indications { ModuleEffectName: string }? +---@field machine_effects ModuleEffects? +---@field recipe_effects ModuleEffects? + +local function format_effect(value, color) + if value == nil then return "" end + -- Force display of either a '+' or '-', also round the result + local display_value = ("%+d"):format(math.floor((value * 100) + 0.5)) + return {"fp.effect_value", color, display_value} +end + +-- Formats the given effects for use in a tooltip +---@param module_effects ModuleEffects +---@param options FormatModuleEffectsOptions? +---@return LocalisedString +function _effects.format(module_effects, options) + options = options or {} + options.indications = options.indications or {} + options.machine_effects = options.machine_effects or {} + options.recipe_effects = options.recipe_effects or {} + + local tooltip_lines = {""} + for effect_name, _ in pairs(BLANK_EFFECTS) do + local module_effect = module_effects[effect_name] + local machine_effect = options.machine_effects[effect_name] + local recipe_effect = options.recipe_effects[effect_name] + + if options.indications[effect_name] ~= nil or module_effect ~= 0 + or (machine_effect ~= nil and machine_effect ~= 0) + or (recipe_effect ~= nil and recipe_effect ~= 0) then + local module_percentage = format_effect(module_effect, "#FFE6C0") + local machine_percentage = format_effect(machine_effect, "#7CFF01") + local recipe_percentage = format_effect(recipe_effect, "#01FFF4") + + if #tooltip_lines > 1 then table.insert(tooltip_lines, "\n") end + table.insert(tooltip_lines, {"fp.effect_line", {"fp." .. effect_name}, module_percentage, + machine_percentage, recipe_percentage, options.indications[effect_name] or ""}) + end + end + + if #tooltip_lines > 1 then return tooltip_lines + else return {"fp.none"} end +end + +return _effects diff --git a/modfiles/util/format.lua b/modfiles/util/format.lua new file mode 100644 index 000000000..58235d7bc --- /dev/null +++ b/modfiles/util/format.lua @@ -0,0 +1,107 @@ +local _format = {} + +-- Formats given number to given number of significant digits +---@param number number +---@param precision integer +---@return string formatted_number +function _format.number(number, precision) + -- To avoid scientific notation, chop off the decimals points for big numbers + if (number / (10 ^ precision)) >= 1 then + return ("%d"):format(number) + else + -- Set very small numbers to 0 + if number < (0.1 ^ precision) then + number = 0 + + -- Decrease significant digits for every zero after the decimal point + -- This keeps the number of digits after the decimal point constant + elseif number < 1 then + local n = number + while n < 1 do + precision = precision - 1 + n = n * 10 + end + end + + -- Show the number in the shortest possible way + return ("%." .. precision .. "g"):format(number) + end +end + +-- Returns string representing the given power +---@param value number +---@param unit string +---@param precision integer +---@return LocalisedString formatted_number +function _format.SI_value(value, unit, precision) + local prefixes = {"", "kilo", "mega", "giga", "tera", "peta", "exa", "zetta", "yotta"} + local units = { + ["W"] = {"fp.unit_watt"}, + ["J"] = {"fp.unit_joule"}, + ["E/m"] = {"", {"fp.unit_emissions"}, "/", {"fp.unit_minute"}} + } + + local sign = (value >= 0) and "" or "-" + value = math.abs(value) or 0 + + local scale_counter = 0 + -- Determine unit of the energy consumption, while keeping the result above 1 (ie no 0.1kW, but 100W) + while scale_counter < #prefixes and value > (1000 ^ (scale_counter + 1)) do + scale_counter = scale_counter + 1 + end + + -- Round up if energy consumption is close to the next tier + if (value / (1000 ^ scale_counter)) > 999 then + scale_counter = scale_counter + 1 + end + + value = value / (1000 ^ scale_counter) + local prefix = (scale_counter == 0) and "" or {"fp.prefix_" .. prefixes[scale_counter + 1]} + return {"", sign .. util.format.number(value, precision) .. " ", prefix, units[unit]} +end + + +---@param count number +---@param round_number boolean +---@return string? formatted_count +---@return LocalisedString tooltip_line +function _format.machine_count(count, round_number) + if count == 0 then return nil, {""} end + + -- The formatting is used to 'round down' when the decimal is very small + local formatted_count = util.format.number(count, 3) + local tooltip_count = formatted_count + + -- If the formatting returns 0, it is a very small number, so show it as 0.001 + if formatted_count == "0" then + tooltip_count = "≤0.001" + formatted_count = "0.01" -- shows up as 0.0 on the button + end + + if round_number then formatted_count = tostring(math.ceil(formatted_count --[[@as number]])) end + + local plural_parameter = (tooltip_count == "1") and 1 or 2 + local tooltip_line = {"", "\n", tooltip_count, " ", {"fp.pl_machine", plural_parameter}} + + return formatted_count, tooltip_line +end + + +---@param ticks number +---@return LocalisedString formatted_time +function _format.time(ticks) + if ticks == 0 then return {"fp.none"} end + local seconds = ticks / 60 + + local minutes = math.floor(seconds / 60) + local minutes_string = (minutes > 0) and {"fp.time_minutes", minutes, minutes} or "" + + seconds = math.floor(seconds - (60 * minutes)) + local seconds_string = (seconds > 0) and {"fp.time_seconds", seconds, seconds} or "" + + return (seconds_string ~= "" and minutes_string ~= "") + and {"", minutes_string, ", ", seconds_string} + or {"", minutes_string, seconds_string} -- one or none will be non-"" + end + +return _format diff --git a/modfiles/util/globals.lua b/modfiles/util/globals.lua new file mode 100644 index 000000000..5bd0b0124 --- /dev/null +++ b/modfiles/util/globals.lua @@ -0,0 +1,27 @@ +local _globals = {} + +---@param player LuaPlayer +---@return PlayerTable +function _globals.player_table(player) return storage.players[player.index] end + +---@param player LuaPlayer +---@return PreferencesTable +function _globals.preferences(player) return storage.players[player.index].preferences end + +---@param player LuaPlayer +---@return UIStateTable +function _globals.ui_state(player) return storage.players[player.index].ui_state end + +---@param player LuaPlayer +---@return table? +function _globals.modal_data(player) return storage.players[player.index].ui_state.modal_data end + +---@param player LuaPlayer +---@return table +function _globals.main_elements(player) return storage.players[player.index].ui_state.main_elements end + +---@param player LuaPlayer +---@return table +function _globals.modal_elements(player) return storage.players[player.index].ui_state.modal_data.modal_elements end + +return _globals diff --git a/modfiles/util/gui.lua b/modfiles/util/gui.lua new file mode 100644 index 000000000..714827fa7 --- /dev/null +++ b/modfiles/util/gui.lua @@ -0,0 +1,200 @@ +local mod_gui = require("mod-gui") + +local _gui = { switch = {}, mod = {} } + + +-- Adds an on/off-switch including a label with tooltip to the given flow +-- Automatically converts boolean state to the appropriate switch_state +---@param parent_flow LuaGuiElement +---@param action string? +---@param additional_tags Tags +---@param state SwitchState | boolean +---@param caption LocalisedString? +---@param tooltip LocalisedString? +---@param label_first boolean? +---@return LuaGuiElement created_switch +function _gui.switch.add_on_off(parent_flow, action, additional_tags, state, caption, tooltip, label_first) + if type(state) == "boolean" then state = util.gui.switch.convert_to_state(state) end + + local flow = parent_flow.add{type="flow", direction="horizontal"} + flow.style.vertical_align = "center" + local switch, label ---@type LuaGuiElement, LuaGuiElement + + local function add_switch() + additional_tags.mod = "fp"; additional_tags.on_gui_switch_state_changed = action + switch = flow.add{type="switch", tags=additional_tags, switch_state=state, + left_label_caption={"fp.on"}, right_label_caption={"fp.off"}} + end + + local function add_label() + caption = (tooltip ~= nil) and {"", caption, " [img=info]"} or caption + label = flow.add{type="label", caption=caption, tooltip=tooltip} + end + + if label_first then add_label(); add_switch(); label.style.right_margin = 8 + else add_switch(); add_label(); label.style.left_margin = 8 end + + return switch +end + +---@param state SwitchState +---@return boolean converted_state +function _gui.switch.convert_to_boolean(state) + return (state == "left") and true or false +end + +---@param boolean boolean +---@return SwitchState converted_state +function _gui.switch.convert_to_state(boolean) + return boolean and "left" or "right" +end + + +local function check_empty_flow(player) + local button_flow = mod_gui.get_button_flow(player) + -- parent.parent is to check that I'm not deleting a top level element. Now, I have no idea how that + -- could ever be a top level element, but oh well, can't know everything now can we? + if #button_flow.children_names == 0 and button_flow.parent.parent then + button_flow.parent.destroy() + end +end + +-- Destroys the toggle-main-dialog-button if present +---@param player LuaPlayer +local function destroy_mod_gui(player) + local button_flow = mod_gui.get_button_flow(player) + local mod_gui_button = button_flow["fp_button_toggle_interface"] + if mod_gui_button then mod_gui_button.destroy() end +end + +-- Toggles the visibility of the toggle-main-dialog-button +---@param player LuaPlayer +function _gui.toggle_mod_gui(player) + local enable = util.globals.preferences(player).show_gui_button + + local frame_flow = mod_gui.get_button_flow(player) + local mod_gui_button = frame_flow["fp_button_toggle_interface"] + + if enable and not mod_gui_button then + local tooltip = {"", {"shortcut-name.fp_open_interface"}, " (", {"fp.toggle_interface"}, ")"} + frame_flow.add{type="sprite-button", name="fp_button_toggle_interface", sprite="fp_mod_gui", + tooltip=tooltip, tags={mod="fp", on_gui_click="mod_gui_toggle_interface"}, + style=mod_gui.button_style, mouse_button_filter={"left"}} + elseif mod_gui_button then -- use the destroy function for possible cleanup reasons + destroy_mod_gui(player) + end + + -- The simple fact of getting the button flow creates it, so make sure + -- it doesn't stay around if it's empty + check_empty_flow(player) +end + + +-- Properly centers the given frame (need width/height parameters cause no API-read exists) +---@param player LuaPlayer +---@param frame LuaGuiElement +---@param dimensions DisplayResolution +function _gui.properly_center_frame(player, frame, dimensions) + local resolution, scale = player.display_resolution, player.display_scale + local x_offset = ((resolution.width - (dimensions.width * scale)) / 2) + local y_offset = ((resolution.height - (dimensions.height * scale)) / 2) + frame.location = {x_offset, y_offset} +end + +---@param player LuaPlayer +---@return DisplayResolution +function _gui.calculate_scaled_resolution(player) + local resolution, scale = player.display_resolution, player.display_scale + return {width=math.ceil(resolution.width / scale), height=math.ceil(resolution.height / scale)} +end + +---@param textfield LuaGuiElement +---@param decimal boolean +---@param negative boolean +function _gui.setup_numeric_textfield(textfield, decimal, negative) + textfield.lose_focus_on_confirm = true + textfield.numeric = true + textfield.allow_decimal = (decimal or false) + textfield.allow_negative = (negative or false) +end + +---@param textfield LuaGuiElement +function _gui.select_all(textfield) + textfield.focus() + textfield.select_all() +end + +-- Destroys all GUIs so they are loaded anew the next time they are shown +---@param player LuaPlayer +function _gui.reset_player(player) + destroy_mod_gui(player) -- mod_gui button + check_empty_flow(player) -- make sure no empty flow is left behind + + for _, gui_element in pairs(player.gui.screen.children) do -- all mod frames + if gui_element.valid and gui_element.get_mod() == "factoryplanner" then + gui_element.destroy() + end + end +end + + +---@param emissions number +---@param district District +---@return LocalisedString tooltip +function _gui.format_emissions(emissions, district) + if emissions == 0 then + return {"fp.emissions_none"} + else + local pollutant = {"airborne-pollutant-name." .. district.location_proto.pollutant_type} + local emission = util.format.SI_value(emissions, "E/m", 3) + return {"fp.emissions_line", pollutant, emission} + end +end + + +local expression_variables = {k=1000, K=1000, m=1000000, M=1000000, g=1000000000, G=1000000000} + +---@param textfield LuaGuiElement +---@return number? expression +function _gui.parse_expression_field(textfield) + local expression = nil + pcall(function() expression = helpers.evaluate_expression(textfield.text, expression_variables) end) + return expression +end + +---@param textfield LuaGuiElement +function _gui.update_expression_field(textfield) + local expression = _gui.parse_expression_field(textfield) + + textfield.style = (textfield.text ~= "" and expression == nil) and "invalid_value_textfield" or "textbox" + textfield.style.width = textfield.tags.width --[[@as number]] -- this is stupid but styles work out that way +end + +---@param textfield LuaGuiElement +---@return boolean confirmed +function _gui.confirm_expression_field(textfield) + local expression = _gui.parse_expression_field(textfield) + if expression then + local exp = tostring(expression) + if exp == textfield.text then + return true + else + textfield.text = exp + end + end + return false +end + + +function _gui.add_quality_dropdown(parent_flow, selected_index, tags) + local items = {} + for _, quality in pairs(storage.prototypes.qualities) do + local label = {"", "[quality=" .. quality.name .. "] ", quality.localised_name} + table.insert(items, label) + end + + parent_flow.add{type="drop-down", items=items, selected_index=selected_index, + style="fp_drop-down_slim", tags=tags} +end + +return _gui diff --git a/modfiles/util/llog.lua b/modfiles/util/llog.lua new file mode 100644 index 000000000..6cda3e67c --- /dev/null +++ b/modfiles/util/llog.lua @@ -0,0 +1,73 @@ +--- Internally used logging function for a single table +---@param table_to_print AnyBasic +---@return string +local function _llog(table_to_print) + local excludes = LLOG_EXCLUDES or {} -- Optional custom excludes defined by the parent mod + + if type(table_to_print) ~= "table" then return (tostring(table_to_print)) end + + local tab_width, super_space = 2, "" + for _=0, tab_width-1, 1 do super_space = super_space .. " " end + + ---@param table_part { [AnyBasic]: AnyBasic } + ---@param depth number + ---@return string + local function format(table_part, depth) + if not next(table_part) then return "{}" end + + local spacing = "" + for _=0, depth-1, 1 do spacing = spacing .. " " end + local super_spacing = spacing .. super_space ---@type string + + local out, first_element = "{", true + local preceding_name = 0 + + for name, value in pairs(table_part) do + local element = tostring(value) + if type(value) == "string" then + element = "'" .. element .. "'" + elseif type(value) == "table" then + if excludes[name] ~= nil then + element = value.name or "EXCLUDE" + else + element = format(value, depth+tab_width) + end + end + + local comma = (first_element) and "" or "," + first_element = false + + -- Print string and discontinuous numerical keys only + local key = (type(name) == "number" and preceding_name+1 == name) and "" or (name .. " = ") + preceding_name = name --[[@as number]] + + out = out .. comma .. "\n" .. super_spacing .. key .. element + end + + return (out .. "\n" .. spacing .. "}") + end + + return format(table_to_print, 0) +end + +-- User-facing function, handles multiple tables at being passed at once +---@param ... AnyBasic +local function llog(...) + local info = debug.getinfo(2, "Sl") + local out = "\n" .. info.short_src .. ":" .. info.currentline .. ":" + + local arg_nr = table_size({...}) + if arg_nr == 0 then + out = out .. " No arguments" + elseif arg_nr == 1 then + out = out .. " " .. _llog(select(1, ...)) + else + for index, table_to_print in ipairs{...} do + out = out .. "\n" .. index .. ": " .. _llog(table_to_print) + end + end + + log(out) +end + +return llog diff --git a/modfiles/util/messages.lua b/modfiles/util/messages.lua new file mode 100644 index 000000000..1763030be --- /dev/null +++ b/modfiles/util/messages.lua @@ -0,0 +1,44 @@ +local _messages = {} + +---@alias MessageCategory "error" | "warning" | "hint" + +---@class PlayerMessage +---@field category MessageCategory +---@field text LocalisedString +---@field lifetime integer + +---@param player LuaPlayer +---@param category MessageCategory +---@param message LocalisedString +---@param lifetime integer +function _messages.raise(player, category, message, lifetime) + local messages = util.globals.ui_state(player).messages + table.insert(messages, {category=category, text=message, lifetime=lifetime}) +end + +---@param player LuaPlayer +function _messages.refresh(player) + -- Only refresh messages if the user is actually looking at them + if not main_dialog.is_in_focus(player) then return end + + local ui_state = util.globals.ui_state(player) + local message_frame = ui_state.main_elements["messages_frame"] + if not message_frame or not message_frame.valid then return end + + local messages = ui_state.messages + message_frame.visible = (next(messages) ~= nil) + + local message_flow = ui_state.main_elements["messages_flow"] + message_flow.clear() + + for i=#messages, 1, -1 do + local message = messages[i] ---@type PlayerMessage + local caption = {"", "[img=warning-white] ", {"fp." .. message.category .. "_message", message.text}} + message_flow.add{type="label", caption=caption, style="bold_label"} + + message.lifetime = message.lifetime - 1 + if message.lifetime == 0 then table.remove(messages, i) end + end +end + +return _messages diff --git a/modfiles/util/nth_tick.lua b/modfiles/util/nth_tick.lua new file mode 100644 index 000000000..b4f0bb5ba --- /dev/null +++ b/modfiles/util/nth_tick.lua @@ -0,0 +1,46 @@ +local _nth_tick = {} + +---@alias NthTickEvent { handler_name: string, metadata: table } + +---@param tick Tick +local function register_nth_tick_handler(tick) + script.on_nth_tick(tick, function(nth_tick_data) + local event_data = storage.nth_tick_events[nth_tick_data.nth_tick] + local handler = GLOBAL_HANDLERS[event_data.handler_name] ---@type function + handler(event_data.metadata) + util.nth_tick.cancel(tick) + end) +end + + +---@param desired_tick Tick +---@param handler_name string +---@param metadata table +---@return Tick +function _nth_tick.register(desired_tick, handler_name, metadata) + local actual_tick = desired_tick + -- Search until the next free nth_tick is found + while (storage.nth_tick_events[actual_tick] ~= nil) do + actual_tick = actual_tick + 1 + end + + storage.nth_tick_events[actual_tick] = {handler_name=handler_name, metadata=metadata} + register_nth_tick_handler(actual_tick) + + return actual_tick -- let caller know which tick they actually got +end + +---@param tick Tick +function _nth_tick.cancel(tick) + script.on_nth_tick(tick, nil) + storage.nth_tick_events[tick] = nil +end + +function _nth_tick.register_all() + if not storage.nth_tick_events then return end + for tick, _ in pairs(storage.nth_tick_events) do + register_nth_tick_handler(tick) + end +end + +return _nth_tick diff --git a/modfiles/util/porter.lua b/modfiles/util/porter.lua new file mode 100644 index 000000000..fe1494edb --- /dev/null +++ b/modfiles/util/porter.lua @@ -0,0 +1,150 @@ +local migrator = require("backend.handlers.migrator") +local Factory = require("backend.data.Factory") + +local _porter = {} + +---@class ExportTable +---@field export_modset ModToVersion +---@field factories PackedFactory[] + +---@class ImportTable +---@field export_modset ModToVersion +---@field factories Factory[] + +---@alias ExportString string + +-- Converts the given factories into a factory exchange string +---@param factories Factory[] +---@return ExportString +function _porter.generate_export_string(factories) + local export_table = { + export_modset = storage.installed_mods, + factories = {} + } + + for _, factory in pairs(factories) do + table.insert(export_table.factories, factory:pack()) + end + + return helpers.encode_string(helpers.table_to_json(export_table)) --[[@as ExportString]] +end + +-- Converts the given factory exchange string into a temporary Factory +---@param export_string ExportString +---@return ImportTable? +---@return string? +function _porter.process_export_string(export_string) + local export_table = nil ---@type AnyBasic? + + if not pcall(function() + export_table = helpers.json_to_table(helpers.decode_string(export_string) --[[@as string]]) + assert(type(export_table) == "table") + end) then return nil, "decoding_failure" end + ---@cast export_table ExportTable + + if not pcall(function() + -- Works for any old version of the mod, which is not the case for other migrations + migrator.migrate_export_table(export_table) + end) then return nil, "migration_failure" end + + -- Include the modset at export time to be displayed to the user if a factory is invalid + local import_table = {export_modset = export_table.export_modset, factories = {}} ---@type ImportTable + + if not pcall(function() -- Unpacking and validating could be pcall-ed separately, but that's too many slow pcalls + for _, packed_factory in pairs(export_table.factories) do + local unpacked_factory = Factory.unpack(packed_factory) + unpacked_factory:validate() + table.insert(import_table.factories, unpacked_factory) + end + end) then return nil, "unpacking_failure" end + + -- This is not strictly a decoding failure, but close enough + if #import_table.factories == 0 then return nil, "decoding_failure" end + + return import_table, nil +end + +---@alias UpdatedMods { [string]: { old: VersionString, current: VersionString } } + +-- Creates a nice tooltip laying out which mods were added, removed and updated since the factory became invalid +---@param old_modset ModToVersion +---@return LocalisedString +function _porter.format_modset_diff(old_modset) + if not old_modset then return "" end + + ---@type { added: ModToVersion, removed: ModToVersion, updated: UpdatedMods } + local changes = {added={}, removed={}, updated={}} + local new_modset = script.active_mods + + -- Determine changes by running through both sets of mods once each + for name, current_version in pairs(new_modset) do + local old_version = old_modset[name] + if not old_version then + changes.added[name] = current_version + elseif old_version ~= current_version then + changes.updated[name] = {old=old_version, current=current_version} + end + end + + for name, old_version in pairs(old_modset) do + if not new_modset[name] then + changes.removed[name] = old_version + end + end + + -- Compose tooltip from all three types of changes + local tooltip = {"", {"fp.factory_modset_changes"}} ---@type LocalisedString + local current_table, next_index = tooltip, 3 + + if next(changes.added) then + current_table, next_index = util.build_localised_string( + {"fp.factory_mod_added"}, current_table, next_index) + for name, version in pairs(changes.added) do + current_table, next_index = util.build_localised_string( + {"fp.factory_mod_and_version", name, version}, current_table, next_index) + end + end + + if next(changes.removed) then + current_table, next_index = util.build_localised_string( + {"fp.factory_mod_removed"}, current_table, next_index) + for name, version in pairs(changes.removed) do + current_table, next_index = util.build_localised_string( + {"fp.factory_mod_and_version", name, version}, current_table, next_index) + end + end + + if next(changes.updated) then + current_table, next_index = util.build_localised_string( + {"fp.factory_mod_updated"}, current_table, next_index) + for name, versions in pairs(changes.updated) do + current_table, next_index = util.build_localised_string( + {"fp.factory_mod_and_versions", name, versions.old, versions.current}, current_table, next_index) + end + end + + -- Return an empty string if no changes were found, ie. the tooltip is still only the header + return (table_size(tooltip) == 2) and "" or tooltip +end + +-- Adds given export_string-factories to the current factory +---@param player LuaPlayer +---@param export_string ExportString +function _porter.add_factories(player, export_string) + local import_table, _ = util.porter.process_export_string(export_string) ---@cast import_table -nil + -- No error handling here, as the export_string for this will always be known to work + + local district = util.context.get(player, "District") --[[@as District]] + local first_factory = nil + + for _, factory in pairs(import_table.factories) do + district:insert(factory) + if not factory.valid then factory:repair(player) end + solver.update(player, factory) + first_factory = first_factory or factory + end + + util.context.set(player, first_factory) +end + +return _porter diff --git a/modfiles/util/raise.lua b/modfiles/util/raise.lua new file mode 100644 index 000000000..d070a8289 --- /dev/null +++ b/modfiles/util/raise.lua @@ -0,0 +1,30 @@ +local _raise = {} + +---@param player LuaPlayer +---@param trigger "main_dialog" | "compact_factory" +---@param parent LuaGuiElement? +function _raise.build(player, trigger, parent) + script.raise_event(CUSTOM_EVENTS.build_gui_element, {player_index=player.index, trigger=trigger, parent=parent}) +end + +---@param player LuaPlayer +---@param trigger "all" | "factory" | "production" | "production_detail" | "title_bar" | "district_info" | "factory_list" | "production_bar" | "districts_box" | "item_boxes" | "production_box" | "production_table" | "compact_factory" | "paste_button" +function _raise.refresh(player, trigger) + script.raise_event(CUSTOM_EVENTS.refresh_gui_element, {player_index=player.index, trigger=trigger}) +end + +---@param player LuaPlayer +---@param metadata table +function _raise.open_dialog(player, metadata) + script.raise_event(CUSTOM_EVENTS.open_modal_dialog, {player_index=player.index, metadata=metadata}) +end + +---@param player LuaPlayer +---@param action "submit" | "cancel" | "delete" +---@param skip_opened boolean? +function _raise.close_dialog(player, action, skip_opened) + script.raise_event(CUSTOM_EVENTS.close_modal_dialog, + {player_index=player.index, action=action, skip_opened=skip_opened}) +end + +return _raise diff --git a/modfiles/util/temperature.lua b/modfiles/util/temperature.lua new file mode 100644 index 000000000..6e6f822c1 --- /dev/null +++ b/modfiles/util/temperature.lua @@ -0,0 +1,49 @@ +local _temperature = {} + +---@class TemperatureData +---@field annotation LocalisedString? +---@field applicable_values float[] + +-- Assumes the given ingredient is a fluid +---@param ingredient Ingredient +---@return float? temperature +---@return TemperatureData data +function _temperature.generate_data(ingredient, previous_temperature) + local min_temp = ingredient.minimum_temperature + local max_temp = ingredient.maximum_temperature + + local annotation = nil + if min_temp and not max_temp then + annotation = {"fp.min_temperature", min_temp} + elseif not min_temp and max_temp then + annotation = {"fp.max_temperature", max_temp} + elseif min_temp and max_temp then + annotation = {"fp.min_max_temperature", min_temp, max_temp} + end + + local applicable_values = {} + local previous_still_valid = false + + for _, fluid_proto in pairs(TEMPERATURE_MAP[ingredient.name]) do + if (not min_temp or min_temp <= fluid_proto.temperature) and + (not max_temp or max_temp >= fluid_proto.temperature) then + table.insert(applicable_values, fluid_proto.temperature) + + if previous_temperature == fluid_proto.temperature then + previous_still_valid = true + end + end + end + + local default_temperature = (#applicable_values == 1) and applicable_values[1] or nil + local temperature = (previous_still_valid) and previous_temperature or default_temperature + + local data = { + annotation = {"", " ", annotation}, + applicable_values = applicable_values + } + + return temperature, data +end + +return _temperature diff --git a/modfiles/util/util.lua b/modfiles/util/util.lua new file mode 100644 index 000000000..c7b3dc291 --- /dev/null +++ b/modfiles/util/util.lua @@ -0,0 +1,75 @@ +local _util = { + globals = require("util.globals"), + context = require("util.context"), + clipboard = require("util.clipboard"), + messages = require("util.messages"), + raise = require("util.raise"), + cursor = require("util.cursor"), + gui = require("util.gui"), + format = require("util.format"), + nth_tick = require("util.nth_tick"), + porter = require("util.porter"), + actions = require("util.actions"), + effects = require("util.effects"), + temperature = require("util.temperature") +} + + +-- Still can't believe this is not a thing in Lua +-- This has the added feature of turning any number strings into actual numbers +---@param str string +---@param separator string +---@return string[] +function _util.split_string(str, separator) + local result = {} + for token in string.gmatch(str, "[^" .. separator .. "]+") do + table.insert(result, (tonumber(token) or token)) + end + return result +end + + +-- Fills up the localised table in a smart way to avoid the limit of 20 strings per level +-- To make it stateless, it needs its return values passed back as arguments +-- Uses state to avoid needing to call table_size() because that function is slow +---@param string_to_insert LocalisedString +---@param current_table LocalisedString +---@param next_index integer +---@return LocalisedString, integer +function _util.build_localised_string(string_to_insert, current_table, next_index) + current_table = current_table or {""} + next_index = next_index or 2 + + if next_index == 20 then -- go a level deeper if this one is almost full + local new_table = {""} + current_table[next_index] = new_table + current_table = new_table + next_index = 2 + end + current_table[next_index] = string_to_insert + next_index = next_index + 1 + + return current_table, next_index +end + + +---@param a boolean +---@param b boolean +---@return boolean +function _util.xor(a, b) + return not a ~= not b +end + + +---@param force LuaForce +---@param recipe_name string +---@return ModuleEffectValue productivity_bonus +function _util.get_recipe_productivity(force, recipe_name) + if recipe_name == "custom-mining" then + return force.mining_drill_productivity_bonus + else + return force.recipes[recipe_name].productivity_bonus + end +end + +return _util diff --git a/prototypes/fonts.lua b/prototypes/fonts.lua deleted file mode 100644 index b8d531341..000000000 --- a/prototypes/fonts.lua +++ /dev/null @@ -1,14 +0,0 @@ -data:extend({ - { - type = "font", - name = "fp-label-supersized", - from = "default-bold", - size = 26 - }, - { - type = "font", - name = "fp-button-standard", - from = "default", - size = 16 - } -}) \ No newline at end of file diff --git a/prototypes/hotkeys.lua b/prototypes/hotkeys.lua deleted file mode 100644 index e14da6d55..000000000 --- a/prototypes/hotkeys.lua +++ /dev/null @@ -1,14 +0,0 @@ -data:extend({ - { - type = "custom-input", - name = "fp_toggle_main_dialog", - key_sequence = "CONTROL + R", - consuming = "all" - }, - { - type = "custom-input", - name = "fp_confirm", - key_sequence = "ENTER", - consuming = "script-only" - } -}) \ No newline at end of file diff --git a/prototypes/styles.lua b/prototypes/styles.lua deleted file mode 100644 index bbe5ed1eb..000000000 --- a/prototypes/styles.lua +++ /dev/null @@ -1,22 +0,0 @@ -data.raw["gui-style"].default["fp_button_exit"] = { - type = "button_style", - font = "default-listbox", - height = 30, - width = 30, - top_padding = 2, - left_padding = 6 -} - -data.raw["gui-style"].default["fp_button_with_spacing"] = { - type = "button_style", - left_padding = 12, - right_padding = 12 -} - -data.raw["gui-style"].default["fp_button_action"] = { - type = "button_style", - parent = "fp_button_with_spacing", - font = "fp-button-standard", - height = 29, - top_padding = 1 -} \ No newline at end of file diff --git a/releases/FactoryPlanner_2.0.5.zip b/releases/FactoryPlanner_2.0.5.zip new file mode 100644 index 000000000..03a2a29f9 Binary files /dev/null and b/releases/FactoryPlanner_2.0.5.zip differ diff --git a/releases/FactoryPlanner_2.0.6.zip b/releases/FactoryPlanner_2.0.6.zip new file mode 100644 index 000000000..c5311f90b Binary files /dev/null and b/releases/FactoryPlanner_2.0.6.zip differ diff --git a/releases/FactoryPlanner_2.0.7.zip b/releases/FactoryPlanner_2.0.7.zip new file mode 100644 index 000000000..60dfc73be Binary files /dev/null and b/releases/FactoryPlanner_2.0.7.zip differ diff --git a/releases/factoryplanner_0.17.0.zip b/releases/factoryplanner_0.17.0.zip new file mode 100644 index 000000000..db7340129 Binary files /dev/null and b/releases/factoryplanner_0.17.0.zip differ diff --git a/releases/factoryplanner_0.17.1.zip b/releases/factoryplanner_0.17.1.zip new file mode 100644 index 000000000..a88ce62e8 Binary files /dev/null and b/releases/factoryplanner_0.17.1.zip differ diff --git a/releases/factoryplanner_0.17.10.zip b/releases/factoryplanner_0.17.10.zip new file mode 100644 index 000000000..1c3fd10f8 Binary files /dev/null and b/releases/factoryplanner_0.17.10.zip differ diff --git a/releases/factoryplanner_0.17.11.zip b/releases/factoryplanner_0.17.11.zip new file mode 100644 index 000000000..505001631 Binary files /dev/null and b/releases/factoryplanner_0.17.11.zip differ diff --git a/releases/factoryplanner_0.17.12.zip b/releases/factoryplanner_0.17.12.zip new file mode 100644 index 000000000..ef8f50f70 Binary files /dev/null and b/releases/factoryplanner_0.17.12.zip differ diff --git a/releases/factoryplanner_0.17.13.zip b/releases/factoryplanner_0.17.13.zip new file mode 100644 index 000000000..4ad964a34 Binary files /dev/null and b/releases/factoryplanner_0.17.13.zip differ diff --git a/releases/factoryplanner_0.17.14.zip b/releases/factoryplanner_0.17.14.zip new file mode 100644 index 000000000..e7ced119f Binary files /dev/null and b/releases/factoryplanner_0.17.14.zip differ diff --git a/releases/factoryplanner_0.17.15.zip b/releases/factoryplanner_0.17.15.zip new file mode 100644 index 000000000..80904a981 Binary files /dev/null and b/releases/factoryplanner_0.17.15.zip differ diff --git a/releases/factoryplanner_0.17.16.zip b/releases/factoryplanner_0.17.16.zip new file mode 100644 index 000000000..411a5bebb Binary files /dev/null and b/releases/factoryplanner_0.17.16.zip differ diff --git a/releases/factoryplanner_0.17.17.zip b/releases/factoryplanner_0.17.17.zip new file mode 100644 index 000000000..63e328edb Binary files /dev/null and b/releases/factoryplanner_0.17.17.zip differ diff --git a/releases/factoryplanner_0.17.18.zip b/releases/factoryplanner_0.17.18.zip new file mode 100644 index 000000000..6c27c725a Binary files /dev/null and b/releases/factoryplanner_0.17.18.zip differ diff --git a/releases/factoryplanner_0.17.19.zip b/releases/factoryplanner_0.17.19.zip new file mode 100644 index 000000000..4ac77cb07 Binary files /dev/null and b/releases/factoryplanner_0.17.19.zip differ diff --git a/releases/factoryplanner_0.17.2.zip b/releases/factoryplanner_0.17.2.zip new file mode 100644 index 000000000..3de1201c8 Binary files /dev/null and b/releases/factoryplanner_0.17.2.zip differ diff --git a/releases/factoryplanner_0.17.20.zip b/releases/factoryplanner_0.17.20.zip new file mode 100644 index 000000000..3c56a88ed Binary files /dev/null and b/releases/factoryplanner_0.17.20.zip differ diff --git a/releases/factoryplanner_0.17.21.zip b/releases/factoryplanner_0.17.21.zip new file mode 100644 index 000000000..2b150daac Binary files /dev/null and b/releases/factoryplanner_0.17.21.zip differ diff --git a/releases/factoryplanner_0.17.22.zip b/releases/factoryplanner_0.17.22.zip new file mode 100644 index 000000000..318ae82f3 Binary files /dev/null and b/releases/factoryplanner_0.17.22.zip differ diff --git a/releases/factoryplanner_0.17.23.zip b/releases/factoryplanner_0.17.23.zip new file mode 100644 index 000000000..514abc691 Binary files /dev/null and b/releases/factoryplanner_0.17.23.zip differ diff --git a/releases/factoryplanner_0.17.24.zip b/releases/factoryplanner_0.17.24.zip new file mode 100644 index 000000000..d85f555e5 Binary files /dev/null and b/releases/factoryplanner_0.17.24.zip differ diff --git a/releases/factoryplanner_0.17.25.zip b/releases/factoryplanner_0.17.25.zip new file mode 100644 index 000000000..9970a2b84 Binary files /dev/null and b/releases/factoryplanner_0.17.25.zip differ diff --git a/releases/factoryplanner_0.17.26.zip b/releases/factoryplanner_0.17.26.zip new file mode 100644 index 000000000..14eb80cd3 Binary files /dev/null and b/releases/factoryplanner_0.17.26.zip differ diff --git a/releases/factoryplanner_0.17.27.zip b/releases/factoryplanner_0.17.27.zip new file mode 100644 index 000000000..2557d0ac4 Binary files /dev/null and b/releases/factoryplanner_0.17.27.zip differ diff --git a/releases/factoryplanner_0.17.28.zip b/releases/factoryplanner_0.17.28.zip new file mode 100644 index 000000000..a709d11fd Binary files /dev/null and b/releases/factoryplanner_0.17.28.zip differ diff --git a/releases/factoryplanner_0.17.29.zip b/releases/factoryplanner_0.17.29.zip new file mode 100644 index 000000000..f8241b5e8 Binary files /dev/null and b/releases/factoryplanner_0.17.29.zip differ diff --git a/releases/factoryplanner_0.17.3.zip b/releases/factoryplanner_0.17.3.zip new file mode 100644 index 000000000..d0d53e655 Binary files /dev/null and b/releases/factoryplanner_0.17.3.zip differ diff --git a/releases/factoryplanner_0.17.30.zip b/releases/factoryplanner_0.17.30.zip new file mode 100644 index 000000000..3be9b2da4 Binary files /dev/null and b/releases/factoryplanner_0.17.30.zip differ diff --git a/releases/factoryplanner_0.17.31.zip b/releases/factoryplanner_0.17.31.zip new file mode 100644 index 000000000..962d018f5 Binary files /dev/null and b/releases/factoryplanner_0.17.31.zip differ diff --git a/releases/factoryplanner_0.17.32.zip b/releases/factoryplanner_0.17.32.zip new file mode 100644 index 000000000..c273d70e0 Binary files /dev/null and b/releases/factoryplanner_0.17.32.zip differ diff --git a/releases/factoryplanner_0.17.33.zip b/releases/factoryplanner_0.17.33.zip new file mode 100644 index 000000000..b080ea619 Binary files /dev/null and b/releases/factoryplanner_0.17.33.zip differ diff --git a/releases/factoryplanner_0.17.34.zip b/releases/factoryplanner_0.17.34.zip new file mode 100644 index 000000000..50d1b7523 Binary files /dev/null and b/releases/factoryplanner_0.17.34.zip differ diff --git a/releases/factoryplanner_0.17.35.zip b/releases/factoryplanner_0.17.35.zip new file mode 100644 index 000000000..22fac2db3 Binary files /dev/null and b/releases/factoryplanner_0.17.35.zip differ diff --git a/releases/factoryplanner_0.17.36.zip b/releases/factoryplanner_0.17.36.zip new file mode 100644 index 000000000..09ba78b18 Binary files /dev/null and b/releases/factoryplanner_0.17.36.zip differ diff --git a/releases/factoryplanner_0.17.37.zip b/releases/factoryplanner_0.17.37.zip new file mode 100644 index 000000000..e816f9852 Binary files /dev/null and b/releases/factoryplanner_0.17.37.zip differ diff --git a/releases/factoryplanner_0.17.38.zip b/releases/factoryplanner_0.17.38.zip new file mode 100644 index 000000000..c05dbfa72 Binary files /dev/null and b/releases/factoryplanner_0.17.38.zip differ diff --git a/releases/factoryplanner_0.17.39.zip b/releases/factoryplanner_0.17.39.zip new file mode 100644 index 000000000..b8dca08eb Binary files /dev/null and b/releases/factoryplanner_0.17.39.zip differ diff --git a/releases/factoryplanner_0.17.4.zip b/releases/factoryplanner_0.17.4.zip new file mode 100644 index 000000000..c49fe2ac4 Binary files /dev/null and b/releases/factoryplanner_0.17.4.zip differ diff --git a/releases/factoryplanner_0.17.40.zip b/releases/factoryplanner_0.17.40.zip new file mode 100644 index 000000000..a05831a79 Binary files /dev/null and b/releases/factoryplanner_0.17.40.zip differ diff --git a/releases/factoryplanner_0.17.41.zip b/releases/factoryplanner_0.17.41.zip new file mode 100644 index 000000000..3e2b8e0b1 Binary files /dev/null and b/releases/factoryplanner_0.17.41.zip differ diff --git a/releases/factoryplanner_0.17.42.zip b/releases/factoryplanner_0.17.42.zip new file mode 100644 index 000000000..e52fd8313 Binary files /dev/null and b/releases/factoryplanner_0.17.42.zip differ diff --git a/releases/factoryplanner_0.17.43.zip b/releases/factoryplanner_0.17.43.zip new file mode 100644 index 000000000..f04cd6b79 Binary files /dev/null and b/releases/factoryplanner_0.17.43.zip differ diff --git a/releases/factoryplanner_0.17.44.zip b/releases/factoryplanner_0.17.44.zip new file mode 100644 index 000000000..e16b26366 Binary files /dev/null and b/releases/factoryplanner_0.17.44.zip differ diff --git a/releases/factoryplanner_0.17.45.zip b/releases/factoryplanner_0.17.45.zip new file mode 100644 index 000000000..d14fc935b Binary files /dev/null and b/releases/factoryplanner_0.17.45.zip differ diff --git a/releases/factoryplanner_0.17.46.zip b/releases/factoryplanner_0.17.46.zip new file mode 100644 index 000000000..c9ad0bf1d Binary files /dev/null and b/releases/factoryplanner_0.17.46.zip differ diff --git a/releases/factoryplanner_0.17.47.zip b/releases/factoryplanner_0.17.47.zip new file mode 100644 index 000000000..4ffa5839e Binary files /dev/null and b/releases/factoryplanner_0.17.47.zip differ diff --git a/releases/factoryplanner_0.17.48.zip b/releases/factoryplanner_0.17.48.zip new file mode 100644 index 000000000..81773d588 Binary files /dev/null and b/releases/factoryplanner_0.17.48.zip differ diff --git a/releases/factoryplanner_0.17.49.zip b/releases/factoryplanner_0.17.49.zip new file mode 100644 index 000000000..cd9c8c451 Binary files /dev/null and b/releases/factoryplanner_0.17.49.zip differ diff --git a/releases/factoryplanner_0.17.5.zip b/releases/factoryplanner_0.17.5.zip new file mode 100644 index 000000000..072fae841 Binary files /dev/null and b/releases/factoryplanner_0.17.5.zip differ diff --git a/releases/factoryplanner_0.17.50.zip b/releases/factoryplanner_0.17.50.zip new file mode 100644 index 000000000..315dbe247 Binary files /dev/null and b/releases/factoryplanner_0.17.50.zip differ diff --git a/releases/factoryplanner_0.17.51.zip b/releases/factoryplanner_0.17.51.zip new file mode 100644 index 000000000..c60fff2ac Binary files /dev/null and b/releases/factoryplanner_0.17.51.zip differ diff --git a/releases/factoryplanner_0.17.52.zip b/releases/factoryplanner_0.17.52.zip new file mode 100644 index 000000000..429842371 Binary files /dev/null and b/releases/factoryplanner_0.17.52.zip differ diff --git a/releases/factoryplanner_0.17.53.zip b/releases/factoryplanner_0.17.53.zip new file mode 100644 index 000000000..fa00402e8 Binary files /dev/null and b/releases/factoryplanner_0.17.53.zip differ diff --git a/releases/factoryplanner_0.17.54.zip b/releases/factoryplanner_0.17.54.zip new file mode 100644 index 000000000..a073dd907 Binary files /dev/null and b/releases/factoryplanner_0.17.54.zip differ diff --git a/releases/factoryplanner_0.17.55.zip b/releases/factoryplanner_0.17.55.zip new file mode 100644 index 000000000..6b8001a83 Binary files /dev/null and b/releases/factoryplanner_0.17.55.zip differ diff --git a/releases/factoryplanner_0.17.56.zip b/releases/factoryplanner_0.17.56.zip new file mode 100644 index 000000000..faa1ab46e Binary files /dev/null and b/releases/factoryplanner_0.17.56.zip differ diff --git a/releases/factoryplanner_0.17.57.zip b/releases/factoryplanner_0.17.57.zip new file mode 100644 index 000000000..6c557f656 Binary files /dev/null and b/releases/factoryplanner_0.17.57.zip differ diff --git a/releases/factoryplanner_0.17.58.zip b/releases/factoryplanner_0.17.58.zip new file mode 100644 index 000000000..b7fd5906a Binary files /dev/null and b/releases/factoryplanner_0.17.58.zip differ diff --git a/releases/factoryplanner_0.17.59.zip b/releases/factoryplanner_0.17.59.zip new file mode 100644 index 000000000..0e6290ec0 Binary files /dev/null and b/releases/factoryplanner_0.17.59.zip differ diff --git a/releases/factoryplanner_0.17.6.zip b/releases/factoryplanner_0.17.6.zip new file mode 100644 index 000000000..5e8d82cb7 Binary files /dev/null and b/releases/factoryplanner_0.17.6.zip differ diff --git a/releases/factoryplanner_0.17.60.zip b/releases/factoryplanner_0.17.60.zip new file mode 100644 index 000000000..2b0ec24ad Binary files /dev/null and b/releases/factoryplanner_0.17.60.zip differ diff --git a/releases/factoryplanner_0.17.61.zip b/releases/factoryplanner_0.17.61.zip new file mode 100644 index 000000000..0d50f40d6 Binary files /dev/null and b/releases/factoryplanner_0.17.61.zip differ diff --git a/releases/factoryplanner_0.17.62.zip b/releases/factoryplanner_0.17.62.zip new file mode 100644 index 000000000..0bc3f04de Binary files /dev/null and b/releases/factoryplanner_0.17.62.zip differ diff --git a/releases/factoryplanner_0.17.63.zip b/releases/factoryplanner_0.17.63.zip new file mode 100644 index 000000000..4ba73b5fe Binary files /dev/null and b/releases/factoryplanner_0.17.63.zip differ diff --git a/releases/factoryplanner_0.17.64.zip b/releases/factoryplanner_0.17.64.zip new file mode 100644 index 000000000..16cc869d3 Binary files /dev/null and b/releases/factoryplanner_0.17.64.zip differ diff --git a/releases/factoryplanner_0.17.65.zip b/releases/factoryplanner_0.17.65.zip new file mode 100644 index 000000000..2badaa2a2 Binary files /dev/null and b/releases/factoryplanner_0.17.65.zip differ diff --git a/releases/factoryplanner_0.17.66.zip b/releases/factoryplanner_0.17.66.zip new file mode 100644 index 000000000..525f248a2 Binary files /dev/null and b/releases/factoryplanner_0.17.66.zip differ diff --git a/releases/factoryplanner_0.17.67.zip b/releases/factoryplanner_0.17.67.zip new file mode 100644 index 000000000..3bd21faae Binary files /dev/null and b/releases/factoryplanner_0.17.67.zip differ diff --git a/releases/factoryplanner_0.17.68.zip b/releases/factoryplanner_0.17.68.zip new file mode 100644 index 000000000..b33dcaa2e Binary files /dev/null and b/releases/factoryplanner_0.17.68.zip differ diff --git a/releases/factoryplanner_0.17.69.zip b/releases/factoryplanner_0.17.69.zip new file mode 100644 index 000000000..51a60806d Binary files /dev/null and b/releases/factoryplanner_0.17.69.zip differ diff --git a/releases/factoryplanner_0.17.7.zip b/releases/factoryplanner_0.17.7.zip new file mode 100644 index 000000000..72f1f0f4c Binary files /dev/null and b/releases/factoryplanner_0.17.7.zip differ diff --git a/releases/factoryplanner_0.17.70.zip b/releases/factoryplanner_0.17.70.zip new file mode 100644 index 000000000..88e42dd9f Binary files /dev/null and b/releases/factoryplanner_0.17.70.zip differ diff --git a/releases/factoryplanner_0.17.71.zip b/releases/factoryplanner_0.17.71.zip new file mode 100644 index 000000000..88c5e0b6e Binary files /dev/null and b/releases/factoryplanner_0.17.71.zip differ diff --git a/releases/factoryplanner_0.17.72.zip b/releases/factoryplanner_0.17.72.zip new file mode 100644 index 000000000..35f53ca90 Binary files /dev/null and b/releases/factoryplanner_0.17.72.zip differ diff --git a/releases/factoryplanner_0.17.73.zip b/releases/factoryplanner_0.17.73.zip new file mode 100644 index 000000000..d2a0edde4 Binary files /dev/null and b/releases/factoryplanner_0.17.73.zip differ diff --git a/releases/factoryplanner_0.17.8.zip b/releases/factoryplanner_0.17.8.zip new file mode 100644 index 000000000..25b8cc097 Binary files /dev/null and b/releases/factoryplanner_0.17.8.zip differ diff --git a/releases/factoryplanner_0.17.9.zip b/releases/factoryplanner_0.17.9.zip new file mode 100644 index 000000000..9f88335c0 Binary files /dev/null and b/releases/factoryplanner_0.17.9.zip differ diff --git a/releases/factoryplanner_0.18.1.zip b/releases/factoryplanner_0.18.1.zip new file mode 100644 index 000000000..6e892ca03 Binary files /dev/null and b/releases/factoryplanner_0.18.1.zip differ diff --git a/releases/factoryplanner_0.18.10.zip b/releases/factoryplanner_0.18.10.zip new file mode 100644 index 000000000..55bae23bc Binary files /dev/null and b/releases/factoryplanner_0.18.10.zip differ diff --git a/releases/factoryplanner_0.18.11.zip b/releases/factoryplanner_0.18.11.zip new file mode 100644 index 000000000..c903e4649 Binary files /dev/null and b/releases/factoryplanner_0.18.11.zip differ diff --git a/releases/factoryplanner_0.18.12.zip b/releases/factoryplanner_0.18.12.zip new file mode 100644 index 000000000..e057f8728 Binary files /dev/null and b/releases/factoryplanner_0.18.12.zip differ diff --git a/releases/factoryplanner_0.18.13.zip b/releases/factoryplanner_0.18.13.zip new file mode 100644 index 000000000..426ec00b4 Binary files /dev/null and b/releases/factoryplanner_0.18.13.zip differ diff --git a/releases/factoryplanner_0.18.14.zip b/releases/factoryplanner_0.18.14.zip new file mode 100644 index 000000000..4d5010806 Binary files /dev/null and b/releases/factoryplanner_0.18.14.zip differ diff --git a/releases/factoryplanner_0.18.15.zip b/releases/factoryplanner_0.18.15.zip new file mode 100644 index 000000000..06cd4153d Binary files /dev/null and b/releases/factoryplanner_0.18.15.zip differ diff --git a/releases/factoryplanner_0.18.16.zip b/releases/factoryplanner_0.18.16.zip new file mode 100644 index 000000000..5d64f139d Binary files /dev/null and b/releases/factoryplanner_0.18.16.zip differ diff --git a/releases/factoryplanner_0.18.17.zip b/releases/factoryplanner_0.18.17.zip new file mode 100644 index 000000000..9e5dae272 Binary files /dev/null and b/releases/factoryplanner_0.18.17.zip differ diff --git a/releases/factoryplanner_0.18.18.zip b/releases/factoryplanner_0.18.18.zip new file mode 100644 index 000000000..a0854a89c Binary files /dev/null and b/releases/factoryplanner_0.18.18.zip differ diff --git a/releases/factoryplanner_0.18.19.zip b/releases/factoryplanner_0.18.19.zip new file mode 100644 index 000000000..9379b5762 Binary files /dev/null and b/releases/factoryplanner_0.18.19.zip differ diff --git a/releases/factoryplanner_0.18.2.zip b/releases/factoryplanner_0.18.2.zip new file mode 100644 index 000000000..890fc23ae Binary files /dev/null and b/releases/factoryplanner_0.18.2.zip differ diff --git a/releases/factoryplanner_0.18.20.zip b/releases/factoryplanner_0.18.20.zip new file mode 100644 index 000000000..d205ad5fd Binary files /dev/null and b/releases/factoryplanner_0.18.20.zip differ diff --git a/releases/factoryplanner_0.18.21.zip b/releases/factoryplanner_0.18.21.zip new file mode 100644 index 000000000..228826c26 Binary files /dev/null and b/releases/factoryplanner_0.18.21.zip differ diff --git a/releases/factoryplanner_0.18.22.zip b/releases/factoryplanner_0.18.22.zip new file mode 100644 index 000000000..7b95ab1b8 Binary files /dev/null and b/releases/factoryplanner_0.18.22.zip differ diff --git a/releases/factoryplanner_0.18.23.zip b/releases/factoryplanner_0.18.23.zip new file mode 100644 index 000000000..d00023ee1 Binary files /dev/null and b/releases/factoryplanner_0.18.23.zip differ diff --git a/releases/factoryplanner_0.18.24.zip b/releases/factoryplanner_0.18.24.zip new file mode 100644 index 000000000..f4bba2e9e Binary files /dev/null and b/releases/factoryplanner_0.18.24.zip differ diff --git a/releases/factoryplanner_0.18.25.zip b/releases/factoryplanner_0.18.25.zip new file mode 100644 index 000000000..8837b17ef Binary files /dev/null and b/releases/factoryplanner_0.18.25.zip differ diff --git a/releases/factoryplanner_0.18.26.zip b/releases/factoryplanner_0.18.26.zip new file mode 100644 index 000000000..06728b3e9 Binary files /dev/null and b/releases/factoryplanner_0.18.26.zip differ diff --git a/releases/factoryplanner_0.18.27.zip b/releases/factoryplanner_0.18.27.zip new file mode 100644 index 000000000..7b948d895 Binary files /dev/null and b/releases/factoryplanner_0.18.27.zip differ diff --git a/releases/factoryplanner_0.18.28.zip b/releases/factoryplanner_0.18.28.zip new file mode 100644 index 000000000..1939d434c Binary files /dev/null and b/releases/factoryplanner_0.18.28.zip differ diff --git a/releases/factoryplanner_0.18.29.zip b/releases/factoryplanner_0.18.29.zip new file mode 100644 index 000000000..9f6391a30 Binary files /dev/null and b/releases/factoryplanner_0.18.29.zip differ diff --git a/releases/factoryplanner_0.18.3.zip b/releases/factoryplanner_0.18.3.zip new file mode 100644 index 000000000..690e28e5e Binary files /dev/null and b/releases/factoryplanner_0.18.3.zip differ diff --git a/releases/factoryplanner_0.18.30.zip b/releases/factoryplanner_0.18.30.zip new file mode 100644 index 000000000..8293ef433 Binary files /dev/null and b/releases/factoryplanner_0.18.30.zip differ diff --git a/releases/factoryplanner_0.18.31.zip b/releases/factoryplanner_0.18.31.zip new file mode 100644 index 000000000..aeeb9d958 Binary files /dev/null and b/releases/factoryplanner_0.18.31.zip differ diff --git a/releases/factoryplanner_0.18.32.zip b/releases/factoryplanner_0.18.32.zip new file mode 100644 index 000000000..3c5c359f4 Binary files /dev/null and b/releases/factoryplanner_0.18.32.zip differ diff --git a/releases/factoryplanner_0.18.33.zip b/releases/factoryplanner_0.18.33.zip new file mode 100644 index 000000000..196ed3041 Binary files /dev/null and b/releases/factoryplanner_0.18.33.zip differ diff --git a/releases/factoryplanner_0.18.34.zip b/releases/factoryplanner_0.18.34.zip new file mode 100644 index 000000000..48b93e045 Binary files /dev/null and b/releases/factoryplanner_0.18.34.zip differ diff --git a/releases/factoryplanner_0.18.35.zip b/releases/factoryplanner_0.18.35.zip new file mode 100644 index 000000000..39696d1be Binary files /dev/null and b/releases/factoryplanner_0.18.35.zip differ diff --git a/releases/factoryplanner_0.18.36.zip b/releases/factoryplanner_0.18.36.zip new file mode 100644 index 000000000..c01b58fd4 Binary files /dev/null and b/releases/factoryplanner_0.18.36.zip differ diff --git a/releases/factoryplanner_0.18.37.zip b/releases/factoryplanner_0.18.37.zip new file mode 100644 index 000000000..75fccb984 Binary files /dev/null and b/releases/factoryplanner_0.18.37.zip differ diff --git a/releases/factoryplanner_0.18.38.zip b/releases/factoryplanner_0.18.38.zip new file mode 100644 index 000000000..b4066d1b8 Binary files /dev/null and b/releases/factoryplanner_0.18.38.zip differ diff --git a/releases/factoryplanner_0.18.39.zip b/releases/factoryplanner_0.18.39.zip new file mode 100644 index 000000000..fec320c48 Binary files /dev/null and b/releases/factoryplanner_0.18.39.zip differ diff --git a/releases/factoryplanner_0.18.4.zip b/releases/factoryplanner_0.18.4.zip new file mode 100644 index 000000000..45f37816f Binary files /dev/null and b/releases/factoryplanner_0.18.4.zip differ diff --git a/releases/factoryplanner_0.18.40.zip b/releases/factoryplanner_0.18.40.zip new file mode 100644 index 000000000..d5498a0e1 Binary files /dev/null and b/releases/factoryplanner_0.18.40.zip differ diff --git a/releases/factoryplanner_0.18.41.zip b/releases/factoryplanner_0.18.41.zip new file mode 100644 index 000000000..c68921ebe Binary files /dev/null and b/releases/factoryplanner_0.18.41.zip differ diff --git a/releases/factoryplanner_0.18.42.zip b/releases/factoryplanner_0.18.42.zip new file mode 100644 index 000000000..7f59b25d5 Binary files /dev/null and b/releases/factoryplanner_0.18.42.zip differ diff --git a/releases/factoryplanner_0.18.43.zip b/releases/factoryplanner_0.18.43.zip new file mode 100644 index 000000000..89061c788 Binary files /dev/null and b/releases/factoryplanner_0.18.43.zip differ diff --git a/releases/factoryplanner_0.18.44.zip b/releases/factoryplanner_0.18.44.zip new file mode 100644 index 000000000..1e22b2a9d Binary files /dev/null and b/releases/factoryplanner_0.18.44.zip differ diff --git a/releases/factoryplanner_0.18.45.zip b/releases/factoryplanner_0.18.45.zip new file mode 100644 index 000000000..6ef47e9a2 Binary files /dev/null and b/releases/factoryplanner_0.18.45.zip differ diff --git a/releases/factoryplanner_0.18.46.zip b/releases/factoryplanner_0.18.46.zip new file mode 100644 index 000000000..da7d902db Binary files /dev/null and b/releases/factoryplanner_0.18.46.zip differ diff --git a/releases/factoryplanner_0.18.47.zip b/releases/factoryplanner_0.18.47.zip new file mode 100644 index 000000000..32e75f9e5 Binary files /dev/null and b/releases/factoryplanner_0.18.47.zip differ diff --git a/releases/factoryplanner_0.18.48.zip b/releases/factoryplanner_0.18.48.zip new file mode 100644 index 000000000..776eb7921 Binary files /dev/null and b/releases/factoryplanner_0.18.48.zip differ diff --git a/releases/factoryplanner_0.18.49.zip b/releases/factoryplanner_0.18.49.zip new file mode 100644 index 000000000..41c525be3 Binary files /dev/null and b/releases/factoryplanner_0.18.49.zip differ diff --git a/releases/factoryplanner_0.18.5.zip b/releases/factoryplanner_0.18.5.zip new file mode 100644 index 000000000..f86ddfa99 Binary files /dev/null and b/releases/factoryplanner_0.18.5.zip differ diff --git a/releases/factoryplanner_0.18.50.zip b/releases/factoryplanner_0.18.50.zip new file mode 100644 index 000000000..017d3a91c Binary files /dev/null and b/releases/factoryplanner_0.18.50.zip differ diff --git a/releases/factoryplanner_0.18.51.zip b/releases/factoryplanner_0.18.51.zip new file mode 100644 index 000000000..f9a893150 Binary files /dev/null and b/releases/factoryplanner_0.18.51.zip differ diff --git a/releases/factoryplanner_0.18.52.zip b/releases/factoryplanner_0.18.52.zip new file mode 100644 index 000000000..1bd03f859 Binary files /dev/null and b/releases/factoryplanner_0.18.52.zip differ diff --git a/releases/factoryplanner_0.18.6.zip b/releases/factoryplanner_0.18.6.zip new file mode 100644 index 000000000..8b140fd8f Binary files /dev/null and b/releases/factoryplanner_0.18.6.zip differ diff --git a/releases/factoryplanner_0.18.7.zip b/releases/factoryplanner_0.18.7.zip new file mode 100644 index 000000000..1f037f836 Binary files /dev/null and b/releases/factoryplanner_0.18.7.zip differ diff --git a/releases/factoryplanner_0.18.8.zip b/releases/factoryplanner_0.18.8.zip new file mode 100644 index 000000000..1dc9a1dc1 Binary files /dev/null and b/releases/factoryplanner_0.18.8.zip differ diff --git a/releases/factoryplanner_0.18.9.zip b/releases/factoryplanner_0.18.9.zip new file mode 100644 index 000000000..b85a71637 Binary files /dev/null and b/releases/factoryplanner_0.18.9.zip differ diff --git a/releases/factoryplanner_1.0.1.zip b/releases/factoryplanner_1.0.1.zip new file mode 100644 index 000000000..fa4ee8ec5 Binary files /dev/null and b/releases/factoryplanner_1.0.1.zip differ diff --git a/releases/factoryplanner_1.0.10.zip b/releases/factoryplanner_1.0.10.zip new file mode 100644 index 000000000..fc9ea76d9 Binary files /dev/null and b/releases/factoryplanner_1.0.10.zip differ diff --git a/releases/factoryplanner_1.0.11.zip b/releases/factoryplanner_1.0.11.zip new file mode 100644 index 000000000..3d637edc1 Binary files /dev/null and b/releases/factoryplanner_1.0.11.zip differ diff --git a/releases/factoryplanner_1.0.12.zip b/releases/factoryplanner_1.0.12.zip new file mode 100644 index 000000000..62f30e096 Binary files /dev/null and b/releases/factoryplanner_1.0.12.zip differ diff --git a/releases/factoryplanner_1.0.13.zip b/releases/factoryplanner_1.0.13.zip new file mode 100644 index 000000000..af9a60b5c Binary files /dev/null and b/releases/factoryplanner_1.0.13.zip differ diff --git a/releases/factoryplanner_1.0.2.zip b/releases/factoryplanner_1.0.2.zip new file mode 100644 index 000000000..f19aaee40 Binary files /dev/null and b/releases/factoryplanner_1.0.2.zip differ diff --git a/releases/factoryplanner_1.0.3.zip b/releases/factoryplanner_1.0.3.zip new file mode 100644 index 000000000..9ef0a300e Binary files /dev/null and b/releases/factoryplanner_1.0.3.zip differ diff --git a/releases/factoryplanner_1.0.4.zip b/releases/factoryplanner_1.0.4.zip new file mode 100644 index 000000000..86b81d9b3 Binary files /dev/null and b/releases/factoryplanner_1.0.4.zip differ diff --git a/releases/factoryplanner_1.0.5.zip b/releases/factoryplanner_1.0.5.zip new file mode 100644 index 000000000..54e3b9e55 Binary files /dev/null and b/releases/factoryplanner_1.0.5.zip differ diff --git a/releases/factoryplanner_1.0.6.zip b/releases/factoryplanner_1.0.6.zip new file mode 100644 index 000000000..5c0a7c19f Binary files /dev/null and b/releases/factoryplanner_1.0.6.zip differ diff --git a/releases/factoryplanner_1.0.7.zip b/releases/factoryplanner_1.0.7.zip new file mode 100644 index 000000000..ee0c8728d Binary files /dev/null and b/releases/factoryplanner_1.0.7.zip differ diff --git a/releases/factoryplanner_1.0.8.zip b/releases/factoryplanner_1.0.8.zip new file mode 100644 index 000000000..152070c2f Binary files /dev/null and b/releases/factoryplanner_1.0.8.zip differ diff --git a/releases/factoryplanner_1.0.9.zip b/releases/factoryplanner_1.0.9.zip new file mode 100644 index 000000000..12e6e113f Binary files /dev/null and b/releases/factoryplanner_1.0.9.zip differ diff --git a/releases/factoryplanner_1.1.1.zip b/releases/factoryplanner_1.1.1.zip new file mode 100644 index 000000000..f557d4cac Binary files /dev/null and b/releases/factoryplanner_1.1.1.zip differ diff --git a/releases/factoryplanner_1.1.10.zip b/releases/factoryplanner_1.1.10.zip new file mode 100644 index 000000000..f36ba9e5f Binary files /dev/null and b/releases/factoryplanner_1.1.10.zip differ diff --git a/releases/factoryplanner_1.1.11.zip b/releases/factoryplanner_1.1.11.zip new file mode 100644 index 000000000..e0c6ab2ec Binary files /dev/null and b/releases/factoryplanner_1.1.11.zip differ diff --git a/releases/factoryplanner_1.1.12.zip b/releases/factoryplanner_1.1.12.zip new file mode 100644 index 000000000..f726e0628 Binary files /dev/null and b/releases/factoryplanner_1.1.12.zip differ diff --git a/releases/factoryplanner_1.1.13.zip b/releases/factoryplanner_1.1.13.zip new file mode 100644 index 000000000..f998d08e8 Binary files /dev/null and b/releases/factoryplanner_1.1.13.zip differ diff --git a/releases/factoryplanner_1.1.14.zip b/releases/factoryplanner_1.1.14.zip new file mode 100644 index 000000000..7c0ac603f Binary files /dev/null and b/releases/factoryplanner_1.1.14.zip differ diff --git a/releases/factoryplanner_1.1.15.zip b/releases/factoryplanner_1.1.15.zip new file mode 100644 index 000000000..229cf9c77 Binary files /dev/null and b/releases/factoryplanner_1.1.15.zip differ diff --git a/releases/factoryplanner_1.1.16.zip b/releases/factoryplanner_1.1.16.zip new file mode 100644 index 000000000..13397ccb0 Binary files /dev/null and b/releases/factoryplanner_1.1.16.zip differ diff --git a/releases/factoryplanner_1.1.17.zip b/releases/factoryplanner_1.1.17.zip new file mode 100644 index 000000000..a419390ea Binary files /dev/null and b/releases/factoryplanner_1.1.17.zip differ diff --git a/releases/factoryplanner_1.1.18.zip b/releases/factoryplanner_1.1.18.zip new file mode 100644 index 000000000..a3629be5d Binary files /dev/null and b/releases/factoryplanner_1.1.18.zip differ diff --git a/releases/factoryplanner_1.1.19.zip b/releases/factoryplanner_1.1.19.zip new file mode 100644 index 000000000..b63c37d6b Binary files /dev/null and b/releases/factoryplanner_1.1.19.zip differ diff --git a/releases/factoryplanner_1.1.2.zip b/releases/factoryplanner_1.1.2.zip new file mode 100644 index 000000000..ed382472e Binary files /dev/null and b/releases/factoryplanner_1.1.2.zip differ diff --git a/releases/factoryplanner_1.1.20.zip b/releases/factoryplanner_1.1.20.zip new file mode 100644 index 000000000..72e0f8eae Binary files /dev/null and b/releases/factoryplanner_1.1.20.zip differ diff --git a/releases/factoryplanner_1.1.21.zip b/releases/factoryplanner_1.1.21.zip new file mode 100644 index 000000000..daf5f425b Binary files /dev/null and b/releases/factoryplanner_1.1.21.zip differ diff --git a/releases/factoryplanner_1.1.22.zip b/releases/factoryplanner_1.1.22.zip new file mode 100644 index 000000000..dcc847049 Binary files /dev/null and b/releases/factoryplanner_1.1.22.zip differ diff --git a/releases/factoryplanner_1.1.23.zip b/releases/factoryplanner_1.1.23.zip new file mode 100644 index 000000000..129c31fd2 Binary files /dev/null and b/releases/factoryplanner_1.1.23.zip differ diff --git a/releases/factoryplanner_1.1.24.zip b/releases/factoryplanner_1.1.24.zip new file mode 100644 index 000000000..6a59dde2f Binary files /dev/null and b/releases/factoryplanner_1.1.24.zip differ diff --git a/releases/factoryplanner_1.1.25.zip b/releases/factoryplanner_1.1.25.zip new file mode 100644 index 000000000..03559fc8e Binary files /dev/null and b/releases/factoryplanner_1.1.25.zip differ diff --git a/releases/factoryplanner_1.1.26.zip b/releases/factoryplanner_1.1.26.zip new file mode 100644 index 000000000..056c60972 Binary files /dev/null and b/releases/factoryplanner_1.1.26.zip differ diff --git a/releases/factoryplanner_1.1.27.zip b/releases/factoryplanner_1.1.27.zip new file mode 100644 index 000000000..181d62db2 Binary files /dev/null and b/releases/factoryplanner_1.1.27.zip differ diff --git a/releases/factoryplanner_1.1.28.zip b/releases/factoryplanner_1.1.28.zip new file mode 100644 index 000000000..69fdaf116 Binary files /dev/null and b/releases/factoryplanner_1.1.28.zip differ diff --git a/releases/factoryplanner_1.1.29.zip b/releases/factoryplanner_1.1.29.zip new file mode 100644 index 000000000..2faa54c56 Binary files /dev/null and b/releases/factoryplanner_1.1.29.zip differ diff --git a/releases/factoryplanner_1.1.3.zip b/releases/factoryplanner_1.1.3.zip new file mode 100644 index 000000000..23e3eb2b3 Binary files /dev/null and b/releases/factoryplanner_1.1.3.zip differ diff --git a/releases/factoryplanner_1.1.30.zip b/releases/factoryplanner_1.1.30.zip new file mode 100644 index 000000000..1c0a6e85d Binary files /dev/null and b/releases/factoryplanner_1.1.30.zip differ diff --git a/releases/factoryplanner_1.1.31.zip b/releases/factoryplanner_1.1.31.zip new file mode 100644 index 000000000..4bac4613b Binary files /dev/null and b/releases/factoryplanner_1.1.31.zip differ diff --git a/releases/factoryplanner_1.1.32.zip b/releases/factoryplanner_1.1.32.zip new file mode 100644 index 000000000..e452c82c7 Binary files /dev/null and b/releases/factoryplanner_1.1.32.zip differ diff --git a/releases/factoryplanner_1.1.33.zip b/releases/factoryplanner_1.1.33.zip new file mode 100644 index 000000000..9e1c2eff7 Binary files /dev/null and b/releases/factoryplanner_1.1.33.zip differ diff --git a/releases/factoryplanner_1.1.34.zip b/releases/factoryplanner_1.1.34.zip new file mode 100644 index 000000000..8f9f9acb8 Binary files /dev/null and b/releases/factoryplanner_1.1.34.zip differ diff --git a/releases/factoryplanner_1.1.35.zip b/releases/factoryplanner_1.1.35.zip new file mode 100644 index 000000000..7ded42efe Binary files /dev/null and b/releases/factoryplanner_1.1.35.zip differ diff --git a/releases/factoryplanner_1.1.36.zip b/releases/factoryplanner_1.1.36.zip new file mode 100644 index 000000000..584aa6052 Binary files /dev/null and b/releases/factoryplanner_1.1.36.zip differ diff --git a/releases/factoryplanner_1.1.37.zip b/releases/factoryplanner_1.1.37.zip new file mode 100644 index 000000000..c27b3af33 Binary files /dev/null and b/releases/factoryplanner_1.1.37.zip differ diff --git a/releases/factoryplanner_1.1.38.zip b/releases/factoryplanner_1.1.38.zip new file mode 100644 index 000000000..006031539 Binary files /dev/null and b/releases/factoryplanner_1.1.38.zip differ diff --git a/releases/factoryplanner_1.1.39.zip b/releases/factoryplanner_1.1.39.zip new file mode 100644 index 000000000..7d0a7cacb Binary files /dev/null and b/releases/factoryplanner_1.1.39.zip differ diff --git a/releases/factoryplanner_1.1.4.zip b/releases/factoryplanner_1.1.4.zip new file mode 100644 index 000000000..6ceca1024 Binary files /dev/null and b/releases/factoryplanner_1.1.4.zip differ diff --git a/releases/factoryplanner_1.1.40.zip b/releases/factoryplanner_1.1.40.zip new file mode 100644 index 000000000..d0dd40d7c Binary files /dev/null and b/releases/factoryplanner_1.1.40.zip differ diff --git a/releases/factoryplanner_1.1.41.zip b/releases/factoryplanner_1.1.41.zip new file mode 100644 index 000000000..e9e90ed91 Binary files /dev/null and b/releases/factoryplanner_1.1.41.zip differ diff --git a/releases/factoryplanner_1.1.42.zip b/releases/factoryplanner_1.1.42.zip new file mode 100644 index 000000000..ae6c8751a Binary files /dev/null and b/releases/factoryplanner_1.1.42.zip differ diff --git a/releases/factoryplanner_1.1.43.zip b/releases/factoryplanner_1.1.43.zip new file mode 100644 index 000000000..e93abff51 Binary files /dev/null and b/releases/factoryplanner_1.1.43.zip differ diff --git a/releases/factoryplanner_1.1.44.zip b/releases/factoryplanner_1.1.44.zip new file mode 100644 index 000000000..5ac11e8d1 Binary files /dev/null and b/releases/factoryplanner_1.1.44.zip differ diff --git a/releases/factoryplanner_1.1.45.zip b/releases/factoryplanner_1.1.45.zip new file mode 100644 index 000000000..84f0ccac9 Binary files /dev/null and b/releases/factoryplanner_1.1.45.zip differ diff --git a/releases/factoryplanner_1.1.46.zip b/releases/factoryplanner_1.1.46.zip new file mode 100644 index 000000000..a4812d093 Binary files /dev/null and b/releases/factoryplanner_1.1.46.zip differ diff --git a/releases/factoryplanner_1.1.47.zip b/releases/factoryplanner_1.1.47.zip new file mode 100644 index 000000000..7d925e7ea Binary files /dev/null and b/releases/factoryplanner_1.1.47.zip differ diff --git a/releases/factoryplanner_1.1.48.zip b/releases/factoryplanner_1.1.48.zip new file mode 100644 index 000000000..e9f86ba44 Binary files /dev/null and b/releases/factoryplanner_1.1.48.zip differ diff --git a/releases/factoryplanner_1.1.49.zip b/releases/factoryplanner_1.1.49.zip new file mode 100644 index 000000000..2ec3f4831 Binary files /dev/null and b/releases/factoryplanner_1.1.49.zip differ diff --git a/releases/factoryplanner_1.1.5.zip b/releases/factoryplanner_1.1.5.zip new file mode 100644 index 000000000..193ac1d1e Binary files /dev/null and b/releases/factoryplanner_1.1.5.zip differ diff --git a/releases/factoryplanner_1.1.50.zip b/releases/factoryplanner_1.1.50.zip new file mode 100644 index 000000000..3a8299acd Binary files /dev/null and b/releases/factoryplanner_1.1.50.zip differ diff --git a/releases/factoryplanner_1.1.51.zip b/releases/factoryplanner_1.1.51.zip new file mode 100644 index 000000000..6ac2fcd39 Binary files /dev/null and b/releases/factoryplanner_1.1.51.zip differ diff --git a/releases/factoryplanner_1.1.52.zip b/releases/factoryplanner_1.1.52.zip new file mode 100644 index 000000000..a36a3db36 Binary files /dev/null and b/releases/factoryplanner_1.1.52.zip differ diff --git a/releases/factoryplanner_1.1.53.zip b/releases/factoryplanner_1.1.53.zip new file mode 100644 index 000000000..1307b268c Binary files /dev/null and b/releases/factoryplanner_1.1.53.zip differ diff --git a/releases/factoryplanner_1.1.54.zip b/releases/factoryplanner_1.1.54.zip new file mode 100644 index 000000000..e4b5299b5 Binary files /dev/null and b/releases/factoryplanner_1.1.54.zip differ diff --git a/releases/factoryplanner_1.1.55.zip b/releases/factoryplanner_1.1.55.zip new file mode 100644 index 000000000..35e54759a Binary files /dev/null and b/releases/factoryplanner_1.1.55.zip differ diff --git a/releases/factoryplanner_1.1.56.zip b/releases/factoryplanner_1.1.56.zip new file mode 100644 index 000000000..d07736f38 Binary files /dev/null and b/releases/factoryplanner_1.1.56.zip differ diff --git a/releases/factoryplanner_1.1.57.zip b/releases/factoryplanner_1.1.57.zip new file mode 100644 index 000000000..ade4aa209 Binary files /dev/null and b/releases/factoryplanner_1.1.57.zip differ diff --git a/releases/factoryplanner_1.1.58.zip b/releases/factoryplanner_1.1.58.zip new file mode 100644 index 000000000..0d92204d7 Binary files /dev/null and b/releases/factoryplanner_1.1.58.zip differ diff --git a/releases/factoryplanner_1.1.59.zip b/releases/factoryplanner_1.1.59.zip new file mode 100644 index 000000000..5bd6b7d73 Binary files /dev/null and b/releases/factoryplanner_1.1.59.zip differ diff --git a/releases/factoryplanner_1.1.6.zip b/releases/factoryplanner_1.1.6.zip new file mode 100644 index 000000000..7c97bbb34 Binary files /dev/null and b/releases/factoryplanner_1.1.6.zip differ diff --git a/releases/factoryplanner_1.1.60.zip b/releases/factoryplanner_1.1.60.zip new file mode 100644 index 000000000..4045c923a Binary files /dev/null and b/releases/factoryplanner_1.1.60.zip differ diff --git a/releases/factoryplanner_1.1.61.zip b/releases/factoryplanner_1.1.61.zip new file mode 100644 index 000000000..203bde474 Binary files /dev/null and b/releases/factoryplanner_1.1.61.zip differ diff --git a/releases/factoryplanner_1.1.62.zip b/releases/factoryplanner_1.1.62.zip new file mode 100644 index 000000000..faa0d9e7f Binary files /dev/null and b/releases/factoryplanner_1.1.62.zip differ diff --git a/releases/factoryplanner_1.1.63.zip b/releases/factoryplanner_1.1.63.zip new file mode 100644 index 000000000..bbbdc86ff Binary files /dev/null and b/releases/factoryplanner_1.1.63.zip differ diff --git a/releases/factoryplanner_1.1.64.zip b/releases/factoryplanner_1.1.64.zip new file mode 100644 index 000000000..c8518a8ae Binary files /dev/null and b/releases/factoryplanner_1.1.64.zip differ diff --git a/releases/factoryplanner_1.1.65.zip b/releases/factoryplanner_1.1.65.zip new file mode 100644 index 000000000..077324eed Binary files /dev/null and b/releases/factoryplanner_1.1.65.zip differ diff --git a/releases/factoryplanner_1.1.66.zip b/releases/factoryplanner_1.1.66.zip new file mode 100644 index 000000000..856954215 Binary files /dev/null and b/releases/factoryplanner_1.1.66.zip differ diff --git a/releases/factoryplanner_1.1.67.zip b/releases/factoryplanner_1.1.67.zip new file mode 100644 index 000000000..219d7e1af Binary files /dev/null and b/releases/factoryplanner_1.1.67.zip differ diff --git a/releases/factoryplanner_1.1.68.zip b/releases/factoryplanner_1.1.68.zip new file mode 100644 index 000000000..31ec94678 Binary files /dev/null and b/releases/factoryplanner_1.1.68.zip differ diff --git a/releases/factoryplanner_1.1.69.zip b/releases/factoryplanner_1.1.69.zip new file mode 100644 index 000000000..45b2b50c8 Binary files /dev/null and b/releases/factoryplanner_1.1.69.zip differ diff --git a/releases/factoryplanner_1.1.7.zip b/releases/factoryplanner_1.1.7.zip new file mode 100644 index 000000000..f40197eab Binary files /dev/null and b/releases/factoryplanner_1.1.7.zip differ diff --git a/releases/factoryplanner_1.1.70.zip b/releases/factoryplanner_1.1.70.zip new file mode 100644 index 000000000..f00aeae9d Binary files /dev/null and b/releases/factoryplanner_1.1.70.zip differ diff --git a/releases/factoryplanner_1.1.71.zip b/releases/factoryplanner_1.1.71.zip new file mode 100644 index 000000000..8e6e472d9 Binary files /dev/null and b/releases/factoryplanner_1.1.71.zip differ diff --git a/releases/factoryplanner_1.1.72.zip b/releases/factoryplanner_1.1.72.zip new file mode 100644 index 000000000..4fc221fc7 Binary files /dev/null and b/releases/factoryplanner_1.1.72.zip differ diff --git a/releases/factoryplanner_1.1.73.zip b/releases/factoryplanner_1.1.73.zip new file mode 100644 index 000000000..80404a877 Binary files /dev/null and b/releases/factoryplanner_1.1.73.zip differ diff --git a/releases/factoryplanner_1.1.74.zip b/releases/factoryplanner_1.1.74.zip new file mode 100644 index 000000000..932b541ed Binary files /dev/null and b/releases/factoryplanner_1.1.74.zip differ diff --git a/releases/factoryplanner_1.1.75.zip b/releases/factoryplanner_1.1.75.zip new file mode 100644 index 000000000..1c568e5fd Binary files /dev/null and b/releases/factoryplanner_1.1.75.zip differ diff --git a/releases/factoryplanner_1.1.76.zip b/releases/factoryplanner_1.1.76.zip new file mode 100644 index 000000000..64bd42fc4 Binary files /dev/null and b/releases/factoryplanner_1.1.76.zip differ diff --git a/releases/factoryplanner_1.1.77.zip b/releases/factoryplanner_1.1.77.zip new file mode 100644 index 000000000..6bb01e47a Binary files /dev/null and b/releases/factoryplanner_1.1.77.zip differ diff --git a/releases/factoryplanner_1.1.78.zip b/releases/factoryplanner_1.1.78.zip new file mode 100644 index 000000000..085ee76cc Binary files /dev/null and b/releases/factoryplanner_1.1.78.zip differ diff --git a/releases/factoryplanner_1.1.79.zip b/releases/factoryplanner_1.1.79.zip new file mode 100644 index 000000000..6aec0392f Binary files /dev/null and b/releases/factoryplanner_1.1.79.zip differ diff --git a/releases/factoryplanner_1.1.8.zip b/releases/factoryplanner_1.1.8.zip new file mode 100644 index 000000000..1ffd21a45 Binary files /dev/null and b/releases/factoryplanner_1.1.8.zip differ diff --git a/releases/factoryplanner_1.1.9.zip b/releases/factoryplanner_1.1.9.zip new file mode 100644 index 000000000..7f110e5c3 Binary files /dev/null and b/releases/factoryplanner_1.1.9.zip differ diff --git a/releases/factoryplanner_1.2.1.zip b/releases/factoryplanner_1.2.1.zip new file mode 100644 index 000000000..6425617cf Binary files /dev/null and b/releases/factoryplanner_1.2.1.zip differ diff --git a/releases/factoryplanner_1.2.10.zip b/releases/factoryplanner_1.2.10.zip new file mode 100644 index 000000000..1591ced96 Binary files /dev/null and b/releases/factoryplanner_1.2.10.zip differ diff --git a/releases/factoryplanner_1.2.11.zip b/releases/factoryplanner_1.2.11.zip new file mode 100644 index 000000000..c6c1b8922 Binary files /dev/null and b/releases/factoryplanner_1.2.11.zip differ diff --git a/releases/factoryplanner_1.2.12.zip b/releases/factoryplanner_1.2.12.zip new file mode 100644 index 000000000..130875251 Binary files /dev/null and b/releases/factoryplanner_1.2.12.zip differ diff --git a/releases/factoryplanner_1.2.13.zip b/releases/factoryplanner_1.2.13.zip new file mode 100644 index 000000000..80462c386 Binary files /dev/null and b/releases/factoryplanner_1.2.13.zip differ diff --git a/releases/factoryplanner_1.2.14.zip b/releases/factoryplanner_1.2.14.zip new file mode 100644 index 000000000..8a92287d2 Binary files /dev/null and b/releases/factoryplanner_1.2.14.zip differ diff --git a/releases/factoryplanner_1.2.15.zip b/releases/factoryplanner_1.2.15.zip new file mode 100644 index 000000000..9a260a2c6 Binary files /dev/null and b/releases/factoryplanner_1.2.15.zip differ diff --git a/releases/factoryplanner_1.2.2.zip b/releases/factoryplanner_1.2.2.zip new file mode 100644 index 000000000..13cb1f7ab Binary files /dev/null and b/releases/factoryplanner_1.2.2.zip differ diff --git a/releases/factoryplanner_1.2.3.zip b/releases/factoryplanner_1.2.3.zip new file mode 100644 index 000000000..433855fe9 Binary files /dev/null and b/releases/factoryplanner_1.2.3.zip differ diff --git a/releases/factoryplanner_1.2.4.zip b/releases/factoryplanner_1.2.4.zip new file mode 100644 index 000000000..27f83a504 Binary files /dev/null and b/releases/factoryplanner_1.2.4.zip differ diff --git a/releases/factoryplanner_1.2.5.zip b/releases/factoryplanner_1.2.5.zip new file mode 100644 index 000000000..707faac69 Binary files /dev/null and b/releases/factoryplanner_1.2.5.zip differ diff --git a/releases/factoryplanner_1.2.6.zip b/releases/factoryplanner_1.2.6.zip new file mode 100644 index 000000000..faf1e61af Binary files /dev/null and b/releases/factoryplanner_1.2.6.zip differ diff --git a/releases/factoryplanner_1.2.7.zip b/releases/factoryplanner_1.2.7.zip new file mode 100644 index 000000000..69163c1fc Binary files /dev/null and b/releases/factoryplanner_1.2.7.zip differ diff --git a/releases/factoryplanner_1.2.8.zip b/releases/factoryplanner_1.2.8.zip new file mode 100644 index 000000000..370731c95 Binary files /dev/null and b/releases/factoryplanner_1.2.8.zip differ diff --git a/releases/factoryplanner_1.2.9.zip b/releases/factoryplanner_1.2.9.zip new file mode 100644 index 000000000..82dbad5d9 Binary files /dev/null and b/releases/factoryplanner_1.2.9.zip differ diff --git a/releases/factoryplanner_2.0.1.zip b/releases/factoryplanner_2.0.1.zip new file mode 100644 index 000000000..da8e2b724 Binary files /dev/null and b/releases/factoryplanner_2.0.1.zip differ diff --git a/releases/factoryplanner_2.0.10.zip b/releases/factoryplanner_2.0.10.zip new file mode 100644 index 000000000..6b94fa872 Binary files /dev/null and b/releases/factoryplanner_2.0.10.zip differ diff --git a/releases/factoryplanner_2.0.11.zip b/releases/factoryplanner_2.0.11.zip new file mode 100644 index 000000000..79ce9c866 Binary files /dev/null and b/releases/factoryplanner_2.0.11.zip differ diff --git a/releases/factoryplanner_2.0.12.zip b/releases/factoryplanner_2.0.12.zip new file mode 100644 index 000000000..f989f204a Binary files /dev/null and b/releases/factoryplanner_2.0.12.zip differ diff --git a/releases/factoryplanner_2.0.13.zip b/releases/factoryplanner_2.0.13.zip new file mode 100644 index 000000000..c75efc4fc Binary files /dev/null and b/releases/factoryplanner_2.0.13.zip differ diff --git a/releases/factoryplanner_2.0.14.zip b/releases/factoryplanner_2.0.14.zip new file mode 100644 index 000000000..5411f6e87 Binary files /dev/null and b/releases/factoryplanner_2.0.14.zip differ diff --git a/releases/factoryplanner_2.0.15.zip b/releases/factoryplanner_2.0.15.zip new file mode 100644 index 000000000..c919a43f2 Binary files /dev/null and b/releases/factoryplanner_2.0.15.zip differ diff --git a/releases/factoryplanner_2.0.16.zip b/releases/factoryplanner_2.0.16.zip new file mode 100644 index 000000000..4e75d6045 Binary files /dev/null and b/releases/factoryplanner_2.0.16.zip differ diff --git a/releases/factoryplanner_2.0.17.zip b/releases/factoryplanner_2.0.17.zip new file mode 100644 index 000000000..d81f36c7a Binary files /dev/null and b/releases/factoryplanner_2.0.17.zip differ diff --git a/releases/factoryplanner_2.0.18.zip b/releases/factoryplanner_2.0.18.zip new file mode 100644 index 000000000..b45e4388f Binary files /dev/null and b/releases/factoryplanner_2.0.18.zip differ diff --git a/releases/factoryplanner_2.0.19.zip b/releases/factoryplanner_2.0.19.zip new file mode 100644 index 000000000..ab52a98d9 Binary files /dev/null and b/releases/factoryplanner_2.0.19.zip differ diff --git a/releases/factoryplanner_2.0.2.zip b/releases/factoryplanner_2.0.2.zip new file mode 100644 index 000000000..ea1ef3e3a Binary files /dev/null and b/releases/factoryplanner_2.0.2.zip differ diff --git a/releases/factoryplanner_2.0.20.zip b/releases/factoryplanner_2.0.20.zip new file mode 100644 index 000000000..3d71d9bd9 Binary files /dev/null and b/releases/factoryplanner_2.0.20.zip differ diff --git a/releases/factoryplanner_2.0.21.zip b/releases/factoryplanner_2.0.21.zip new file mode 100644 index 000000000..e2d137a29 Binary files /dev/null and b/releases/factoryplanner_2.0.21.zip differ diff --git a/releases/factoryplanner_2.0.22.zip b/releases/factoryplanner_2.0.22.zip new file mode 100644 index 000000000..9f403fd3f Binary files /dev/null and b/releases/factoryplanner_2.0.22.zip differ diff --git a/releases/factoryplanner_2.0.23.zip b/releases/factoryplanner_2.0.23.zip new file mode 100644 index 000000000..a187eb59d Binary files /dev/null and b/releases/factoryplanner_2.0.23.zip differ diff --git a/releases/factoryplanner_2.0.24.zip b/releases/factoryplanner_2.0.24.zip new file mode 100644 index 000000000..5dbe25fac Binary files /dev/null and b/releases/factoryplanner_2.0.24.zip differ diff --git a/releases/factoryplanner_2.0.25.zip b/releases/factoryplanner_2.0.25.zip new file mode 100644 index 000000000..5b948ed03 Binary files /dev/null and b/releases/factoryplanner_2.0.25.zip differ diff --git a/releases/factoryplanner_2.0.26.zip b/releases/factoryplanner_2.0.26.zip new file mode 100644 index 000000000..93fe046eb Binary files /dev/null and b/releases/factoryplanner_2.0.26.zip differ diff --git a/releases/factoryplanner_2.0.27.zip b/releases/factoryplanner_2.0.27.zip new file mode 100644 index 000000000..8b5d38b8c Binary files /dev/null and b/releases/factoryplanner_2.0.27.zip differ diff --git a/releases/factoryplanner_2.0.28.zip b/releases/factoryplanner_2.0.28.zip new file mode 100644 index 000000000..18ba0c184 Binary files /dev/null and b/releases/factoryplanner_2.0.28.zip differ diff --git a/releases/factoryplanner_2.0.29.zip b/releases/factoryplanner_2.0.29.zip new file mode 100644 index 000000000..9ad5054d4 Binary files /dev/null and b/releases/factoryplanner_2.0.29.zip differ diff --git a/releases/factoryplanner_2.0.3.zip b/releases/factoryplanner_2.0.3.zip new file mode 100644 index 000000000..d8ae14925 Binary files /dev/null and b/releases/factoryplanner_2.0.3.zip differ diff --git a/releases/factoryplanner_2.0.30.zip b/releases/factoryplanner_2.0.30.zip new file mode 100644 index 000000000..38dc08a10 Binary files /dev/null and b/releases/factoryplanner_2.0.30.zip differ diff --git a/releases/factoryplanner_2.0.31.zip b/releases/factoryplanner_2.0.31.zip new file mode 100644 index 000000000..1379c3eec Binary files /dev/null and b/releases/factoryplanner_2.0.31.zip differ diff --git a/releases/factoryplanner_2.0.4.zip b/releases/factoryplanner_2.0.4.zip new file mode 100644 index 000000000..467768fc7 Binary files /dev/null and b/releases/factoryplanner_2.0.4.zip differ diff --git a/releases/factoryplanner_2.0.8.zip b/releases/factoryplanner_2.0.8.zip new file mode 100644 index 000000000..62693497c Binary files /dev/null and b/releases/factoryplanner_2.0.8.zip differ diff --git a/releases/factoryplanner_2.0.9.zip b/releases/factoryplanner_2.0.9.zip new file mode 100644 index 000000000..c4f8f7c56 Binary files /dev/null and b/releases/factoryplanner_2.0.9.zip differ diff --git a/scenarios/screenshotter/README.md b/scenarios/screenshotter/README.md new file mode 100644 index 000000000..aea45df10 --- /dev/null +++ b/scenarios/screenshotter/README.md @@ -0,0 +1,3 @@ +# README + +The `mod-list.json` and `config.ini` files don't actually do anything for the scenario. The `take_screenshots.py` script uses them to set the game up for its purposes. diff --git a/scenarios/screenshotter/blueprint.zip b/scenarios/screenshotter/blueprint.zip new file mode 100644 index 000000000..e3e73ba8c Binary files /dev/null and b/scenarios/screenshotter/blueprint.zip differ diff --git a/scenarios/screenshotter/config.ini b/scenarios/screenshotter/config.ini new file mode 100644 index 000000000..b7a63da47 --- /dev/null +++ b/scenarios/screenshotter/config.ini @@ -0,0 +1,1466 @@ +; version=9 +; This is INI file : https://en.wikipedia.org/wiki/INI_file#Format +; Semicolons (;) at the beginning of the line indicate a comment. Comment lines are ignored. +[path] +read-data=__PATH__system-read-data__ +write-data=__PATH__system-write-data__ + +[general] +locale= + +[other] +; Options: true, false +; verbose-logging=false + +; Options: true, false +; log-saving-statistics=false + +autosave-interval=0 + +; autosave-slots=3 + +; In ticks +; minimum-latency-in-multiplayer=0 + +; In seconds +; multiplayer-initial-connection-timeout=10 + +; Maximum connection attempts within a 60 second window from the same IP::PORT before more are ignored. +; multiplayer-max-connection-attempts-per-peer=130 + +; port=34197 + +; max-map-preview-chunk-side=64 + +; max-map-preview-threads=5 + +; In bytes +; max-multiplayer-script-reload-size=1048576 + +; Options: true, false +enable-steam-networking=false + +; proxy= + +; proxy-username= + +; proxy-password= + +; Options: true, false +check-updates=false + +; Options: true, false +enable-experimental-updates=true + +; Options: true, false +enable-new-mods=false + +; Options: true, false +; use-mod-settings-per-save=true + +; Options: true, false +; disable-minimal-mode=false + +; Options: true, false +; disable-blueprint-storage=false + +; Disables tracking which mod created/changed what prototype. Mainly for faster startup during development. +; +; Options: true, false +; disable-prototype-history=false + +; Print a warning for all prototype values that were not accessed. +; +; Options: true, false +; check-unused-prototype-data=false + +; Cache data stage prototype data for faster startup. Experimental. +; +; Options: true, false +; cache-prototype-data=false + +; Options: true, false +; enable-razer-chroma-support=true + +; Options: true, false +; enable-logitech-led-support=true + +; Options: true, false +; enable-steelseries-gamesense-support=true + +; Options: true, false +; enable-crash-log-uploading=true + +; Options: true, false +; enable-heap-validation=true + +; Options: true, false +; enable-threaded-message-pump=true + +; Options: true, false +; enable-taskbar-animation=true + +; Does nothing on Windows +; +; Options: true, false +; non-blocking-saving=false + +; Related to MacOS +; +; Options: true, false +; discard-mouse-events-when-accessibility-zoomed=false + +; Options: true, false +; enable-blueprint-storage-cloud-sync=false + +; Options: true, false +; enable-mod-settings-load-save-confirmation=true + +; Options: true, false +; force-enable-factorio-version-check=false + +; Options: true, false +; bring-window-to-top-on-click=true + +; Options: true, false +; allow-manual-autosaves=false + +; Options: fast, maximum +; multiplayer-compression-level=fast + +; Options: none, fast, maximum +; autosave-compression-level=fast + +; max-save-compression-threads=5 + +; Socket to host RCON on when lauching MP server from the menu. +; local-rcon-socket=0.0.0.0:0 + +; Password for RCON when launching MP server from the menu. +; local-rcon-password= + +; Disables shelf synchronization when loading a save file allowing to extract blueprints from the save file +; +; Options: true, false +; bypass-library-sync=false + + +[interface] +; Options: true, false +automatic-ui-scale=false + +custom-ui-scale=2.500000 + +; tooltip-delay=0.040000 + +; entity-tooltip-delay=0.000000 + +; tooltip-offset=20 + +; output-console-delay=1200 + +; train-stop-label-angle=0.085526 + +active-quick-bars=0 + +shortcut-bar-rows=0 + +; Options: true, false +; autosort-inventory=true + +; Options: true, false +; research-finished-stops-game=false + +; Options: true, false +; use-item-groups=true + +; Options: true, false +; use-item-subgroups=true + +; Options: true, false +; use-version-filter-in-browse-games-gui=true + +; Options: true, false +; use-version-filter-in-install-mods-gui=true + +; Options: true, false +; check-enable-replay-checkbox=false + +; Options: true, false +; play-sound-for-chat-messages=true + +; Options: true, false +; fuzzy-search-enabled=false + +; Options: true, false +; pick-ghost-cursor=false + +; Options: true, false +; show-all-items-in-selection-lists=false + +; Options: true, false +; lock-belt-building-to-straight-line=true + +; Options: true, false +; smart-belt-dragging=true + +; Options: true, false +; change-quickbar-by-clicking-with-item=false + +; Options: true, false +show-minimap=false + +; Options: true, false +show-tips-and-tricks-notifications=false + +; Options: true, false +; show-turret-radius-when-blueprinting=false + +; Options: true, false +; show-item-labels-in-cursor=true + +; Options: true, false +; show-rail-block-visualization=true + +; Options: true, false +; show-missing-logistic-network-icon=true + +; Options: true, false +; show-interaction-indications=true + +; Options: true, false +; show-grid-when-paused=true + +; Options: true, false +; flat-character-gui=true + +; Options: true, false +; enable-recipe-notifications=true + +; Options: true, false +; tool-window-next-to-quickbar=false + +; Options: true, false +; show-inserter-arrows-when-selected=true + +; Options: true, false +; show-inserter-arrows-when-detailed-info-is-on=false + +; Options: true, false +; show-pump-arrows-when-detailed-info-is-on=true + +; Options: true, false +; show-mining-drill-arrows-when-detailed-info-is-on=true + +; Options: true, false +; show-combinator-settings-when-detailed-info-is-on=false + +; Options: true, false +; show-beacon-modules-in-alt-mode=false + +; Options: true, false +; entity-tooltip-on-the-side=true + +; Options: true, false +show-mod-owners-in-tooltips=false + +; Options: true, false +; show-descriptions-in-tooltips=true + +; Options: true, false +; show-total-raw-in-recipe-tooltips=true + +; debug-font-size=18 + +; train-visualization-length=5 + + +[controls] +; move-up=W + +; move-up-alternative= + +; move-down=S + +; move-down-alternative= + +; move-left=A + +; move-left-alternative= + +; move-right=D + +; move-right-alternative= + +; open-character-gui=E + +; open-character-gui-alternative=mouse-button-4 + +; open-gui=mouse-button-1 + +; open-gui-alternative= + +; confirm-gui=E + +; confirm-gui-alternative=mouse-button-4 + +; mine=mouse-button-2 + +; mine-alternative= + +; build=mouse-button-1 + +; build-alternative= + +; build-ghost=SHIFT + mouse-button-1 + +; build-ghost-alternative= + +; clear-cursor=Q + +; clear-cursor-alternative=mouse-button-5 + +; smart-pipette=Q + +; smart-pipette-alternative=mouse-button-5 + +; rotate=R + +; rotate-alternative= + +; reverse-rotate=SHIFT + R + +; reverse-rotate-alternative= + +; flip-blueprint-horizontal=F + +; flip-blueprint-horizontal-alternative= + +; flip-blueprint-vertical=G + +; flip-blueprint-vertical-alternative= + +; pick-items=F + +; pick-items-alternative= + +; drop-cursor=Z + +; drop-cursor-alternative= + +; show-info=LALT + +; show-info-alternative= + +; shoot-enemy=SPACE + +; shoot-enemy-alternative= + +; shoot-selected=C + +; shoot-selected-alternative= + +; next-weapon=TAB + +; next-weapon-alternative= + +; toggle-driving=RETURN + +; toggle-driving-alternative= + +; zoom-in=mouse-wheel-up + +; zoom-in-alternative=SHIFT + mouse-wheel-up + +; zoom-out=mouse-wheel-down + +; zoom-out-alternative=SHIFT + mouse-wheel-down + +; toggle-console=GRAVE + +; toggle-console-alternative= + +; copy-entity-settings=SHIFT + mouse-button-2 + +; copy-entity-settings-alternative= + +; paste-entity-settings=SHIFT + mouse-button-1 + +; paste-entity-settings-alternative= + +; controller-gui-logistics-tab=F1 + +; controller-gui-logistics-tab-alternative= + +; controller-gui-crafting-tab=F2 + +; controller-gui-crafting-tab-alternative= + +; select-for-blueprint=mouse-button-1 + +; select-for-blueprint-alternative=COMMAND + mouse-button-1 + +; select-for-cancel-deconstruct=SHIFT + mouse-button-1 + +; select-for-cancel-deconstruct-alternative= + +; reverse-select=mouse-button-2 + +; reverse-select-alternative= + +; cycle-blueprint-forwards=SHIFT + mouse-wheel-down + +; cycle-blueprint-forwards-alternative= + +; cycle-blueprint-backwards=SHIFT + mouse-wheel-up + +; cycle-blueprint-backwards-alternative= + +; focus-search=COMMAND + F + +; focus-search-alternative= + +; larger-terrain-building-area=KP_PLUS + +; larger-terrain-building-area-alternative= + +; smaller-terrain-building-area=KP_MINUS + +; smaller-terrain-building-area-alternative= + +; remove-pole-cables=SHIFT + mouse-button-1 + +; remove-pole-cables-alternative= + +; build-with-obstacle-avoidance=CONTROL + mouse-button-1 + +; build-with-obstacle-avoidance-alternative= + +; add-station=SHIFT + mouse-button-1 + +; add-station-alternative= + +; add-temporary-station=CONTROL + mouse-button-1 + +; add-temporary-station-alternative= + +; drag-map=mouse-button-1 + +; drag-map-alternative= + +; place-in-chat=SHIFT + mouse-button-1 + +; place-in-chat-alternative= + +; place-ping=CONTROL + ALT + mouse-button-1 + +; place-ping-alternative= + +; alt-zoom-in=mouse-wheel-up + +; alt-zoom-in-alternative= + +; alt-zoom-out=mouse-wheel-down + +; alt-zoom-out-alternative= + +; activate-tooltip=LSHIFT + +; activate-tooltip-alternative= + +; craft=mouse-button-1 + +; craft-alternative= + +; craft-5=mouse-button-2 + +; craft-5-alternative= + +; craft-all=SHIFT + mouse-button-1 + +; craft-all-alternative= + +; cancel-craft=mouse-button-1 + +; cancel-craft-alternative= + +; cancel-craft-5=mouse-button-2 + +; cancel-craft-5-alternative= + +; cancel-craft-all=SHIFT + mouse-button-1 + +; cancel-craft-all-alternative= + +; pick-item=mouse-button-1 + +; pick-item-alternative= + +; stack-transfer=SHIFT + mouse-button-1 + +; stack-transfer-alternative= + +; inventory-transfer=CONTROL + mouse-button-1 + +; inventory-transfer-alternative= + +; fast-entity-transfer=CONTROL + mouse-button-1 + +; fast-entity-transfer-alternative= + +; cursor-split=mouse-button-2 + +; cursor-split-alternative= + +; stack-split=SHIFT + mouse-button-2 + +; stack-split-alternative= + +; inventory-split=CONTROL + mouse-button-2 + +; inventory-split-alternative= + +; fast-entity-split=CONTROL + mouse-button-2 + +; fast-entity-split-alternative= + +; toggle-filter=COMMAND + mouse-button-2 + +; toggle-filter-alternative= + +; open-item=mouse-button-2 + +; open-item-alternative= + +; rotate-active-quick-bars=X + +; rotate-active-quick-bars-alternative= + +; next-active-quick-bar= + +; next-active-quick-bar-alternative= + +; previous-active-quick-bar= + +; previous-active-quick-bar-alternative= + +; quick-bar-button-1=1 + +; quick-bar-button-1-alternative= + +; quick-bar-button-2=2 + +; quick-bar-button-2-alternative= + +; quick-bar-button-3=3 + +; quick-bar-button-3-alternative= + +; quick-bar-button-4=4 + +; quick-bar-button-4-alternative= + +; quick-bar-button-5=5 + +; quick-bar-button-5-alternative= + +; quick-bar-button-6=6 + +; quick-bar-button-6-alternative= + +; quick-bar-button-7=7 + +; quick-bar-button-7-alternative= + +; quick-bar-button-8=8 + +; quick-bar-button-8-alternative= + +; quick-bar-button-9=9 + +; quick-bar-button-9-alternative= + +; quick-bar-button-10=0 + +; quick-bar-button-10-alternative= + +; quick-bar-button-1-secondary= + +; quick-bar-button-1-secondary-alternative= + +; quick-bar-button-2-secondary= + +; quick-bar-button-2-secondary-alternative= + +; quick-bar-button-3-secondary= + +; quick-bar-button-3-secondary-alternative= + +; quick-bar-button-4-secondary= + +; quick-bar-button-4-secondary-alternative= + +; quick-bar-button-5-secondary= + +; quick-bar-button-5-secondary-alternative= + +; quick-bar-button-6-secondary= + +; quick-bar-button-6-secondary-alternative= + +; quick-bar-button-7-secondary= + +; quick-bar-button-7-secondary-alternative= + +; quick-bar-button-8-secondary= + +; quick-bar-button-8-secondary-alternative= + +; quick-bar-button-9-secondary= + +; quick-bar-button-9-secondary-alternative= + +; quick-bar-button-10-secondary= + +; quick-bar-button-10-secondary-alternative= + +; action-bar-select-page-1=SHIFT + 1 + +; action-bar-select-page-1-alternative= + +; action-bar-select-page-2=SHIFT + 2 + +; action-bar-select-page-2-alternative= + +; action-bar-select-page-3=SHIFT + 3 + +; action-bar-select-page-3-alternative= + +; action-bar-select-page-4=SHIFT + 4 + +; action-bar-select-page-4-alternative= + +; action-bar-select-page-5=SHIFT + 5 + +; action-bar-select-page-5-alternative= + +; action-bar-select-page-6=SHIFT + 6 + +; action-bar-select-page-6-alternative= + +; action-bar-select-page-7=SHIFT + 7 + +; action-bar-select-page-7-alternative= + +; action-bar-select-page-8=SHIFT + 8 + +; action-bar-select-page-8-alternative= + +; action-bar-select-page-9=SHIFT + 9 + +; action-bar-select-page-9-alternative= + +; action-bar-select-page-10=SHIFT + 0 + +; action-bar-select-page-10-alternative= + +; copy=COMMAND + C + +; copy-alternative= + +; cut=COMMAND + X + +; cut-alternative= + +; paste=COMMAND + V + +; paste-alternative= + +; cycle-clipboard-forwards=SHIFT + mouse-wheel-up + +; cycle-clipboard-forwards-alternative= + +; cycle-clipboard-backwards=SHIFT + mouse-wheel-down + +; cycle-clipboard-backwards-alternative= + +; undo=COMMAND + Y + +; undo-alternative= + +; toggle-menu=ESCAPE + +; toggle-menu-alternative= + +; toggle-map=M + +; toggle-map-alternative= + +; open-technology-gui=T + +; open-technology-gui-alternative= + +; production-statistics=P + +; production-statistics-alternative= + +; logistic-networks=L + +; logistic-networks-alternative= + +; toggle-blueprint-library=B + +; toggle-blueprint-library-alternative= + +; open-trains-gui=O + +; open-trains-gui-alternative= + +; pause-game=SHIFT + SPACE + +; pause-game-alternative=PAUSE + +; confirm-message=TAB + +; confirm-message-alternative= + +; previous-technology=BACKSPACE + +; previous-technology-alternative= + +; previous-mod=BACKSPACE + +; previous-mod-alternative=mouse-button-4 + +; connect-train=G + +; connect-train-alternative= + +; disconnect-train=V + +; disconnect-train-alternative= + +; editor-next-variation=mouse-button-3 + +; editor-next-variation-alternative= + +; editor-previous-variation=SHIFT + mouse-button-3 + +; editor-previous-variation-alternative= + +; editor-clone-item=SHIFT + mouse-button-3 + +; editor-clone-item-alternative= + +; editor-delete-item=CONTROL + mouse-button-3 + +; editor-delete-item-alternative= + +; editor-toggle-pause=KP_0 + +; editor-toggle-pause-alternative= + +; editor-tick-once=KP_PERIOD + +; editor-tick-once-alternative= + +; editor-speed-up=SHIFT + KP_PLUS + +; editor-speed-up-alternative= + +; editor-speed-down=SHIFT + KP_MINUS + +; editor-speed-down-alternative= + +; editor-reset-speed=SHIFT + KP_MULTIPLY + +; editor-reset-speed-alternative= + +; editor-set-clone-brush-source=SHIFT + mouse-button-2 + +; editor-set-clone-brush-source-alternative= + +; editor-set-clone-brush-destination=SHIFT + mouse-button-1 + +; editor-set-clone-brush-destination-alternative= + +; editor-switch-to-surface=LCTRL + +; editor-switch-to-surface-alternative= + +; editor-remove-scripting-object=SHIFT + mouse-button-2 + +; editor-remove-scripting-object-alternative= + +; debug-toggle-atlas-gui=CONTROL + F3 + +; debug-toggle-atlas-gui-alternative= + +; debug-toggle-debug-settings=F4 + +; debug-toggle-debug-settings-alternative= + +; debug-toggle-basic=F5 + +; debug-toggle-basic-alternative= + +; debug-reset-zoom=F9 + +; debug-reset-zoom-alternative= + +; debug-reset-zoom-2x=CONTROL + F9 + +; debug-reset-zoom-2x-alternative= + +; toggle-gui-debug=CONTROL + F5 + +; toggle-gui-debug-alternative= + +; toggle-gui-style-view=CONTROL + F6 + +; toggle-gui-style-view-alternative= + +; toggle-gui-shadows=CONTROL + F7 + +; toggle-gui-shadows-alternative= + +; toggle-gui-glows=CONTROL + F8 + +; toggle-gui-glows-alternative= + +; open-prototypes-gui=CONTROL + SHIFT + E + +; open-prototypes-gui-alternative= + +; open-prototype-explorer-gui=CONTROL + SHIFT + F + +; open-prototype-explorer-gui-alternative= + +; increase-ui-scale=COMMAND + KP_PLUS + +; increase-ui-scale-alternative= + +; decrease-ui-scale=COMMAND + KP_MINUS + +; decrease-ui-scale-alternative= + +; reset-ui-scale=COMMAND + KP_0 + +; reset-ui-scale-alternative= + +; next-player-in-replay=F10 + +; next-player-in-replay-alternative= + +; order-to-follow=CONTROL + mouse-button-1 + +; order-to-follow-alternative= + +; give-blueprint=ALT + B + +; give-blueprint-alternative= + +; give-blueprint-book= + +; give-blueprint-book-alternative= + +; give-deconstruction-planner=ALT + D + +; give-deconstruction-planner-alternative= + +; give-upgrade-planner=ALT + U + +; give-upgrade-planner-alternative= + +; toggle-equipment-movement-bonus=ALT + E + +; toggle-equipment-movement-bonus-alternative= + +; toggle-personal-logistic-requests=ALT + L + +; toggle-personal-logistic-requests-alternative= + +; toggle-personal-roboport=ALT + R + +; toggle-personal-roboport-alternative= + +fp_toggle_main_dialog=COMMAND + R + +; fp_toggle_main_dialog-alternative= + +; fp_toggle_pause=CONTROL + P + +; fp_toggle_pause-alternative= + +; fp_floor_up=SHIFT + R + +; fp_floor_up-alternative= + +; fp_refresh_production=R + +; fp_refresh_production-alternative= + +; fp_cycle_production_views=TAB + +; fp_cycle_production_views-alternative= + +; fp_confirm_dialog=RETURN + +; fp_confirm_dialog-alternative=KP_ENTER + + +[sound] +master-volume=0.300000 + +music-volume=0.000000 + +game-effects-volume=0.400000 + +gui-effects-volume=0.500000 + +walking-sound-volume=0.150000 + +environment-sounds-volume=0.150000 + +; alerts-volume=0.700000 + +wind-volume=0.000000 + +; simulation-volume=0.350000 + +; audible-distance=40.000000 + +; environment-audible-distance=30.000000 + +; maximum-environment-sounds=50 + +; active-gui-volume-modifier=0.800000 + +; active-gui-environment-volume-modifier=0.400000 + +; The maximum volume allowed for any sound. +; maximum-volume=1.000000 + +; ambient-music-pause-mean-seconds=45.000000 + +; ambient-music-pause-variance-seconds=30.000000 + +; Options: main-tracks-only, interleave-main-tracks-with-interludes, randomize-all, main-menu +; ambient-music-mode=interleave-main-tracks-with-interludes + +; zoom-audible-distance-coefficient=0.500000 + +; zoom-volume-coefficient=0.750000 + +; Options: default, sdl +; audio-backend=default + +; Options: point, linear, cubic +; default-mixer-quality=linear + +; primary-voice-frequency=44100 + +; primary-voice-depth=16 + +; Options: true, false +; directsound-global-focus=true + + +[map-view] +; Options: true, false +; show-logistic-network=false + +; Options: true, false +; show-electric-network=false + +; Options: true, false +; show-turret-range=false + +; Options: true, false +; show-pollution=true + +; Options: true, false +; show-networkless-logistic-members=false + +; Options: true, false +; show-train-station-names=true + +; Options: true, false +; show-player-names=true + +; Options: true, false +; show-tags=true + +; Options: true, false +; show-worker-robots=false + +; Options: true, false +; show-rail-signal-states=false + +; Options: true, false +; show-recipe-icons=false + +; Options: true, false +; show-non-standard-map-info=false + + +[debug] +; force=enemy + +; Options: true, false +; capture-perf-statistics=false + +; Options: always, debug, never +; show-fps=never + +; Options: always, debug, never +; show-detailed-info=never + +; Options: always, debug, never +; show-time-usage=never + +; Options: always, debug, never +; show-entity-time-usage=never + +; Options: always, debug, never +; show-gpu-time-usage=never + +; Options: always, debug, never +; show-sprite-counts=never + +; Options: always, debug, never +; show-lua-object-statistics=never + +; Options: always, debug, never +; show-heat-buffer-info=never + +; Options: always, debug, never +; show-multiplayer-waiting-icon=never + +; Options: always, debug, never +; show-multiplayer-statistics=never + +; Options: always, debug, never +; show-multiplayer-selection-rectangles=never + +; Options: always, debug, never +; show-debug-info-in-tooltips=never + +; Options: always, debug, never +; show-resistances-in-tooltips-always=never + +; Options: always, debug, never +; hide-mod-guis=never + +; Options: always, debug, never +; show-tile-grid=debug + +; Options: always, debug, never +; show-blueprint-grid=never + +; Options: always, debug, never +; show-collision-rectangles=never + +; Options: always, debug, never +; show-selection-rectangles=never + +; Options: always, debug, never +; show-render-rectangles=never + +; Options: always, debug, never +; show-sticker-boxes=never + +; Options: always, debug, never +; show-entity-positions=never + +; Options: always, debug, never +; show-entity-velocities=never + +; Options: always, debug, never +; show-selected-entity-advanced-tiles=never + +; Options: always, debug, never +; show-selected-input-transport-belts=never + +; Options: always, debug, never +; show-paths=never + +; Options: always, debug, never +; show-path-requests=never + +; Options: always, debug, never +; show-next-waypoint-bb=never + +; Options: always, debug, never +; show-target=never + +; Options: always, debug, never +; show-unit-group-info=never + +; Options: always, debug, never +; show-unit-behavior-info=never + +; Options: always, debug, never +; show-pathfinder-fringe=never + +; Options: always, debug, never +; show-path-cache=never + +; Options: always, debug, never +; show-path-cache-paths=never + +; Options: always, debug, never +; show-rail-paths=never + +; Options: always, debug, never +; show-rolling-stock-count=never + +; Options: always, debug, never +; show-rail-connections=never + +; Options: always, debug, never +; show-rail-joints=never + +; Options: always, debug, never +; show-rail-segment-collision-boxes=never + +; Options: always, debug, never +; show-train-stop-point=never + +; Options: always, debug, never +; show-train-braking-distance=never + +; Options: always, debug, never +; show-train-signals=never + +; Options: always, debug, never +; show-train-repathing=never + +; Options: always, debug, never +; show-network-connected-entities=never + +; Options: always, debug, never +; show-circuit-network-numbers=never + +; Options: always, debug, never +; show-energy-sources-networks=never + +; Options: always, debug, never +; show-active-state=never + +; Options: always, debug, never +; show-wakeup-lists=never + +; Options: always, debug, never +; show-transport-lines=never + +; Options: always, debug, never +; show-transport-line-gaps=never + +; Options: always, debug, never +; show-pollution-values=never + +; Options: always, debug, never +; show-active-entities-on-chunk-counts=never + +; Options: always, debug, never +; show-active-chunks=never + +; Options: always, debug, never +; show-polluted-chunks=never + +; Options: always, debug, never +; hide-chart-tags=never + +; Options: always, debug, never +; show-enemy-expansion-candidate-chunks=never + +; Options: always, debug, never +; show-enemy-expansion-candidate-chunk-values=never + +; Options: always, debug, never +; show-bad-attack-chunks=never + +; Options: always, debug, never +; show-tile-variations=never + +; Options: always, debug, never +; show-raw-tile-transitions=never + +; Options: always, debug, never +; show-fluid-box-fluid-info=never + +; Options: always, debug, never +; show-environment-sound-info=never + +; Options: always, debug, never +; show-environment-sound-area=never + +; Options: always, debug, never +; show-selected-entity-audible-range=never + +; Options: always, debug, never +; show-recently-played-sound-info=never + +; Options: always, debug, never +; show-logistic-robot-targets=never + +; Options: always, debug, never +; show-spidertron-movement=never + +; Options: always, debug, never +; show-player-robots=never + +; Options: always, debug, never +; show-fire-info=never + +; Options: always, debug, never +; show-sticker-info=never + +; Options: always, debug, never +; show-decorative-names=never + +; Options: always, debug, never +; show-decorative-collision-rectangles=never + +; Options: always, debug, never +; allow-increased-zoom=never + +; Options: always, debug, never +; show-chunk-components=never + + +[multiplayer-lobby] +; name= + +; description= + +; Options: true, false +; visibility-public=true + +; Options: true, false +; visibility-steam=true + +; Options: true, false +; visibility-lan=true + +; max-players=0 + +; Options: true, false +; ignore-player-limit-when-returning=false + +; max-upload-in-kilobytes-per-second=0 + +; max-upload-slots=5 + +; password= + +; tag-list= + +; afk-auto-kick=0 + +; Options: true, false, admins-only +; allowed-commands=admins-only + +; Options: true, false +; only-admins-can-pause=true + +; Options: true, false +; autosave-only-on-server=true + +; Options: true, false +; non-blocking-saving=true + +; Options: true, false +; verify-user-identity=true + +; Options: true, false +; enable-whitelist=false + + +[graphics] +; Default preferred display index should force finding primary monitor +; preferred-display-index=255 + +; screenshots-threads-count=6 + +; cache-sprite-atlas-count=1 + +; Options: true, false +cache-sprite-atlas=true + +; Options: true, false +; compress-sprite-atlas-cache=false + +; Options: true, false +; texture-streaming=true + +; streamed-atlas-physical-vram-size=0 + +; sprite-vertex-buffer-size=1048576 + +max-texture-size=0 + +; max-threads=6 + +; 'low' and 'very-low' options are deprecated and will be migrated to 'normal' +; +; Options: high, normal, low, very-low +graphics-quality=normal + +; brightness=0 + +; contrast=0 + +; saturation=100 + +; color-filter= + +; Options: true, false +full-screen=true + +; Options: true, false +; minimize-on-focus-loss=false + +; Options: true, false +; show-smoke=true + +; Options: true, false +; show-clouds=false + +; Options: true, false +; show-decoratives=true + +; Options: true, false +; show-particles=true + +; Options: true, false +; show-item-shadows=true + +; Options: true, false +; show-inserter-shadows=true + +; Options: true, false +; show-animated-water=false + +; Options: true, false +; show-tree-distortion=false + +; Options: true, false +; force-opengl=false + +; Options: true, false +; v-sync=true + +; Options: true, false +; high-quality-animations=false + +; Options: true, false +; high-quality-shadows=false + +; Options: true, false +; high-quality-terrain=true + +; Options: true, false +; show-game-simulations-in-background=true + +; Minimum number of turrets required to turn on the turret range overdraw optimization +; turret-overdraw-minimum-count=4 + +; Scale at which the turret range overdraw optimization will start being applied +; turret-overdraw-scale-threshold=0.200000 + +; Options: true, false +; skip-vram-detection=false + +; Options: true, false +; halt-rendering-when-minimized=true + +; Options: true, false +; runtime-sprite-reload=false + +; Options: true, false +; full-color-depth=true + +; Options: true, false +render-in-native-resolution=true + +; Options: true, false +; use-flip-presentation-model=false + +; Options: true, false +; debug-api=false + +; Options: true, false +; discard-buffers-on-begin-frame=true + +; Options: all, high, medium, low +; video-memory-usage=all + +; Options: none, high-quality, low-quality +texture-compression-level=high-quality + +; Options: true, false +; compress-virtual-atlas=true + +; Options: copy, copy-sequential, flip, flip-discard +; dxgi-presentation-model=copy + +; Options: none, flush, wait-for-vblank, flush-and-wait-for-vblank +; dxgi-action-before-present=none + +; relevant only for flip presentation models +; +; Options: true, false +; dxgi-allow-tearing=false + +; Options: false, true, auto +; dxgi-flip-do-not-wait=false + +; Options: true, false +; dxgi-present-restart=false + +; dxgi-swap-chain-buffer-count=0 + +; dxgi-max-frame-latency=0 + +; dxgi-adapter-index=-1 + +; max-sprite-loading-threads=32 + +; Options: true, false +; gpu-accelerated-compression=false + +; Options: true, false +; gpu-accelerated-mipmap-compression=false + +; Options: true, false +; wait-until-mipmap-generation-finished=true + +; Options: true, false +; check-for-unused-pixels=false + +; ogl-depth-buffer-bit-depth=0 + +; Options: false, true, auto +; ogl-accelerated-renderer=auto + +; Options: true, false +; ogl-double-buffered=true + +; Set to true if mipmapped sprites render very blurry on your GPU. Limited support. +; +; Options: true, false +; legacy-gpu-no-mipmaps=false + +; Options: true, false +; force-linear-magnification=false + +; Options: true, false +; custom-mipmap-workaround=true + +; Options: true, false +; buffer-rename-workaround=false + +; Comma separated list of OpenGL extensions that should not be used (for example: ARB_copy_image,KHR_debug) +; disabled-opengl-extensions= diff --git a/scenarios/screenshotter/control.lua b/scenarios/screenshotter/control.lua new file mode 100644 index 000000000..1057cdcbf --- /dev/null +++ b/scenarios/screenshotter/control.lua @@ -0,0 +1,91 @@ +---@diagnostic disable + +-- Runs through a series of steps, taking screenshots of various FP windows + +script.on_event(defines.events.on_game_created_from_scenario, function() + game.autosave_enabled = false + + -- Excuse the stupid move naming, this has been a long process + -- (it totally doesn't correlate to the actual meanings in a sensible way) + storage.scene = 1 + storage.shot = 1 + storage.pause = 10 + + storage.setup = function() remote.call("screenshotter_input", "execute_action", 1, "player_setup") end + storage.dimensions = {} + + remote.call("screenshotter_input", "initial_setup") +end) + + +remote.add_interface("screenshotter_output", { + return_dimensions = function(scene, dimensions) + storage.dimensions[scene] = dimensions + end +}) + +local function write_metadata_file() + local frame_corners = {} + + for scene, dimensions in pairs(storage.dimensions) do + local location, size = dimensions.location, dimensions.actual_size + frame_corners[scene] = { + top_left = {x = location.x, y = location.y}, + bottom_right = {x = location.x + size.width, y = location.y + size.height} + } + end + + local metadata = {frame_corners=frame_corners} + helpers.write_file("metadata.json", helpers.table_to_json(metadata)) +end + + +local scenes = { + "01_main_interface", + "02_compact_interface", + "03_item_picker", + "04_recipe_picker", + "05_machine", + "06_import", + "07_utility", + "08_preferences" +} + +local shots = { + function(scene) + remote.call("screenshotter_input", "execute_action", 1, ("setup_" .. scene)) + storage.pause = 20 + end, + function(scene) + game.take_screenshot{path=(scene .. ".png"), show_gui=true, zoom=3} + end, + function(scene) + remote.call("screenshotter_input", "execute_action", 1, ("teardown_" .. scene)) + end, +} + +script.on_event(defines.events.on_tick, function() + if storage.setup then + storage.setup() + storage.setup = nil + end + + if storage.pause > 0 then + storage.pause = storage.pause - 1 + else + local scene, shot = scenes[storage.scene], shots[storage.shot] + shot(scene) -- execute the current shot + + storage.shot = storage.shot + 1 + if storage.shot > table_size(shots) then + storage.scene = storage.scene + 1 + storage.shot = 1 + end + + if storage.scene > table_size(scenes) then + write_metadata_file() + script.on_event(defines.events.on_tick, nil) + print("screenshotter_done") -- let script know to kill Factorio + end + end +end) diff --git a/scenarios/screenshotter/description.json b/scenarios/screenshotter/description.json new file mode 100644 index 000000000..988f6f252 --- /dev/null +++ b/scenarios/screenshotter/description.json @@ -0,0 +1,4 @@ +{ + "order": "z", + "multiplayer-compatible": false +} diff --git a/scenarios/screenshotter/mod-list.json b/scenarios/screenshotter/mod-list.json new file mode 100644 index 000000000..c4127e966 --- /dev/null +++ b/scenarios/screenshotter/mod-list.json @@ -0,0 +1,16 @@ +{ + "mods": [ + { + "name": "base", + "enabled": true + }, + { + "name": "factoryplanner", + "enabled": true + }, + { + "name": "flib", + "enabled": true + } + ] +} diff --git a/scenarios/tester/blueprint.zip b/scenarios/tester/blueprint.zip new file mode 100644 index 000000000..41be15a12 Binary files /dev/null and b/scenarios/tester/blueprint.zip differ diff --git a/scenarios/tester/control.lua b/scenarios/tester/control.lua new file mode 100644 index 000000000..68bdfd543 --- /dev/null +++ b/scenarios/tester/control.lua @@ -0,0 +1,51 @@ +---@diagnostic disable + +-- ** LLOG ** +--llog = require("__factoryplanner__.llog") +--LLOG_EXCLUDES = {} + +-- ** TESTS ** +local solver_tests = require("solver.tests") + +local function setup() + game.autosave_enabled = false + game.speed = 100 + + storage.runplan = {} + storage.results = {} + storage.next_test = 1 + for _, test_set in pairs{solver_tests} do + for _, test in ipairs(test_set) do + table.insert(storage.runplan, test) + end + end +end + +local function teardown() + local failures = "" + + for name, result in pairs(storage.results) do + print(result) + if result ~= "pass" then failures = failures .. "\nTest " .. name .. ": " .. result end + end + + local output = (failures ~= "") and "Passed all " .. #storage.runplan .. " tests" + or "Failed " .. #failures .. " of " .. #storage.runplan .. " tests" .. failures + helpers.write_file("results.txt", output) + + script.on_event(defines.events.on_tick, nil) + print("tester_done") -- let script know to kill Factorio +end + +local function run_test() + local test = storage.runplan[storage.next_test] + if not test then teardown(); return end + + storage.results[test.name] = test.runner(test) + storage.next_test = storage.next_test + 1 +end + + +script.on_event(defines.events.on_game_created_from_scenario, setup) + +script.on_event(defines.events.on_tick, run_test) diff --git a/scenarios/tester/description.json b/scenarios/tester/description.json new file mode 100644 index 000000000..14bd7f9c4 --- /dev/null +++ b/scenarios/tester/description.json @@ -0,0 +1,4 @@ +{ + "order": "y", + "multiplayer-compatible": false +} \ No newline at end of file diff --git a/scenarios/tester/solver/framework.lua b/scenarios/tester/solver/framework.lua new file mode 100644 index 000000000..d27d91ce7 --- /dev/null +++ b/scenarios/tester/solver/framework.lua @@ -0,0 +1,15 @@ +---@diagnostic disable + +local framework = {} + +function framework.check_top_level_product(subfactory, name, expected_amount) + local product = Subfactory.get_by_name(subfactory, "Product", name) ---@cast product -nil + local actual_amount = product.amount + if actual_amount ~= expected_amount then + return "Expected " .. expected_amount .. " of " .. name .. ", got " .. actual_amount + else + return "pass" + end +end + +return framework diff --git a/scenarios/tester/solver/parts.lua b/scenarios/tester/solver/parts.lua new file mode 100644 index 000000000..422fa09ee --- /dev/null +++ b/scenarios/tester/solver/parts.lua @@ -0,0 +1,144 @@ +---@diagnostic disable + +local parts = {} + +-- This likely requires a system to define custom prototypes as well, since the vanilla ones +-- don't cover nearly all use cases. It'll also allow some conveniences like easier numbers +-- (ex. machine speed of 1), and make it independent of any vanilla prototype changes. +-- Could define real prototypes, but that has quite a few downsides, and also makes the +-- generator part of the test net, which I'd like to avoid. So a system to create fake +-- internal prototype definitions similar to this one could work. +-- Also, these parts are missing lots of things (like the line missing its beacon, for example) + +function parts.export_string(setup) + return helpers.table_to_json({ + export_modset = { + base = "1.1.80", + flib = "0.12.6", + factoryplanner = "1.1.64" + }, + subfactories = { + setup + } + }) +end + +function parts.subfactory(members) + return { + name = "Test", + timescale = 1, + notes = "", + blueprints = {}, + Product = { + objects = { + members.products + }, + class = "Collection" + }, + top_floor = { + Line = { + objects = { + members.lines + }, + class = "Collection" + }, + level = 1, + class = "Floor" + }, + class = "Subfactory" + } +end + +function parts.top_level_product(type, name, amount) + return { + proto = { + name = name, + simplified = true, + type = type + }, + --amount = amount, + required_amount = { + defined_by = "amount", + amount = amount, + belt_proto = false + }, + top_level = true, + class = "Product" + } +end + +function parts.line(members) + return { + class = "Line", + recipe = members.recipe, + active = true, + done = false, + percentage = members.percentage or 100, + machine = members.machine, + --[[ Product = { + objects = { { + proto = { + name = "iron-plate", + simplified = true, + type = "item" + }, + amount = 10, + top_level = false, + class = "Product" + } }, + class = "Collection" + } ]] + } +end + +function parts.recipe(category, name) + return { + proto = { + name = name, + simplified = true, + category = category + }, + production_type = "produce", + class = "Recipe" + } +end + +function parts.machine(category, name, members) + return { + proto = { + name = name, + simplified = true, + category = category + }, + force_limit = true, + fuel = members.fuel, + module_set = members.module_set, + class = "Machine" + } +end + +function parts.fuel(category, name) + return { + proto = { + name = name, + simplified = true, + category = category + }, + --amount = 0.72, + class = "Fuel" + } +end + +function parts.module_set(modules) + return { + modules = { + objects = modules, + class = "Collection" + }, + --module_count = 0, + --empty_slots = 0, + class = "ModuleSet" + } +end + +return parts diff --git a/scenarios/tester/solver/tests.lua b/scenarios/tester/solver/tests.lua new file mode 100644 index 000000000..53a6b82bc --- /dev/null +++ b/scenarios/tester/solver/tests.lua @@ -0,0 +1,66 @@ +---@diagnostic disable + +local parts = require("solver.parts") +local framework = require("solver.framework") +-- Doing the following is really bad, but writing a proper interface kinda is as well +-- Also, it doesn't work without dumb changes to the main mod, so this whole test +-- setup is non-functional and untested until the requires are cleaned up +require("__factoryplanner__.control") -- pull in all the crap +local Factory = require("__factoryplanner__.backend.data.Factory") +local District= require("__factoryplanner__.backend.data.District") + +local tests = { + { + name = "example_subfactory", + -- TODO: This setup data is currently completely ignored, the export_string below is what is used. + setup = parts.subfactory{ + products = { + parts.top_level_product("item", "iron-plate", 10) + }, + lines = { + parts.line{ + recipe = parts.recipe("smelting", "iron-plate"), + --percentage = 100, + machine = parts.machine("smelting", "stone-furnace", { + fuel = parts.fuel("chemical", "coal"), + module_set = parts.module_set({}) + }) + } + } + }, + export_string = "eNp9U9uOmzAQ/ZXKz2wEyRICH9CnVlpp+1atkDFD1pJva49XjSL+vWMgK5I0RUjg4zNz5szYZwZ/nPXYatsHQNacWccDsIZtN/mmZhnroYtH3nOH4Bd4S/CgZEfLfFOUmzytuUDrT05xY1bE8bIjIbDm95kJxQP9se8znyIN10nvFwT8VtC6UxGclwaJdqZ4YzHFMtpy3vZRoPyUeGo7a+TMWOBrgZcZnKPQJmeLkvTWPFGhCLQp6HNMhRCOoJNhjrzFk4MFCoQFqZ2Sg4SeNegjjKkvgzTQt10K5dpGk7Q8fETpCV6Qhhq0v37K4rDf7etdnj/XdVUX26p+3pVFWVVVeagOdb7fluNbxtC6dlDW+lT6V9smIGMKPkGxpqA/quLa+Q9CplKEdND+1/3a68x/4PbSeWsu9BmZkljSawauAmSMp/HAHEdh4AUY5EdCijzPmObiPZW3svRzge4HFZAyPw3RGy5uZhU0KJTmeONhSf/AxEfkKp2cWx1jvebqJtVMlo9yDZactUpqiRezQ0wjWc0qre9dCTtprcyId9BS3FWQ8v1TneTpukYF7XJlv1o5oa+QTuLMmO5QChBWa0gHkrHxbaT3L8IaU6g=", + body = (function(subfactory) + -- TODO: This is all very temporary proof-of-concept + local EPSILON = 0.00001 + local expected = 0.16666666666667 + local ore_ingredient = subfactory.top_floor.ingredients.items[1] + if ore_ingredient.proto.name == "iron-ore" and (math.abs(ore_ingredient.amount - expected) < EPSILON) then + return "pass" + + else + return "Expected " .. expected .. " got " .. ore_ingredient.amount + end + -- The framework needs to change but I don't know what too yet. + --return framework.check_top_level_product(subfactory, "iron-plate", 10) + end) + } +} + +local function runner(test) + -- This approach doesn't work with the test data as-is because it is too old + -- a format to be migrated. + --local export_string = helpers.encode_string(parts.export_string(test.setup)) + + -- Instead, for now we'll use an export string direct from the game/mod. + local import_factory = util.porter.process_export_string(test.export_string) ---@cast import_factory -nil + local subfactory = import_factory.factories[1] + -- A district is necessary for a location/pollutant_type + subfactory.parent = District.init("Test District") + if not subfactory.valid then error("Loaded subfactory setup is invalid") end + solver.update(game.get_player(1), subfactory) -- jank + + return test.body(subfactory) +end + +for _, test in pairs(tests) do test.runner = runner end +return tests diff --git a/screenshots/01_main_interface.png b/screenshots/01_main_interface.png new file mode 100644 index 000000000..17e458fcd Binary files /dev/null and b/screenshots/01_main_interface.png differ diff --git a/screenshots/02_compact_interface.png b/screenshots/02_compact_interface.png new file mode 100644 index 000000000..2d0f0c120 Binary files /dev/null and b/screenshots/02_compact_interface.png differ diff --git a/screenshots/03_item_picker.png b/screenshots/03_item_picker.png new file mode 100644 index 000000000..c36135ae7 Binary files /dev/null and b/screenshots/03_item_picker.png differ diff --git a/screenshots/04_recipe_picker.png b/screenshots/04_recipe_picker.png new file mode 100644 index 000000000..6d02cb8e3 Binary files /dev/null and b/screenshots/04_recipe_picker.png differ diff --git a/screenshots/05_machine.png b/screenshots/05_machine.png new file mode 100644 index 000000000..dfcbf0cf4 Binary files /dev/null and b/screenshots/05_machine.png differ diff --git a/screenshots/06_import.png b/screenshots/06_import.png new file mode 100644 index 000000000..b7d2a616d Binary files /dev/null and b/screenshots/06_import.png differ diff --git a/screenshots/07_utility.png b/screenshots/07_utility.png new file mode 100644 index 000000000..bd8869414 Binary files /dev/null and b/screenshots/07_utility.png differ diff --git a/screenshots/08_preferences.png b/screenshots/08_preferences.png new file mode 100644 index 000000000..5845e00ab Binary files /dev/null and b/screenshots/08_preferences.png differ diff --git a/scripts b/scripts new file mode 160000 index 000000000..7707b1c41 --- /dev/null +++ b/scripts @@ -0,0 +1 @@ +Subproject commit 7707b1c41444ca8f1e9473b312bc6b08a18f74c8 diff --git a/ui/dialogs/actionbar.lua b/ui/dialogs/actionbar.lua deleted file mode 100644 index 1870e3242..000000000 --- a/ui/dialogs/actionbar.lua +++ /dev/null @@ -1,151 +0,0 @@ --- Creates the actionbar including the new-, edit- and delete-buttons -function add_actionbar_to(main_dialog) - local actionbar = main_dialog.add{type="flow", name="flow_action_bar", direction="horizontal"} - - actionbar.add{type="button", name="button_new_subfactory", caption={"button-text.new_subfactory"}, style="fp_button_action"} - actionbar.add{type="button", name="button_edit_subfactory", caption={"button-text.edit_subfactory"}, style="fp_button_action"} - actionbar.add{type="button", name="button_delete_subfactory", caption={"button-text.delete_subfactory"}, style="fp_button_action"} -end - - --- Opens the subfactory dialog for either new or edit -function open_subfactory_dialog(player, edit) - enter_modal_dialog(player) - - if edit then - global["currently_editing"] = true - local subfactory = global["subfactories"][global["selected_subfactory_id"]] - create_subfactory_dialog(player, {"label.edit_subfactory"}, subfactory.name, subfactory.icon) - else - create_subfactory_dialog(player, {"label.new_subfactory"}, "", nil) - end -end - --- Closes the subfactory dialog -function close_subfactory_dialog(player, save) - local subfactory_dialog = player.gui.center["subfactory_dialog"] - - if not save then - subfactory_dialog.destroy() - exit_modal_dialog(player, false) - else - local data = check_subfactory_data(subfactory_dialog) - if data ~= nil then - if global["currently_editing"] then - edit_subfactory(global["selected_subfactory_id"], data.name, data.icon) - global["currently_editing"] = false - else - add_subfactory(data.name, data.icon) - end - -- Only closes when correct data has been entered - subfactory_dialog.destroy() - exit_modal_dialog(player, true) - end - end -end - - --- Checks the entered data for errors and returns it if it's all correct, else returns nil -function check_subfactory_data(subfactory_dialog) - local name = subfactory_dialog["table_subfactory"]["textfield_subfactory_name"].text - local icon = subfactory_dialog["table_subfactory"]["choose-elem-button_subfactory_icon"].elem_value - local instruction_1 = subfactory_dialog["table_conditions"]["label_subfactory_instruction_1"] - local instruction_2 = subfactory_dialog["table_conditions"]["label_subfactory_instruction_2"] - local instruction_3 = subfactory_dialog["table_conditions"]["label_subfactory_instruction_3"] - - -- Reset all error indications - set_label_color(instruction_1, "white") - set_label_color(instruction_2, "white") - set_label_color(instruction_3, "white") - local error_present = false - - if name == "" and icon == nil then - set_label_color(instruction_1, "red") - error_present = true - end - - if name:len() > 16 then - set_label_color(instruction_2, "red") - error_present = true - end - - -- matches everything that is not alphanumeric or a space - if name ~= "" and name:match("[^%w ]") then - set_label_color(instruction_3, "red") - error_present = true - end - - if error_present then - return nil - else - if name == "" then name = nil end - return {name=name, icon=icon} - end -end - - --- Constructs the subfactory dialog -function create_subfactory_dialog(player, title, name, icon) - local subfactory_dialog = player.gui.center.add{type="frame", name="subfactory_dialog", direction="vertical", caption=title} - - local table_conditions = subfactory_dialog.add{type="table", name="table_conditions", column_count=1} - table_conditions.add{type="label", name="label_subfactory_instruction_1", caption={"label.subfactory_instruction_1"}} - table_conditions.add{type="label", name="label_subfactory_instruction_2", caption={"label.subfactory_instruction_2"}} - table_conditions.add{type="label", name="label_subfactory_instruction_3", caption={"label.subfactory_instruction_3"}} - table_conditions.style.bottom_padding = 6 - - local table_subfactory = subfactory_dialog.add{type="table", name="table_subfactory", column_count=2} - table_subfactory.style.bottom_padding = 8 - -- Name - table_subfactory.add{type="label", name="label_subfactory_name", caption={"", {"label.subfactory_name"}, " "}} - table_subfactory.add{type="textfield", name="textfield_subfactory_name", text=name} - table_subfactory["textfield_subfactory_name"].focus() - - -- Icon - table_subfactory.add{type="label", name="label_subfactory_icon", caption={"label.subfactory_icon"}} - table_subfactory.add{type="choose-elem-button", name="choose-elem-button_subfactory_icon", elem_type="item", item=icon} - - -- Button Bar - local buttonbar = subfactory_dialog.add{type="flow", name="flow_subfactory_button_bar", direction="horizontal"} - buttonbar.add{type="button", name="button_subfactory_cancel", caption={"button-text.cancel"}, style="fp_button_with_spacing"} - buttonbar.add{type="flow", name="flow_subfactory_buttonbar", direction="horizontal"} - buttonbar["flow_subfactory_buttonbar"].style.width = 35 - buttonbar.add{type="button", name="button_subfactory_submit", caption={"button-text.submit"}, style="fp_button_with_spacing"} -end - - --- Handles the subfactory deletion process -function handle_subfactory_deletion(player, pressed) - local main_dialog = player.gui.center["main_dialog"] - if main_dialog ~= nil then - local delete_button = main_dialog["flow_action_bar"]["button_delete_subfactory"] - - -- Resets the button if any other button was pressed - if not pressed then - set_delete_button(delete_button, true) - else - if not global["currently_deleting"] then - set_delete_button(delete_button, false) - else - local id = global["selected_subfactory_id"] - delete_subfactory(id) - - set_delete_button(delete_button, true) - refresh_subfactory_bar(player) - end - end - end -end - --- Sets the delete button to either state -function set_delete_button(button, reset) - if reset then - button.caption = {"button-text.delete_subfactory"} - set_label_color(button, "white") - global["currently_deleting"] = false - else - button.caption = {"button-text.delete_subfactory_confirm"} - set_label_color(button, "red") - global["currently_deleting"] = true - end -end \ No newline at end of file diff --git a/ui/dialogs/main.lua b/ui/dialogs/main.lua deleted file mode 100644 index 3d9b72280..000000000 --- a/ui/dialogs/main.lua +++ /dev/null @@ -1,60 +0,0 @@ -require("mod-gui") -require("ui.util") -require("titlebar") -require("actionbar") -require("subfactory_bar") - - --- Create the always-present GUI button to open the main dialog -function gui_init(player) - local frame_flow = mod_gui.get_frame_flow(player) - if not frame_flow["fp_button_toggle_interface"] then - frame_flow.add - { - type = "button", - name = "fp_button_toggle_interface", - caption = "FP", - tooltip = {"tooltip.open_main_dialog"}, - style = mod_gui.button_style - } - end - -- Temporary for dev puroposes - if not global["subfactories"][1] then global["subfactories"][1] = {name=nil, icon="iron-plate"} end - if not global["subfactories"][2] then global["subfactories"][2] = {name="Beta", icon="copper-plate"} end - if not global["subfactories"][3] then global["subfactories"][3] = {name="Delta", icon=nil} end -end - - --- Toggles the main dialog open and closed -function toggle_main_dialog(player) - -- Won't toggle if a modal dialog is open - if not global["modal_dialog_open"] then - local main_dialog = player.gui.center["main_dialog"] - if main_dialog == nil then - create_main_dialog(player) - elseif main_dialog.style.visible == false then - if global["devmode"] then - create_main_dialog(player) - else - main_dialog.style.visible = true - end - else - if global["devmode"] then - main_dialog.destroy() - else - main_dialog.style.visible = false - end - end - end -end - - --- Constructs the main dialog -function create_main_dialog(player) - local main_dialog = player.gui.center.add{type="frame", name="main_dialog", direction="vertical"} - main_dialog.style.right_padding = 6 - - add_titlebar_to(main_dialog) - add_actionbar_to(main_dialog) - add_subfactory_bar_to(main_dialog, player) -end \ No newline at end of file diff --git a/ui/dialogs/subfactory_bar.lua b/ui/dialogs/subfactory_bar.lua deleted file mode 100644 index 0f99e9e64..000000000 --- a/ui/dialogs/subfactory_bar.lua +++ /dev/null @@ -1,132 +0,0 @@ --- Creates the subfactory bar that includes all current subfactory buttons -function add_subfactory_bar_to(main_dialog, player) - main_dialog.add{type="table", name="table_subfactory_bar", direction="horizontal", column_count = 10} - refresh_subfactory_bar(player) -end - - --- Refreshes the subfactory bar by reloading the data -function refresh_subfactory_bar(player) - local subfactory_bar = player.gui.center["main_dialog"]["table_subfactory_bar"] - subfactory_bar.clear() - - -- selected_subfactory_id is always 0 when there are no subfactories - if global["selected_subfactory_id"] == 0 then - local actionbar = player.gui.center["main_dialog"]["flow_action_bar"] - actionbar["button_edit_subfactory"].enabled = false - actionbar["button_delete_subfactory"].enabled = false - else - for id, subfactory in ipairs(global["subfactories"]) do - local table = subfactory_bar.add{type="table", name="table_subfactory_" .. id, column_count=2} - local selected = (global["selected_subfactory_id"] == id) - - if subfactory.name ~= nil and subfactory.icon == nil then - create_label_element(table, id, subfactory, selected) - elseif subfactory.icon ~= nil and subfactory.name == nil then - create_sprite_element(table, id, subfactory, selected) - else - create_label_sprite_element(table, id, subfactory, selected) - end - end - end -end - - --- Constructs an element of the subfactory bar if there is only a name -function create_label_element(table, id, subfactory, selected) - if selected then - local button = table.add{type="label", name="xbutton_subfactory_" .. id, caption=subfactory.name} - button.style.height = 34 - button.style.top_padding = 7 - button.style.left_padding = 8 - button.style.right_padding = 8 - button.style.font = "fp-button-standard" - else - local button = table.add{type="button", name="xbutton_subfactory_" .. id, caption=subfactory.name} - button.style.height = 34 - button.style.top_padding = 3 - button.style.font = "fp-button-standard" - end -end - --- Constructs an element of the subfactory bar if there is only an icon -function create_sprite_element(table, id, subfactory, selected) - if selected then - local button = table.add{type="sprite", name="xbutton_subfactory_" .. id, sprite="item/" .. subfactory.icon} - button.style.width = 34 - button.style.height = 30 - button.style.left_padding = 1 - button.style.right_padding = 3 - else - local button = table.add{type="sprite-button", name="xbutton_subfactory_" .. id, sprite="item/" .. subfactory.icon} - button.style.width = 34 - button.style.height = 34 - end -end - --- Constructs an element of the subfactory bar if there is both a name and an icon -function create_label_sprite_element(table, id, subfactory, selected) - if selected then - local button = table.add{type="flow", name="xbutton_subfactory_" .. id, column_count=2} - button.style.left_padding = 8 - button.style.right_padding = 11 - - button.add{type="sprite", name="sprite_subfactory_" .. id, sprite="item/" .. subfactory.icon} - button["sprite_subfactory_" .. id].style.top_padding = 2 - button["sprite_subfactory_" .. id].style.height = 34 - button["sprite_subfactory_" .. id].style.width = 34 - button["sprite_subfactory_" .. id].ignored_by_interaction = true - button.add{type="label", name="label_subfactory_" .. id, caption=subfactory.name} - button["label_subfactory_" .. id].style.top_padding = 6 - button["label_subfactory_" .. id].style.left_padding = 0 - button["label_subfactory_" .. id].ignored_by_interaction = true - button["label_subfactory_" .. id].style.font = "fp-button-standard" - else - local button = table.add{type="button", name="xbutton_subfactory_" .. id, caption=""} - button.style.font = "fp-button-standard" - button.style.height = 34 - button.style.top_padding = 0 - button.style.width = determine_pixelsize_of(subfactory.name) + 50 - - local flow = button.add{type="flow", name="flow_subfactory_" .. id, column_count=2} - flow.ignored_by_interaction = true - - flow.add{type="sprite", name="sprite_subfactory_" .. id, sprite="item/" .. subfactory.icon} - flow["sprite_subfactory_" .. id].style.height = 34 - flow["sprite_subfactory_" .. id].style.width = 34 - flow.add{type="label", name="label_subfactory_" .. id, caption=subfactory.name} - flow["label_subfactory_" .. id].style.top_padding = 2 - flow["label_subfactory_" .. id].style.left_padding = 0 - flow["label_subfactory_" .. id].style.font = "fp-button-standard" - end -end - - --- Moves selection to the clicked element or shifts it's position left and right -function handle_subfactory_element_click(player, id, control, shift) - local subfactories = global["subfactories"] - - -- shift position to the right - if not control and shift then - if id ~= #subfactories then - subfactories[id], subfactories[id+1] = subfactories[id+1], subfactories[id] - if global["selected_subfactory_id"] == id then - global["selected_subfactory_id"] = global["selected_subfactory_id"] + 1 - end - end - - -- shift position to the left - elseif control and not shift then - if id ~= 1 then - subfactories[id], subfactories[id-1] = subfactories[id-1], subfactories[id] - if global["selected_subfactory_id"] == id then - global["selected_subfactory_id"] = global["selected_subfactory_id"] - 1 - end - end - - elseif not control and not shift then - global["selected_subfactory_id"] = id - end - - refresh_subfactory_bar(player) -end \ No newline at end of file diff --git a/ui/dialogs/titlebar.lua b/ui/dialogs/titlebar.lua deleted file mode 100644 index c187b30ea..000000000 --- a/ui/dialogs/titlebar.lua +++ /dev/null @@ -1,14 +0,0 @@ --- Creates the titlebar including name and exit-button -function add_titlebar_to(main_dialog) - local titlebar = main_dialog.add{type="flow", name="titlebar", direction="horizontal"} - titlebar.style.top_padding = 4 - - titlebar.add{type="label", name="label_titlebar_name", caption=" Factory Planner"} - titlebar["label_titlebar_name"].style.font="fp-label-supersized" - titlebar["label_titlebar_name"].style.top_padding = 0 - - titlebar.add{type="flow", name="flow_titlebar_spacing", direction="horizontal"} - titlebar["flow_titlebar_spacing"].style.width=550 - - titlebar.add{type="button", name="button_titlebar_exit", caption="X", style="fp_button_exit"} -end \ No newline at end of file diff --git a/ui/listeners.lua b/ui/listeners.lua deleted file mode 100644 index ba32741a0..000000000 --- a/ui/listeners.lua +++ /dev/null @@ -1,82 +0,0 @@ --- Fires when a player joins -script.on_event(defines.events.on_player_created, function(event) - -- Create main-dialog GUI button - local player = game.players[event.player_index] - gui_init(player) -end) - --- Fires on pressing of the custom 'Open/Close' shortcut -script.on_event("fp_toggle_main_dialog", function(event) - -- Toggle the main dialog - local player = game.players[event.player_index] - toggle_main_dialog(player) -end) - --- Fires on pressing the custom 'Confirm' shortcut to confirm dialogs -script.on_event("fp_confirm", function(event) - local player = game.players[event.player_index] - - -- Confirms the new/edit subfactory dialog - local subfactory_dialog = player.gui.center["subfactory_dialog"] - if subfactory_dialog then - close_subfactory_dialog(player, true) - end -end) - - --- Fires on all clicks on the GUI -script.on_event(defines.events.on_gui_click, function(event) - local player = game.players[event.player_index] - local is_left_click = is_left_click(event) - - -- Unfocuses textfield when any other element in the dialog is clicked (buggy, see BUGS) - if player.gui.center["subfactory_dialog"] and event.element.name ~= "textfield_subfactory_name" then - player.gui.center["subfactory_dialog"].focus() - end - - if event.element.name == "button_delete_subfactory" and is_left_click then - handle_subfactory_deletion(player, true) - else - -- Resets button if any other button is pressed - handle_subfactory_deletion(player, false) - - -- Reacts to the always-present GUI button or the close-button on the main dialog being pressed - if event.element.name == "fp_button_toggle_interface" or event.element.name == "button_titlebar_exit" and - is_left_click then - toggle_main_dialog(player) - - -- Opens the new-subfactory dialog - elseif event.element.name == "button_new_subfactory" and is_left_click then - open_subfactory_dialog(player, false) - - -- Opens the edit-subfactory dialog - elseif event.element.name == "button_edit_subfactory" and is_left_click then - open_subfactory_dialog(player, true) - - -- Closes the subfactory dialog - elseif event.element.name == "button_subfactory_cancel" and is_left_click then - close_subfactory_dialog(player, false) - - -- Submits the subfactory dialog - elseif event.element.name == "button_subfactory_submit" and is_left_click then - close_subfactory_dialog(player, true) - - -- Reacts to a subfactory button being pressed - elseif string.find(event.element.name, "^xbutton_subfactory_%d+$") and not event.alt and - event.button ~= defines.mouse_button_type.right then - local id = tonumber(string.match(event.element.name, "%d+")) - handle_subfactory_element_click(player, id, event.control, event.shift) - end - end -end) - - --- Returns true when only the left mouse button has been pressed (no modifiers) -function is_left_click(event) - if event.button == defines.mouse_button_type.left and - not event.alt and not event.control and not event.shift then - return true - else - return false - end -end \ No newline at end of file diff --git a/ui/util.lua b/ui/util.lua deleted file mode 100644 index ffcc2ecc0..000000000 --- a/ui/util.lua +++ /dev/null @@ -1,105 +0,0 @@ --- Sets up environment for opening a new modal dialog -function enter_modal_dialog(player) - toggle_main_dialog(player) - global["modal_dialog_open"] = true -end - --- Sets up environment after a modal dialog has been closed -function exit_modal_dialog(player, refresh) - global["modal_dialog_open"] = false - toggle_main_dialog(player) - if refresh then refresh_subfactory_bar(player) end -end - - - --- Sets the font color of the given label / button-label -function set_label_color(ui_element, color) - if color == "red" then - ui_element.style.font_color = {r = 1} - elseif color == "white" or color == "default" then - ui_element.style.font_color = {r = 1, g = 1, b = 1} - end -end - - --- Jank-ass function to approximate the pixel dimensions of a given string --- Fails for some strings (eg. monotone strings and others) due to kerning, but ¯\_(ツ)_/¯ -function determine_pixelsize_of(string) - local alphabet_pixelcounts = get_alphabet_pixelcounts() - local size = 0 - for i = 1, #string do - local c = string:sub(i,i) - size = size + alphabet_pixelcounts[c] + 2 - end - return size -end - --- Returns the pixelsize of letters+numbers with font 'fp-button-standard' (16p font) -function get_alphabet_pixelcounts() - return { - a = 9, - b = 8, - c = 7, - d = 8, - e = 8, - f = 6, - g = 9, - h = 7, - i = 2, - j = 4, - k = 7, - l = 2, - m = 13, - n = 7, - o = 9, - p = 8, - q = 8, - r = 5, - s = 8, - t = 6, - u = 7, - v = 8, - q = 13, - x = 8, - y = 8, - z = 7, - A = 10, - B = 9, - C = 8, - D = 9, - E = 8, - F = 8, - G = 9, - H = 9, - I = 2, - J = 4, - K = 9, - L = 7, - M = 12, - N = 9, - O = 10, - P = 9, - Q = 10, - R = 9, - S = 9, - T = 9, - U = 9, - V = 10, - W = 15, - X = 9, - Y = 9, - Z = 9, - ["0"] = 9, - ["1"] = 6, - ["2"] = 8, - ["3"] = 8, - ["4"] = 9, - ["5"] = 8, - ["6"] = 9, - ["7"] = 8, - ["8"] = 9, - ["9"] = 9, - [" "] = 4 - } -end \ No newline at end of file