diff --git a/clock-generator-sidecar/.gitignore b/clock-generator-sidecar/.gitignore new file mode 100644 index 0000000..6f66c74 --- /dev/null +++ b/clock-generator-sidecar/.gitignore @@ -0,0 +1 @@ +*.zip \ No newline at end of file diff --git a/clock-generator-sidecar/build.sh b/clock-generator-sidecar/build.sh new file mode 100755 index 0000000..77cbbb5 --- /dev/null +++ b/clock-generator-sidecar/build.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Build script for Clock Generator Sidecar Factorio mod +# Creates a zip file ready for installation in Factorio's mods folder + +set -e + +# Get the directory where the script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Read mod info from info.json +MOD_NAME=$(grep -o '"name": *"[^"]*"' info.json | cut -d'"' -f4) +MOD_VERSION=$(grep -o '"version": *"[^"]*"' info.json | cut -d'"' -f4) + +# Output filename follows Factorio convention: modname_version.zip +OUTPUT_NAME="${MOD_NAME}_${MOD_VERSION}" +OUTPUT_FILE="${OUTPUT_NAME}.zip" + +echo "Building ${OUTPUT_FILE}..." + +# Clean up any existing build +rm -f "$OUTPUT_FILE" + +# Create a temporary directory with the correct structure +# Factorio expects mods to be in a folder named modname_version inside the zip +TEMP_DIR=$(mktemp -d) +MOD_DIR="${TEMP_DIR}/${OUTPUT_NAME}" +mkdir -p "$MOD_DIR" + +# Copy mod files +cp info.json "$MOD_DIR/" +cp data.lua "$MOD_DIR/" +cp control.lua "$MOD_DIR/" +cp -r locale "$MOD_DIR/" +cp -r graphics "$MOD_DIR/" +cp -r scripts "$MOD_DIR/" + +# Create the zip file +cd "$TEMP_DIR" +zip -r "$SCRIPT_DIR/$OUTPUT_FILE" "$OUTPUT_NAME" + +# Clean up +rm -rf "$TEMP_DIR" + +echo "" +echo "✅ Built: $OUTPUT_FILE" +echo "" + +if [[ "$1" != "--install" ]]; then + echo "To install:" + echo " 1. Copy $OUTPUT_FILE to your Factorio mods folder:" + echo " macOS: ~/Library/Application Support/factorio/mods/" + echo " Linux: ~/.factorio/mods/" + echo " Windows: %APPDATA%\\Factorio\\mods\\" + echo "" + echo " 2. Or run: ./build.sh --install" +fi + +# Handle --install flag +if [[ "$1" == "--install" ]]; then + # Detect Factorio mods folder + if [[ "$OSTYPE" == "darwin"* ]]; then + MODS_DIR="$HOME/Library/Application Support/factorio/mods" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + MODS_DIR="$HOME/.factorio/mods" + else + echo "❌ Auto-install not supported on this platform. Please copy manually." + exit 1 + fi + + if [[ -d "$MODS_DIR" ]]; then + # Remove old versions of this mod + rm -f "$MODS_DIR/${MOD_NAME}_"*.zip + cp "$SCRIPT_DIR/$OUTPUT_FILE" "$MODS_DIR/" + echo "" + echo "✅ Installed to: $MODS_DIR/$OUTPUT_FILE" + else + echo "❌ Mods folder not found: $MODS_DIR" + echo " Make sure Factorio has been run at least once." + exit 1 + fi +fi diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua new file mode 100644 index 0000000..6e01ff1 --- /dev/null +++ b/clock-generator-sidecar/control.lua @@ -0,0 +1,124 @@ +-- Clock Generator Sidecar - Main Control Script +-- Entry point for the mod. Handles player storage, event handlers, and +-- event registration. All heavy lifting is delegated to modules in scripts/. + +require("scripts.types") +local extraction = require("scripts.extraction") +local export = require("scripts.export") +local gui = require("scripts.gui") + +-- Player Storage + +---@type table +storage = storage or {} + +---Initialize player data in storage +---@param player_index uint +local function init_player_data(player_index) + storage[player_index] = storage[player_index] or { + machines = {}, + gui = nil + } +end + +-- Event Handlers + +---Handle selection tool area selection +---@param event EventData.on_player_selected_area +local function on_player_selected_area(event) + if event.item ~= "clock-generator-sidecar" then + return + end + + local player = game.get_player(event.player_index) + if not player then + return + end + + init_player_data(event.player_index) + local player_data = storage[event.player_index] + + -- Extract entity data (machines, drills, inserters, belts, and chests) + local result = extraction.extract_all_entities(event.entities, player.force) + player_data.extraction_result = result + + -- Show GUI + gui.create(player, player_data, result) + + local total = #result.machines + #result.drills + #result.inserters + #result.belts + #result.chests + if total == 0 then + player.print({ "clock-generator-sidecar.no-machines-selected" }) + else + player.print({ "clock-generator-sidecar.machines-found", total }) + end +end + +---Handle GUI button clicks +---@param event EventData.on_gui_click +local function on_gui_click(event) + local element = event.element + if not element or not element.valid then + return + end + + local player = game.get_player(event.player_index) + if not player then + return + end + + local player_data = storage[event.player_index] + + if element.name == "clock_generator_sidecar_copy" then + -- Show copy popup with JSON text + if player_data and player_data.extraction_result then + local result = player_data.extraction_result + if #result.machines > 0 or #result.drills > 0 or #result.inserters > 0 or #result.belts > 0 or #result.chests > 0 then + local json = export.to_json(result) + gui.create_copy_popup(player, json) + else + player.print("[Clock Generator Sidecar] No data found. Please select entities first.") + end + else + player.print("[Clock Generator Sidecar] No data found. Please select entities first.") + end + elseif element.name == "clock_generator_sidecar_close" then + gui.destroy_all(player, player_data) + elseif element.name == "clock_generator_sidecar_copy_close" then + -- Close just the copy popup + local copy_frame = player.gui.screen[gui.COPY_GUI_NAME] + if copy_frame and copy_frame.valid then + copy_frame.destroy() + end + end +end + +---Handle GUI close +---@param event EventData.on_gui_closed +local function on_gui_closed(event) + if event.element and event.element.valid and event.element.name == gui.GUI_NAME then + local player = game.get_player(event.player_index) + if player then + local player_data = storage[event.player_index] + gui.destroy(player, player_data, true) -- Clear data when user closes the window + end + end +end + +-- Event Registration + +script.on_event(defines.events.on_player_selected_area, on_player_selected_area) +script.on_event(defines.events.on_player_alt_selected_area, on_player_selected_area) +script.on_event(defines.events.on_gui_click, on_gui_click) +script.on_event(defines.events.on_gui_closed, on_gui_closed) + +-- Initialize storage for new players +script.on_event(defines.events.on_player_created, function(event) + init_player_data(event.player_index) +end) + +-- Initialize storage on load +script.on_init(function() + for _, player in pairs(game.players) do + init_player_data(player.index) + end +end) diff --git a/clock-generator-sidecar/data.lua b/clock-generator-sidecar/data.lua new file mode 100644 index 0000000..342225a --- /dev/null +++ b/clock-generator-sidecar/data.lua @@ -0,0 +1,50 @@ +-- Clock Generator Sidecar - Data Stage +-- Defines the selection tool prototype for selecting machines + +data:extend({ + -- Selection tool for extracting crafting speeds + { + type = "selection-tool", + name = "clock-generator-sidecar", + icon = "__clock-generator-sidecar__/graphics/bbq_right.png", + icon_size = 64, + subgroup = "tool", + order = "c[automated-construction]-d[clock-generator-sidecar]", + stack_size = 1, + select = { + border_color = { r = 0, g = 1, b = 0.5 }, + cursor_box_type = "copy", + mode = { "buildable-type", "same-force" }, + entity_type_filters = { "assembling-machine", "furnace", "mining-drill", "lab", "inserter", "transport-belt", "underground-belt", "splitter", "container", "logistic-container", "infinity-container", "linked-container" } + }, + alt_select = { + border_color = { r = 1, g = 0.5, b = 0 }, + cursor_box_type = "copy", + mode = { "buildable-type", "same-force" }, + entity_type_filters = { "assembling-machine", "furnace", "mining-drill", "lab", "inserter", "transport-belt", "underground-belt", "splitter", "container", "logistic-container", "infinity-container", "linked-container" } + }, + flags = { "only-in-cursor", "spawnable" } + }, + + -- Shortcut button for quick access + { + type = "shortcut", + name = "clock-generator-sidecar-shortcut", + action = "spawn-item", + item_to_spawn = "clock-generator-sidecar", + icon = "__clock-generator-sidecar__/graphics/bbq_right.png", + icon_size = 64, + small_icon = "__clock-generator-sidecar__/graphics/bbq_right.png", + small_icon_size = 64, + associated_control_input = "clock-generator-sidecar-toggle" + }, + + -- Custom input for keyboard shortcut + { + type = "custom-input", + name = "clock-generator-sidecar-toggle", + key_sequence = "ALT + E", + action = "spawn-item", + item_to_spawn = "clock-generator-sidecar" + } +}) diff --git a/clock-generator-sidecar/graphics/bbq_right.png b/clock-generator-sidecar/graphics/bbq_right.png new file mode 100644 index 0000000..7e6d754 Binary files /dev/null and b/clock-generator-sidecar/graphics/bbq_right.png differ diff --git a/clock-generator-sidecar/info.json b/clock-generator-sidecar/info.json new file mode 100644 index 0000000..450f4a6 --- /dev/null +++ b/clock-generator-sidecar/info.json @@ -0,0 +1,13 @@ +{ + "name": "clock-generator-sidecar", + "version": "0.1.0", + "title": "Clock Generator Sidecar", + "author": "abucnasty", + "contact": "", + "homepage": "", + "description": "Select machines to extract crafting speeds and productivity bonuses. Export data for use with the Clock Generator tool.", + "factorio_version": "2.0", + "dependencies": [ + "base >= 2.0" + ] +} diff --git a/clock-generator-sidecar/locale/en/locale.cfg b/clock-generator-sidecar/locale/en/locale.cfg new file mode 100644 index 0000000..8004df6 --- /dev/null +++ b/clock-generator-sidecar/locale/en/locale.cfg @@ -0,0 +1,23 @@ +[item-name] +clock-generator-sidecar=Clock Generator Sidecar + +[item-description] +clock-generator-sidecar=Select machines to extract crafting speeds and productivity bonuses. Use the shortcut (ALT+E) or toolbar button for quick access. + +[shortcut-name] +clock-generator-sidecar-shortcut=Clock Generator Sidecar + +[clock-generator-sidecar] +gui-title=Clock Generator Sidecar +entity-count=__1__ entities selected +no-machines=No machines with active recipes found in selection. +no-machines-selected=No machines with active recipes found. Make sure machines have recipes set. +machines-found=Found __1__ machines with active recipes. +copy-button=Copy JSON +close-button=Close +copy-popup-title=Copy Machine Data +copy-instructions=Select all (Ctrl+A) and copy (Ctrl+C) the JSON below, then paste into Clock Generator UI: +col-machine=Machine +col-recipe=Recipe +col-speed=Speed +col-productivity=Productivity diff --git a/clock-generator-sidecar/scripts/export.lua b/clock-generator-sidecar/scripts/export.lua new file mode 100644 index 0000000..c86d386 --- /dev/null +++ b/clock-generator-sidecar/scripts/export.lua @@ -0,0 +1,184 @@ +-- JSON Export for Clock Generator Sidecar +-- Converts extraction results to clock-generator compatible JSON format. + +require("scripts.types") + +local export = {} + +-- Helper Functions + +---Resolve a target reference to its ID using the unit_number mappings +---@param target InserterTargetRef|nil The target reference with type and unit_number +---@param result ExtractionResult The extraction result containing ID mappings +---@return table|nil config The resolved target config with type and id +local function resolve_target_id(target, result) + if not target then + return nil + end + + local target_id = 1 -- Default fallback + if target.unit_number then + if target.type == "belt" then + -- Look up belt ID + local mapped_id = result.belt_unit_number_to_id[target.unit_number] + if mapped_id then + target_id = mapped_id + end + elseif target.type == "chest" then + -- Look up chest ID + local mapped_id = result.chest_unit_number_to_id[target.unit_number] + if mapped_id then + target_id = mapped_id + end + else + -- Look up machine ID + local mapped_id = result.unit_number_to_id[target.unit_number] + if mapped_id then + target_id = mapped_id + end + end + end + + return { + type = target.type, + id = target_id + } +end + +-- Main Export Function + +---Convert extraction result to clock-generator compatible JSON +---@param result ExtractionResult +---@return string +function export.to_json(result) + local output = { + machines = {}, + drills = { + mining_productivity_level = result.mining_productivity_level, + configs = {} + }, + inserters = {}, + belts = {}, + chests = {} + } + + -- Format machines for clock-generator + for i, machine in ipairs(result.machines) do + table.insert(output.machines, { + id = i, + recipe = machine.recipe, + crafting_speed = machine.crafting_speed, + productivity = machine.productivity, + type = machine.type + }) + end + + -- Format belts for clock-generator + for i, belt in ipairs(result.belts) do + local lanes = {} + for _, lane in ipairs(belt.lanes) do + table.insert(lanes, { + ingredient = lane.ingredient or "", + stack_size = lane.stack_size + }) + end + + table.insert(output.belts, { + id = i, + type = belt.belt_type, + lanes = lanes + }) + end + + -- Format drills for clock-generator (different schema) + for i, drill in ipairs(result.drills) do + -- Look up the target machine ID from the unit_number mapping + local target_machine_id = 1 -- Default fallback (must be >0) + if drill.drop_target_unit_number then + local mapped_id = result.unit_number_to_id[drill.drop_target_unit_number] + if mapped_id then + target_machine_id = mapped_id + end + end + + table.insert(output.drills.configs, { + id = i, + type = drill.drill_type, + mined_item_name = drill.mined_item_name, + speed_bonus = drill.speed_bonus, + target = { + type = "machine", + id = target_machine_id + } + }) + end + + -- Format inserters for clock-generator + for _, inserter in ipairs(result.inserters) do + local source_config = resolve_target_id(inserter.source, result) + local sink_config = resolve_target_id(inserter.sink, result) + + -- Only include inserter if both source and sink are known + if source_config and sink_config then + local inserter_export = { + source = source_config, + sink = sink_config, + stack_size = inserter.stack_size + } + + -- Determine filters - use explicit filters first, then infer from source + local filters = {} + if #inserter.filters > 0 then + filters = inserter.filters + elseif inserter.source_recipe_outputs and #inserter.source_recipe_outputs > 0 then + -- Infer from source machine's recipe outputs + filters = inserter.source_recipe_outputs + elseif inserter.source_belt_lanes and #inserter.source_belt_lanes > 0 then + -- Infer from source belt's lane contents + for _, lane_data in ipairs(inserter.source_belt_lanes) do + if lane_data.ingredient then + table.insert(filters, lane_data.ingredient) + end + end + end + + -- Add filters if any exist (including inferred ones) + if #filters > 0 then + inserter_export.filters = filters + end + + table.insert(output.inserters, inserter_export) + end + end + + -- Format chests for clock-generator + for i, chest in ipairs(result.chests) do + if chest.chest_type == "infinity-chest" then + -- Infinity chest format + local filters = {} + for _, filter in ipairs(chest.item_filters) do + table.insert(filters, { + item_name = filter.item_name, + request_count = filter.request_count + }) + end + table.insert(output.chests, { + type = "infinity-chest", + id = i, + item_filter = filters + }) + else + -- Buffer chest format + table.insert(output.chests, { + type = "buffer-chest", + id = i, + storage_size = chest.storage_size, + item_filter = chest.item_filter + }) + end + end + + return helpers.table_to_json(output) +end + +return export diff --git a/clock-generator-sidecar/scripts/extraction.lua b/clock-generator-sidecar/scripts/extraction.lua new file mode 100644 index 0000000..484d4fb --- /dev/null +++ b/clock-generator-sidecar/scripts/extraction.lua @@ -0,0 +1,510 @@ +-- Entity Extraction for Clock Generator Sidecar +-- Functions for extracting data from Factorio entities (machines, drills, +-- inserters, belts). + +require("scripts.types") +local helpers = require("scripts.helpers") + +local extraction = {} + +-- Individual Entity Extractors + +---Extract data from a mining drill +---@param entity LuaEntity +---@return DrillData|nil +local function extract_mining_drill_data(entity) + if not entity or not entity.valid then + return nil + end + + -- Get what the drill is mining + local mining_target = entity.mining_target + if not mining_target then + return nil + end + + local total_productivity = entity.productivity_bonus or 0 + + -- Speed bonus from modules/beacons (this is what clock-generator expects) + local speed_bonus = entity.speed_bonus or 0 + + -- Get the drop target (the entity the drill is putting items into) + local drop_target = entity.drop_target + local drop_target_unit_number = nil + if drop_target and drop_target.valid and drop_target.unit_number then + drop_target_unit_number = drop_target.unit_number + end + + ---@type DrillData + local data = { + drill_type = entity.name, + mined_item_name = mining_target.name, + speed_bonus = speed_bonus, + productivity = total_productivity * 100, + drop_target_unit_number = drop_target_unit_number, + } + + return data +end + +---Extract data from a transport belt +---@param entity LuaEntity +---@return BeltData|nil +local function extract_belt_data(entity) + if not entity or not entity.valid then + return nil + end + + -- Only accept transport-belt entity type (covers all belt tiers) + if entity.prototype.subgroup.name ~= "belt" then + return nil + end + + local default_belt_stack_size = helpers.get_default_belt_stack_size(entity.force) + + local lanes = {} + + -- Transport belts have 2 lines: 1 = right lane, 2 = left lane + local max_lines = entity.get_max_transport_line_index() + + -- Extract lane 1 (right lane) first + local transport_line_1 = entity.get_transport_line(1) + local ingredient_1, stack_size_1 = helpers.get_lane_info(transport_line_1, default_belt_stack_size) + + -- Extract lane 2 (left lane) + local transport_line_2 = max_lines >= 2 and entity.get_transport_line(2) or nil + local ingredient_2, stack_size_2 = nil, nil + if transport_line_2 then + ingredient_2, stack_size_2 = helpers.get_lane_info(transport_line_2, default_belt_stack_size) + end + + -- Skip belts with no items on either lane + if not ingredient_1 and not ingredient_2 then + return nil + end + + -- If only lane 2 has items, use it as lane 1 (single lane belt) + if not ingredient_1 and ingredient_2 then + table.insert(lanes, { + ingredient = ingredient_2, + stack_size = stack_size_2 + }) + else + -- Lane 1 has items - add it + table.insert(lanes, { + ingredient = ingredient_1, + stack_size = stack_size_1 + }) + + -- Add second lane (may be empty) + table.insert(lanes, { + ingredient = ingredient_2, + stack_size = stack_size_2 or default_belt_stack_size + }) + end + + ---@type BeltData + local data = { + belt_type = helpers.normalize_belt_type(entity.name), + unit_number = entity.unit_number, + lanes = lanes + } + + return data +end + +---Check if an entity is a chest type we care about +---@param entity LuaEntity +---@return boolean +local function is_chest_entity(entity) + if not entity or not entity.valid then + return false + end + local t = entity.type + return t == "container" or t == "logistic-container" or + t == "linked-container" or t == "infinity-container" +end + +---Extract data from a chest (buffer chest or infinity chest) +---@param entity LuaEntity +---@return ChestData|nil +local function extract_chest_data(entity) + if not entity or not entity.valid then + return nil + end + + if not is_chest_entity(entity) then + return nil + end + + -- Get the chest's inventory + local inventory = entity.get_inventory(defines.inventory.chest) + if not inventory then + return nil + end + + local contents = inventory.get_contents() + if not contents or #contents == 0 then + return nil -- Skip empty chests + end + + local storage_size = #inventory -- Number of slots + + if (inventory.supports_bar()) then + storage_size = inventory.get_bar() - 1 + end + + -- Check if this is an infinity chest (has infinity_container_filters) + -- Infinity containers in Factorio have the infinity_container_filters property + if entity.type == "infinity-container" then + -- This is an infinity chest - extract filters + local filters = {} + for _, item_stack in pairs(contents) do + table.insert(filters, { + item_name = item_stack.name, + request_count = item_stack.count + }) + end + + if #filters == 0 then + return nil + end + + ---@type InfinityChestData + return { + chest_type = "infinity-chest", + unit_number = entity.unit_number, + item_filters = filters + } + else + -- This is a buffer chest - use the first item as the filter + -- Buffer chests only support a single item type + local first_item = contents[1] + if not first_item then + return nil + end + + ---@type BufferChestData + return { + chest_type = "buffer-chest", + unit_number = entity.unit_number, + storage_size = storage_size, + item_filter = first_item.name + } + end +end + +---Extract data from an inserter +---@param entity LuaEntity +---@return InserterData|nil +local function extract_inserter_data(entity) + if not entity or not entity.valid then + return nil + end + + if entity.type ~= "inserter" then + return nil + end + + -- Get stack size (use inserter_stack_size_override if set, otherwise the current target pickup count) + local stack_size = entity.inserter_stack_size_override + if stack_size == 0 then + -- No override, use the effective stack size + stack_size = entity.inserter_target_pickup_count + end + + -- Get filters + local filters = {} + local filter_slot_count = entity.filter_slot_count or 0 + for i = 1, filter_slot_count do + local filter = entity.get_filter(i) + if filter and filter.name then + table.insert(filters, filter.name) + end + end + + -- Get source (pickup target) + local source = nil + local source_recipe_outputs = nil + local source_belt_lanes = nil + local pickup_target = entity.pickup_target + if pickup_target and pickup_target.valid then + local target_type = helpers.get_target_type(pickup_target) + if target_type then + source = { + type = target_type, + unit_number = pickup_target.unit_number + } + + -- If source is a machine, get its recipe outputs for auto-configuration + if target_type == "machine" then + local recipe, _ = pickup_target.get_recipe() + if recipe then + source_recipe_outputs = {} + for _, product in pairs(recipe.products) do + if product.type == "item" then + table.insert(source_recipe_outputs, product.name) + end + end + end + -- If source is a belt, get contents from each lane + elseif target_type == "belt" then + source_belt_lanes = {} + local max_lines = pickup_target.get_max_transport_line_index() + -- Check which lanes the inserter picks from + local picks_left = entity.pickup_from_left_lane + local picks_right = entity.pickup_from_right_lane + + local default_belt_stack_size = helpers.get_default_belt_stack_size(pickup_target.force) + + for i = 1, math.min(max_lines, 2) do + local is_right_lane = (i == 1) + local is_left_lane = (i == 2) + + -- Only get contents for lanes the inserter actually picks from + if (is_right_lane and picks_right) or (is_left_lane and picks_left) then + local transport_line = pickup_target.get_transport_line(i) + local ingredient, _ = helpers.get_lane_info(transport_line, default_belt_stack_size) + if ingredient then + table.insert(source_belt_lanes, { + lane = i, + ingredient = ingredient + }) + end + end + end + end + end + end + + -- Get sink (drop target) + local sink = nil + local drop_target = entity.drop_target + if drop_target and drop_target.valid then + local target_type = helpers.get_target_type(drop_target) + if target_type then + sink = { + type = target_type, + unit_number = drop_target.unit_number + } + end + end + + ---@type InserterData + local data = { + inserter_type = entity.name, + stack_size = stack_size, + filters = filters, + source = source, + sink = sink, + source_recipe_outputs = source_recipe_outputs, + source_belt_lanes = source_belt_lanes + } + + return data +end + +---Extract crafting data from a single crafting machine entity +---@param entity LuaEntity +---@return MachineData|nil +local function extract_crafting_machine_data(entity) + if not entity or not entity.valid then + return nil + end + + -- Get recipe (skip entities without active recipe) + local recipe, quality = entity.get_recipe() + if not recipe then + return nil + end + + -- Determine type category + local entity_type = "machine" + if entity.type == "furnace" then + entity_type = "furnace" + end + + -- Get entity productivity from modules/beacons + local entity_prod_bonus = entity.productivity_bonus or 0 + + -- Get research productivity from the force's recipe + local research_prod = 0 + if entity.force and recipe then + local force_recipe = entity.force.recipes[recipe.name] + if force_recipe then + research_prod = force_recipe.productivity_bonus or 0 + end + end + + -- Combine entity productivity (modules/beacons) with research productivity + local total_productivity = entity_prod_bonus + research_prod + + -- Cap productivity at 300% (Factorio maximum) + if total_productivity > 3.0 then + total_productivity = 3.0 + end + + -- Extract data + ---@type MachineData + local data = { + name = entity.name, + recipe = recipe.name, + crafting_speed = entity.crafting_speed, + productivity = total_productivity * 100, + type = entity_type + } + + return data +end + +---Extract crafting data from a single entity (dispatches to appropriate handler) +---@param entity LuaEntity +---@return MachineData|nil, string|nil category The extracted data and category ("machine" or "drill") +local function extract_machine_data(entity) + if not entity or not entity.valid then + return nil, nil + end + + -- Handle mining drills separately + if entity.type == "mining-drill" then + return extract_mining_drill_data(entity), "drill" + end + + -- Only handle entities with supported subgroups + local supported_subgroups = { + ["smelting-machine"] = true, + ["production-machine"] = true + } + + local prototype = entity.prototype + local subgroup = prototype and prototype.subgroup and prototype.subgroup.name + if not supported_subgroups[subgroup] then + return nil, nil + end + + -- Handle crafting machines (assemblers, furnaces, etc.) + return extract_crafting_machine_data(entity), "machine" +end + +-- Main Extraction Orchestrator +--- - Extract data from all selected entities, separating machines, drills, inserters, and belts +---@param entities LuaEntity[] +---@param force LuaForce? The player's force (for researched bonuses) +---@return ExtractionResult +function extraction.extract_all_entities(entities, force) + local mining_productivity_level = 1 + if force and force.technologies['mining-productivity-3'] then + mining_productivity_level = force.technologies['mining-productivity-3'].level - 1 + end + + local result = { + machines = {}, + drills = {}, + inserters = {}, + belts = {}, + chests = {}, + unit_number_to_id = {}, + belt_unit_number_to_id = {}, + chest_unit_number_to_id = {}, + mining_productivity_level = mining_productivity_level + } + + -- First pass: extract machines and build unit_number -> id mapping + local machine_id = 0 + for _, entity in pairs(entities) do + if entity.type ~= "mining-drill" and entity.type ~= "inserter" and entity.type ~= "transport-belt" then + local data, entity_category = extract_machine_data(entity) + if data and entity_category == "machine" then + machine_id = machine_id + 1 + table.insert(result.machines, data) + -- Map the entity's unit_number to its assigned ID + if entity.unit_number then + result.unit_number_to_id[entity.unit_number] = machine_id + end + end + end + end + + -- Second pass: extract belts and build belt unit_number -> id mapping + -- Consolidate belts by their ingredient set (belts with same ingredients are treated as one) + local belt_id = 0 + local ingredient_signature_to_belt = {} -- Maps "ingredient1|ingredient2" to {belt_id, data} + + for _, entity in pairs(entities) do + local belt_data = extract_belt_data(entity) + if belt_data then + -- Create a signature based on the unique set of ingredients (sorted for consistency) + local ingredient_set = {} + for _, lane in ipairs(belt_data.lanes) do + if lane.ingredient and lane.ingredient ~= "" then + ingredient_set[lane.ingredient] = true + end + end + -- Convert set to sorted array + local ingredients = {} + for ingredient, _ in pairs(ingredient_set) do + table.insert(ingredients, ingredient) + end + table.sort(ingredients) + local signature = table.concat(ingredients, "|") + + local existing = ingredient_signature_to_belt[signature] + if existing then + -- Map this belt to the existing belt with same ingredients + if entity.unit_number then + result.belt_unit_number_to_id[entity.unit_number] = existing.belt_id + end + else + -- New unique belt line + belt_id = belt_id + 1 + ingredient_signature_to_belt[signature] = { + belt_id = belt_id, + data = belt_data + } + table.insert(result.belts, belt_data) + if entity.unit_number then + result.belt_unit_number_to_id[entity.unit_number] = belt_id + end + end + end + end + + -- Third pass: extract drills (now we have the unit_number mapping) + for _, entity in pairs(entities) do + if entity.type == "mining-drill" then + local data = extract_mining_drill_data(entity) + if data then + table.insert(result.drills, data) + end + end + end + + -- Fourth pass: extract inserters + for _, entity in pairs(entities) do + if entity.type == "inserter" then + local data = extract_inserter_data(entity) + if data then + table.insert(result.inserters, data) + end + end + end + + -- Fifth pass: extract chests and build chest unit_number -> id mapping + local chest_id = 0 + for _, entity in pairs(entities) do + if is_chest_entity(entity) then + local data = extract_chest_data(entity) + if data then + chest_id = chest_id + 1 + table.insert(result.chests, data) + if entity.unit_number then + result.chest_unit_number_to_id[entity.unit_number] = chest_id + end + end + end + end + + return result +end + +return extraction diff --git a/clock-generator-sidecar/scripts/gui.lua b/clock-generator-sidecar/scripts/gui.lua new file mode 100644 index 0000000..4113d2b --- /dev/null +++ b/clock-generator-sidecar/scripts/gui.lua @@ -0,0 +1,407 @@ +-- GUI Management for Clock Generator Sidecar +-- Handles creation and destruction of the extraction results GUI and copy popup. + +require("scripts.types") + +local gui = {} + +-- Constants + +gui.GUI_NAME = "clock_generator_sidecar_frame" +gui.COPY_GUI_NAME = "clock_generator_sidecar_copy_frame" + +-- Helper Functions + +---Add a section with header and table to the scroll pane +---@param scroll LuaGuiElement The scroll pane to add to +---@param title string The section header title +---@param column_count number Number of columns in the table +---@param headers string[]|table[] Array of header labels (can be strings or locale tables) +---@param alignments table? Optional column alignments (1-indexed) +---@return LuaGuiElement table The created table element +local function add_section_table(scroll, title, column_count, headers, alignments) + local section_header = scroll.add({ + type = "label", + caption = title, + style = "heading_2_label" + }) + section_header.style.bottom_margin = 4 + + local tbl = scroll.add({ + type = "table", + column_count = column_count, + draw_horizontal_lines = true, + draw_vertical_lines = false + }) + tbl.style.horizontal_spacing = 16 + + -- Apply alignments + if alignments then + for col, alignment in pairs(alignments) do + tbl.style.column_alignments[col] = alignment + end + end + + -- Add header row + for _, h in ipairs(headers) do + tbl.add({ + type = "label", + caption = h, + style = "bold_label" + }) + end + + return tbl +end + +---Add a vertical spacer between sections +---@param scroll LuaGuiElement The scroll pane to add to +local function add_spacer(scroll) + local spacer = scroll.add({ type = "flow" }) + spacer.style.height = 16 +end + +-- GUI Destruction + +---Destroy the main results GUI for a player (not the copy popup) +---@param player LuaPlayer +---@param player_data PlayerData|nil The player's storage data +---@param clear_data boolean? Whether to also clear the extraction result data (default: false) +function gui.destroy(player, player_data, clear_data) + local frame = player.gui.screen[gui.GUI_NAME] + if frame and frame.valid then + frame.destroy() + end + if player_data then + player_data.gui = nil + if clear_data then + player_data.extraction_result = nil + end + end +end + +---Destroy all GUIs for a player (including copy popup) +---@param player LuaPlayer +---@param player_data PlayerData|nil The player's storage data +---@param clear_data boolean? Whether to also clear the extraction result data (default: true) +function gui.destroy_all(player, player_data, clear_data) + if clear_data == nil then + clear_data = true + end + gui.destroy(player, player_data, clear_data) + local copy_frame = player.gui.screen[gui.COPY_GUI_NAME] + if copy_frame and copy_frame.valid then + copy_frame.destroy() + end +end + +-- Copy Popup + +---Create the copy/paste popup with selectable JSON text +---@param player LuaPlayer +---@param json string +function gui.create_copy_popup(player, json) + -- Remove any existing copy popup + local existing = player.gui.screen[gui.COPY_GUI_NAME] + if existing and existing.valid then + existing.destroy() + end + + -- Create popup frame + local frame = player.gui.screen.add({ + type = "frame", + name = gui.COPY_GUI_NAME, + direction = "vertical", + caption = { "clock-generator-sidecar.copy-popup-title" } + }) + frame.auto_center = true + + -- Instructions + frame.add({ + type = "label", + caption = { "clock-generator-sidecar.copy-instructions" } + }) + + -- Text box with JSON (user can select all and copy) + local textbox = frame.add({ + type = "text-box", + name = "clock_generator_sidecar_json_text", + text = json + }) + textbox.style.width = 600 + textbox.style.height = 300 + textbox.read_only = true + textbox.word_wrap = true + -- Select all text automatically + textbox.select_all() + textbox.focus() + + -- Button flow + local button_flow = frame.add({ + type = "flow", + direction = "horizontal" + }) + button_flow.style.horizontal_spacing = 8 + button_flow.style.top_margin = 8 + button_flow.style.horizontally_stretchable = true + button_flow.style.horizontal_align = "right" + + button_flow.add({ + type = "button", + name = "clock_generator_sidecar_copy_close", + caption = { "clock-generator-sidecar.close-button" } + }) +end + +-- Main Results GUI + +---Create the GUI to display extracted data +---@param player LuaPlayer +---@param player_data PlayerData The player's storage data +---@param result ExtractionResult +function gui.create(player, player_data, result) + -- Remove existing GUI + gui.destroy(player, player_data) + + local total_count = #result.machines + #result.drills + #result.inserters + #result.belts + #result.chests + + -- Main frame + local frame = player.gui.screen.add({ + type = "frame", + name = gui.GUI_NAME, + direction = "vertical", + caption = { "clock-generator-sidecar.gui-title" } + }) + frame.auto_center = true + + -- Store reference + player_data.gui = frame + + -- Header flow with count + local header = frame.add({ + type = "flow", + direction = "horizontal" + }) + header.style.horizontal_spacing = 8 + header.style.bottom_margin = 8 + + header.add({ + type = "label", + caption = { "clock-generator-sidecar.entity-count", total_count }, + style = "heading_2_label" + }) + + -- Content frame + local content_frame = frame.add({ + type = "frame", + direction = "vertical", + style = "inside_shallow_frame_with_padding" + }) + content_frame.style.minimal_width = 600 + content_frame.style.maximal_height = 500 + + if total_count == 0 then + content_frame.add({ + type = "label", + caption = { "clock-generator-sidecar.no-machines" }, + style = "bold_label" + }) + else + -- Scroll pane for entity list + local scroll = content_frame.add({ + type = "scroll-pane", + direction = "vertical", + vertical_scroll_policy = "auto", + horizontal_scroll_policy = "never" + }) + scroll.style.maximal_height = 500 + + -- Display machines if any + if #result.machines > 0 then + local machine_table = add_section_table( + scroll, + "Machines & Furnaces (" .. #result.machines .. ")", + 5, + { "#", { "clock-generator-sidecar.col-machine" }, { "clock-generator-sidecar.col-recipe" }, { "clock-generator-sidecar.col-speed" }, { "clock-generator-sidecar.col-productivity" } }, + { [1] = "center", [4] = "right", [5] = "right" } + ) + + -- Machine rows + for i, machine in ipairs(result.machines) do + machine_table.add({ type = "label", caption = tostring(i) }) + machine_table.add({ type = "label", caption = machine.name }) + machine_table.add({ type = "label", caption = machine.recipe }) + machine_table.add({ type = "label", caption = string.format("%.2f", machine.crafting_speed) }) + machine_table.add({ type = "label", caption = string.format("%.1f%%", machine.productivity) }) + end + + if #result.drills > 0 or #result.inserters > 0 or #result.belts > 0 then + add_spacer(scroll) + end + end + + -- Display drills if any + if #result.drills > 0 then + local drill_table = add_section_table( + scroll, + "Mining Drills (" .. #result.drills .. ")", + 5, + { "#", "Drill Type", "Resource", "Speed Bonus", { "clock-generator-sidecar.col-productivity" } }, + { [1] = "center", [4] = "right", [5] = "right" } + ) + + -- Drill rows + for i, drill in ipairs(result.drills) do + drill_table.add({ type = "label", caption = tostring(i) }) + drill_table.add({ type = "label", caption = drill.drill_type }) + drill_table.add({ type = "label", caption = drill.mined_item_name }) + drill_table.add({ type = "label", caption = string.format("+%.0f%%", drill.speed_bonus * 100) }) + drill_table.add({ type = "label", caption = string.format("%.1f%%", drill.productivity) }) + end + + if #result.inserters > 0 or #result.belts > 0 then + add_spacer(scroll) + end + end + + -- Display inserters if any + if #result.inserters > 0 then + local inserter_table = add_section_table( + scroll, + "Inserters (" .. #result.inserters .. ")", + 5, + { "#", "Type", "Source", "Stack", "Sink" }, + { [1] = "center", [2] = "left", [3] = "left", [4] = "center", [5] = "left" } + ) + + -- Inserter rows + for i, inserter in ipairs(result.inserters) do + inserter_table.add({ type = "label", caption = tostring(i) }) + inserter_table.add({ type = "label", caption = inserter.inserter_type }) + + -- Source description + local source_text = "?" + if inserter.source then + source_text = inserter.source.type + end + inserter_table.add({ type = "label", caption = source_text }) + + inserter_table.add({ type = "label", caption = tostring(inserter.stack_size) }) + + -- Sink description + local sink_text = "?" + if inserter.sink then + sink_text = inserter.sink.type + end + inserter_table.add({ type = "label", caption = sink_text }) + end + + if #result.belts > 0 or #result.chests > 0 then + add_spacer(scroll) + end + end + + -- Display belts if any + if #result.belts > 0 then + local belt_table = add_section_table( + scroll, + "Transport Belts (" .. #result.belts .. ")", + 4, + { "#", "Type", "Lane 1", "Lane 2" }, + { [1] = "center", [2] = "left", [3] = "left", [4] = "left" } + ) + + -- Belt rows + for i, belt in ipairs(result.belts) do + belt_table.add({ type = "label", caption = tostring(i) }) + belt_table.add({ type = "label", caption = belt.belt_type }) + + -- Lane 1 (right lane) + local lane1_text = "(empty)" + if belt.lanes[1] and belt.lanes[1].ingredient then + lane1_text = belt.lanes[1].ingredient + end + belt_table.add({ type = "label", caption = lane1_text }) + + -- Lane 2 (left lane) + local lane2_text = "(empty)" + if belt.lanes[2] and belt.lanes[2].ingredient then + lane2_text = belt.lanes[2].ingredient + end + belt_table.add({ type = "label", caption = lane2_text }) + end + + if #result.chests > 0 then + add_spacer(scroll) + end + end + + -- Display chests if any + if #result.chests > 0 then + local chest_table = add_section_table( + scroll, + "Chests (" .. #result.chests .. ")", + 4, + { "#", "Type", "Items", "Slots" }, + { [1] = "center", [2] = "left", [3] = "left", [4] = "center" } + ) + + -- Chest rows + for i, chest in ipairs(result.chests) do + chest_table.add({ type = "label", caption = tostring(i) }) + chest_table.add({ type = "label", caption = chest.chest_type }) + + -- Items description + local items_text = "" + if chest.chest_type == "infinity-chest" then + local item_names = {} + for _, filter in ipairs(chest.item_filters) do + table.insert(item_names, filter.item_name .. ":" .. filter.request_count) + end + items_text = table.concat(item_names, ", ") + else + items_text = chest.item_filter + end + chest_table.add({ type = "label", caption = items_text }) + + -- Slots (only for buffer chests) + local slots_text = "-" + if chest.chest_type == "buffer-chest" then + slots_text = tostring(chest.storage_size) + end + chest_table.add({ type = "label", caption = slots_text }) + end + end + end + + -- Button flow + local button_flow = frame.add({ + type = "flow", + direction = "horizontal" + }) + button_flow.style.horizontal_spacing = 8 + button_flow.style.top_margin = 8 + button_flow.style.horizontally_stretchable = true + button_flow.style.horizontal_align = "right" + + -- Copy button (only if we have data) + if total_count > 0 then + button_flow.add({ + type = "button", + name = "clock_generator_sidecar_copy", + caption = { "clock-generator-sidecar.copy-button" }, + style = "confirm_button" + }) + end + + -- Close button + button_flow.add({ + type = "button", + name = "clock_generator_sidecar_close", + caption = { "clock-generator-sidecar.close-button" } + }) + + player.opened = frame +end + +return gui diff --git a/clock-generator-sidecar/scripts/helpers.lua b/clock-generator-sidecar/scripts/helpers.lua new file mode 100644 index 0000000..40caf4c --- /dev/null +++ b/clock-generator-sidecar/scripts/helpers.lua @@ -0,0 +1,105 @@ +-- Helper Functions for Clock Generator Sidecar +-- Shared utility functions used across extraction and export modules. + +local helpers = {} + +---Get the default belt stack size for a force (1 + researched bonus) +---@param force LuaForce|nil The force to check +---@return number The default belt stack size +function helpers.get_default_belt_stack_size(force) + if force then + return 1 + (force.belt_stack_size_bonus or 0) + end + return 1 +end + +---Determine the target type for an entity +---@param entity LuaEntity +---@return "machine"|"belt"|"chest"|nil +function helpers.get_target_type(entity) + if not entity or not entity.valid then + return nil + end + + local entity_type = entity.type + + -- Machines (assemblers, furnaces, labs) + if entity_type == "assembling-machine" or entity_type == "furnace" or entity_type == "lab" then + return "machine" + end + + -- Belts + if entity_type == "transport-belt" or entity_type == "underground-belt" or + entity_type == "splitter" or entity_type == "loader" or entity_type == "loader-1x1" then + return "belt" + end + + -- Containers/Chests + if entity_type == "container" or entity_type == "logistic-container" or + entity_type == "linked-container" or entity_type == "infinity-container" then + return "chest" + end + + return nil +end + +---Get the primary ingredient and max stack size from a transport line +---@param transport_line LuaTransportLine +---@param default_stack_size number? The default stack size to use if no items are found (default 1) +---@return string|nil ingredient The primary ingredient name, or nil +---@return number stack_size The maximum stack size found on this lane, or default_stack_size +function helpers.get_lane_info(transport_line, default_stack_size) + default_stack_size = default_stack_size or 1 + + if not transport_line or not transport_line.valid then + return nil, default_stack_size + end + + local contents = transport_line.get_contents() + if not contents or #contents == 0 then + return nil, default_stack_size + end + + -- Get the first item found on this lane + -- contents is an array of {name, quality, count} + local first_item = contents[1] + local ingredient = first_item and first_item.name or nil + + -- Get max stack size from detailed contents + local max_stack = 1 + local detailed = transport_line.get_detailed_contents() + if detailed then + for _, item_info in pairs(detailed) do + if item_info.stack and item_info.stack.valid and item_info.stack.count then + max_stack = math.max(max_stack, item_info.stack.count) + end + end + end + + return ingredient, max_stack +end + +---Normalize belt name to transport belt type (converts underground belts and splitters) +---@param name string The entity name (e.g., "turbo-underground-belt", "fast-splitter") +---@return string The normalized transport belt name (e.g., "turbo-transport-belt", "fast-transport-belt") +function helpers.normalize_belt_type(name) + -- Map underground belts to transport belts + local underground_to_belt = { + ["underground-belt"] = "transport-belt", + ["fast-underground-belt"] = "fast-transport-belt", + ["express-underground-belt"] = "express-transport-belt", + ["turbo-underground-belt"] = "turbo-transport-belt" + } + + -- Map splitters to transport belts + local splitter_to_belt = { + ["splitter"] = "transport-belt", + ["fast-splitter"] = "fast-transport-belt", + ["express-splitter"] = "express-transport-belt", + ["turbo-splitter"] = "turbo-transport-belt" + } + + return underground_to_belt[name] or splitter_to_belt[name] or name +end + +return helpers diff --git a/clock-generator-sidecar/scripts/types.lua b/clock-generator-sidecar/scripts/types.lua new file mode 100644 index 0000000..4a66b6e --- /dev/null +++ b/clock-generator-sidecar/scripts/types.lua @@ -0,0 +1,74 @@ +---@class MachineData +---@field name string Machine entity name +---@field recipe string Recipe name (for machines/furnaces) +---@field crafting_speed number Effective crafting speed with bonuses (for machines/furnaces) +---@field productivity number Productivity bonus as percentage (0-100+) +---@field type "machine"|"furnace"|"mining-drill" Entity type category + +---@class DrillData +---@field drill_type string The specific drill type (e.g., "electric-mining-drill") +---@field mined_item_name string The resource being mined +---@field speed_bonus number The speed bonus from modules/beacons +---@field productivity number Productivity bonus as percentage (for display only) +---@field drop_target_unit_number number|nil The unit_number of the entity the drill drops to + +---@class BeltLaneData +---@field ingredient string|nil The item on this lane (nil if empty or mixed) +---@field stack_size number The belt stack size + +---@class BeltData +---@field belt_type string The belt type (e.g., "transport-belt", "express-transport-belt") +---@field unit_number number The unit number of the belt entity +---@field lanes BeltLaneData[] Data for each lane (1 = right, 2 = left) + +---@class InserterTargetRef +---@field type "machine"|"belt"|"chest" The target type +---@field unit_number number The unit number of the target entity + +---@class InserterBeltLaneInfo +---@field lane number The lane index (1 = right, 2 = left) +---@field ingredient string The item on this lane + +---@class InfinityFilterData +---@field item_name string The item name +---@field request_count number The quantity of items + +---@class BufferChestData +---@field chest_type "buffer-chest" The chest type discriminator +---@field unit_number number The unit number of the chest entity +---@field storage_size number Number of inventory slots +---@field item_filter string The single item type this chest holds + +---@class InfinityChestData +---@field chest_type "infinity-chest" The chest type discriminator +---@field unit_number number The unit number of the chest entity +---@field item_filters InfinityFilterData[] The items and quantities in this chest + +---@alias ChestData BufferChestData|InfinityChestData + +---@class InserterData +---@field inserter_type string The inserter entity name +---@field stack_size number The inserter stack size +---@field filters string[] Item filters set on the inserter +---@field source InserterTargetRef|nil The pickup target +---@field sink InserterTargetRef|nil The drop target +---@field source_recipe_outputs string[]|nil Recipe outputs from source machine +---@field source_belt_lanes InserterBeltLaneInfo[]|nil Belt lane contents from source belt + +---@class PlayerData +---@field machines MachineData[] Extracted machine data +---@field gui LuaGuiElement? Reference to the GUI frame +---@field extraction_result ExtractionResult? Cached extraction result + +---@class ExtractionResult +---@field machines MachineData[] +---@field drills DrillData[] +---@field inserters InserterData[] +---@field belts BeltData[] +---@field chests ChestData[] +---@field unit_number_to_id table Maps entity unit_number to machine ID +---@field belt_unit_number_to_id table Maps belt unit_number to belt ID +---@field chest_unit_number_to_id table Maps chest unit_number to chest ID +---@field mining_productivity_level number The researched mining productivity level + +return {} diff --git a/clock-generator-ui/src/App.tsx b/clock-generator-ui/src/App.tsx index 9268a58..377e9eb 100644 --- a/clock-generator-ui/src/App.tsx +++ b/clock-generator-ui/src/App.tsx @@ -2,13 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { AppBar, Box, - Button, CircularProgress, Container, CssBaseline, ThemeProvider, Toolbar, - Tooltip, Typography, createTheme, Alert, @@ -97,22 +95,27 @@ function App() { addMachine, updateMachine, removeMachine, + replaceMachines, addInserter, updateInserter, removeInserter, addBelt, updateBelt, removeBelt, + replaceBelts, addChest, updateChest, switchChestType, removeChest, + replaceChests, enableDrills, disableDrills, updateDrillsConfig, addDrill, updateDrill, removeDrill, + replaceDrills, + replaceInserters, updateOverrides, importConfig, exportConfig, @@ -178,17 +181,15 @@ function App() { updateDrillsConfig('mining_productivity_level', level)} + onReset={resetConfig} parseConfig={parseConfig} /> - - - {/* Spacer to account for fixed AppBar */} diff --git a/clock-generator-ui/src/components/ConfigImportExport.tsx b/clock-generator-ui/src/components/ConfigImportExport.tsx index 23a53f9..c5d4d8f 100644 --- a/clock-generator-ui/src/components/ConfigImportExport.tsx +++ b/clock-generator-ui/src/components/ConfigImportExport.tsx @@ -1,17 +1,56 @@ -import { Download, Upload } from '@mui/icons-material'; -import { Box, Button, Snackbar, Alert } from '@mui/material'; +import { Download, Upload, ContentPaste, MoreVert, RestartAlt } from '@mui/icons-material'; +import { Box, Button, Snackbar, Alert, Tooltip, Dialog, DialogTitle, DialogContent, DialogActions, FormControlLabel, Checkbox, Typography, IconButton, Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material'; import { useRef, useState, useCallback } from 'react'; import type { Config } from 'clock-generator/browser'; +import { MachineConfigurationSchema, MiningDrillConfigSchema, InserterConfigSchema, BeltConfigSchema, ChestConfigSchema } from 'clock-generator/browser'; +import type { z } from 'zod'; + +type MachineConfiguration = z.infer; +type MiningDrillConfiguration = z.infer; +type InserterConfiguration = z.infer; +type BeltConfiguration = z.infer; +type ChestConfiguration = z.infer; + +interface PendingImport { + machines: MachineConfiguration[]; + drills: MiningDrillConfiguration[]; + inserters: InserterConfiguration[]; + belts: BeltConfiguration[]; + chests: ChestConfiguration[]; + miningProductivityLevel?: number; +} + +interface ImportSelections { + machines: boolean; + drills: boolean; + inserters: boolean; + belts: boolean; + chests: boolean; +} interface ConfigImportExportProps { config: Config; onImport: (config: Config) => void; + onReplaceMachines: (machines: MachineConfiguration[]) => void; + onReplaceDrills: (drills: MiningDrillConfiguration[]) => void; + onReplaceInserters: (inserters: InserterConfiguration[]) => void; + onReplaceBelts: (belts: BeltConfiguration[]) => void; + onReplaceChests: (chests: ChestConfiguration[]) => void; + onUpdateMiningProductivityLevel: (level: number) => void; + onReset: () => void; parseConfig: (content: string) => Promise; } export function ConfigImportExport({ config, onImport, + onReplaceMachines, + onReplaceDrills, + onReplaceInserters, + onReplaceBelts, + onReplaceChests, + onUpdateMiningProductivityLevel, + onReset, parseConfig, }: ConfigImportExportProps) { const fileInputRef = useRef(null); @@ -21,6 +60,20 @@ export function ConfigImportExport({ severity: 'success' | 'error'; }>({ open: false, message: '', severity: 'success' }); + // State for the hamburger menu + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const menuOpen = Boolean(menuAnchorEl); + + // State for the import confirmation modal + const [pendingImport, setPendingImport] = useState(null); + const [importSelections, setImportSelections] = useState({ + machines: true, + drills: true, + inserters: true, + belts: true, + chests: true, + }); + const handleExport = useCallback(() => { const target_recipe_name = config.target_output.recipe const jsonString = JSON.stringify(config, null, 2); @@ -86,9 +139,256 @@ export function ConfigImportExport({ setSnackbar((prev) => ({ ...prev, open: false })); }, []); + const handlePasteFromFactorio = useCallback(async () => { + try { + const clipboardText = await navigator.clipboard.readText(); + + // Try to parse as JSON + let parsed: unknown; + try { + parsed = JSON.parse(clipboardText); + } catch { + throw new Error('Clipboard does not contain valid JSON. Make sure to copy from the Factorio mod.'); + } + + // Check if it's the new format with machines and drills + if (typeof parsed === 'object' && parsed !== null && 'machines' in parsed) { + const data = parsed as { + machines?: unknown[]; + drills?: { mining_productivity_level?: number; configs?: unknown[] } | unknown[]; + inserters?: unknown[]; + belts?: unknown[]; + chests?: unknown[]; + }; + + const machineCount = Array.isArray(data.machines) ? data.machines.length : 0; + // Handle new drills format: { mining_productivity_level, configs: [...] } + const drillsData = data.drills; + const drillConfigs = drillsData && typeof drillsData === 'object' && !Array.isArray(drillsData) && 'configs' in drillsData + ? (drillsData.configs as unknown[]) + : (Array.isArray(drillsData) ? drillsData : []); + const miningProductivityLevel = drillsData && typeof drillsData === 'object' && !Array.isArray(drillsData) && 'mining_productivity_level' in drillsData + ? (drillsData.mining_productivity_level as number) + : undefined; + const drillCount = Array.isArray(drillConfigs) ? drillConfigs.length : 0; + const inserterCount = Array.isArray(data.inserters) ? data.inserters.length : 0; + const beltCount = Array.isArray(data.belts) ? data.belts.length : 0; + const chestCount = Array.isArray(data.chests) ? data.chests.length : 0; + + if (machineCount === 0 && drillCount === 0 && inserterCount === 0 && beltCount === 0 && chestCount === 0) { + throw new Error('No machines, drills, inserters, belts, or chests found in clipboard data.'); + } + + // Validate machines (IDs are already assigned by the mod) + const validatedMachines: MachineConfiguration[] = []; + if (Array.isArray(data.machines)) { + for (let i = 0; i < data.machines.length; i++) { + const entry = data.machines[i]; + const result = MachineConfigurationSchema.safeParse(entry); + if (!result.success) { + throw new Error(`Invalid machine at index ${i}: ${result.error.issues[0]?.message || 'Unknown error'}`); + } + validatedMachines.push(result.data); + } + } + + // Validate drills (IDs are already assigned by the mod) + const validatedDrills: MiningDrillConfiguration[] = []; + if (Array.isArray(drillConfigs)) { + for (let i = 0; i < drillConfigs.length; i++) { + const entry = drillConfigs[i]; + const result = MiningDrillConfigSchema.safeParse(entry); + if (!result.success) { + throw new Error(`Invalid drill at index ${i}: ${result.error.issues[0]?.message || 'Unknown error'}`); + } + validatedDrills.push(result.data); + } + } + + // Validate inserters + const validatedInserters: InserterConfiguration[] = []; + if (Array.isArray(data.inserters)) { + for (let i = 0; i < data.inserters.length; i++) { + const entry = data.inserters[i]; + const result = InserterConfigSchema.safeParse(entry); + if (!result.success) { + throw new Error(`Invalid inserter at index ${i}: ${result.error.issues[0]?.message || 'Unknown error'}`); + } + validatedInserters.push(result.data); + } + } + + // Validate belts + const validatedBelts: BeltConfiguration[] = []; + if (Array.isArray(data.belts)) { + for (let i = 0; i < data.belts.length; i++) { + const entry = data.belts[i]; + const result = BeltConfigSchema.safeParse(entry); + if (!result.success) { + throw new Error(`Invalid belt at index ${i}: ${result.error.issues[0]?.message || 'Unknown error'}`); + } + validatedBelts.push(result.data); + } + } + + // Validate chests + const validatedChests: ChestConfiguration[] = []; + if (Array.isArray(data.chests)) { + for (let i = 0; i < data.chests.length; i++) { + const entry = data.chests[i]; + const result = ChestConfigSchema.safeParse(entry); + if (!result.success) { + throw new Error(`Invalid chest at index ${i}: ${result.error.issues[0]?.message || 'Unknown error'}`); + } + validatedChests.push(result.data); + } + } + + // Store pending import and show confirmation modal + setPendingImport({ + machines: validatedMachines, + drills: validatedDrills, + inserters: validatedInserters, + belts: validatedBelts, + chests: validatedChests, + miningProductivityLevel, + }); + // Reset selections to include all available items + setImportSelections({ + machines: validatedMachines.length > 0, + drills: validatedDrills.length > 0, + inserters: validatedInserters.length > 0, + belts: validatedBelts.length > 0, + chests: validatedChests.length > 0, + }); + return; + } + + // Legacy format: array of machines + if (!Array.isArray(parsed)) { + throw new Error('Expected machines/drills object or array from Factorio mod.'); + } + + if (parsed.length === 0) { + throw new Error('No machines found in clipboard data.'); + } + + // Validate each machine entry (IDs are already assigned by the mod) + const validatedMachines: MachineConfiguration[] = []; + + for (let i = 0; i < parsed.length; i++) { + const entry = parsed[i]; + const result = MachineConfigurationSchema.safeParse(entry); + if (!result.success) { + throw new Error(`Invalid machine at index ${i}: ${result.error.issues[0]?.message || 'Unknown error'}`); + } + validatedMachines.push(result.data); + } + + // Store pending import and show confirmation modal (legacy format - machines only) + setPendingImport({ + machines: validatedMachines, + drills: [], + inserters: [], + belts: [], + chests: [], + }); + setImportSelections({ + machines: true, + drills: false, + inserters: false, + belts: false, + chests: false, + }); + } catch (error) { + console.error('Paste from Factorio error:', error); + setSnackbar({ + open: true, + message: `Failed to paste: ${error instanceof Error ? error.message : 'Unknown error'}`, + severity: 'error', + }); + } + }, []); + + const handleConfirmImport = useCallback(() => { + if (!pendingImport) return; + + const parts: string[] = []; + + if (importSelections.machines && pendingImport.machines.length > 0) { + onReplaceMachines(pendingImport.machines); + parts.push(`${pendingImport.machines.length} machines`); + } + if (importSelections.drills && pendingImport.drills.length > 0) { + onReplaceDrills(pendingImport.drills); + parts.push(`${pendingImport.drills.length} drills`); + // Also update mining productivity level when importing drills + if (pendingImport.miningProductivityLevel !== undefined) { + onUpdateMiningProductivityLevel(pendingImport.miningProductivityLevel); + } + } + if (importSelections.inserters && pendingImport.inserters.length > 0) { + onReplaceInserters(pendingImport.inserters); + parts.push(`${pendingImport.inserters.length} inserters`); + } + if (importSelections.belts && pendingImport.belts.length > 0) { + onReplaceBelts(pendingImport.belts); + parts.push(`${pendingImport.belts.length} belts`); + } + if (importSelections.chests && pendingImport.chests.length > 0) { + onReplaceChests(pendingImport.chests); + parts.push(`${pendingImport.chests.length} chests`); + } + + setPendingImport(null); + + if (parts.length > 0) { + setSnackbar({ + open: true, + message: `Imported ${parts.join(', ')} from Factorio!`, + severity: 'success', + }); + } else { + setSnackbar({ + open: true, + message: 'No items selected for import.', + severity: 'error', + }); + } + }, [pendingImport, importSelections, onReplaceMachines, onReplaceDrills, onReplaceInserters, onReplaceBelts, onReplaceChests, onUpdateMiningProductivityLevel]); + + const handleCancelImport = useCallback(() => { + setPendingImport(null); + }, []); + + const handleSelectionChange = useCallback((key: keyof ImportSelections) => { + setImportSelections(prev => ({ + ...prev, + [key]: !prev[key], + })); + }, []); + + const handleMenuOpen = useCallback((event: React.MouseEvent) => { + setMenuAnchorEl(event.currentTarget); + }, []); + + const handleMenuClose = useCallback(() => { + setMenuAnchorEl(null); + }, []); + + const handleMenuPasteFromFactorio = useCallback(() => { + handleMenuClose(); + handlePasteFromFactorio(); + }, [handlePasteFromFactorio]); + + const handleMenuReset = useCallback(() => { + handleMenuClose(); + onReset(); + }, [onReset]); + return ( <> - + + + + + + + + + + + + Paste from Factorio + + + + + + Reset Configuration + + + + {/* Import Confirmation Dialog */} + + Confirm Import from Factorio + + + Select which items you want to import. Existing items of each selected type will be replaced. + + + {pendingImport && ( + + handleSelectionChange('machines')} + disabled={pendingImport.machines.length === 0} + /> + } + label={`Machines (${pendingImport.machines.length})`} + /> + handleSelectionChange('drills')} + disabled={pendingImport.drills.length === 0} + /> + } + label={`Drills (${pendingImport.drills.length})${pendingImport.miningProductivityLevel !== undefined ? ` - Mining Productivity ${pendingImport.miningProductivityLevel}` : ''}`} + /> + handleSelectionChange('inserters')} + disabled={pendingImport.inserters.length === 0} + /> + } + label={`Inserters (${pendingImport.inserters.length})`} + /> + handleSelectionChange('belts')} + disabled={pendingImport.belts.length === 0} + /> + } + label={`Belts (${pendingImport.belts.length})`} + /> + handleSelectionChange('chests')} + disabled={pendingImport.chests.length === 0} + /> + } + label={`Chests (${pendingImport.chests.length})`} + /> + + )} + + + + + + ); } diff --git a/clock-generator-ui/src/hooks/useConfigForm.ts b/clock-generator-ui/src/hooks/useConfigForm.ts index 7ffab05..b540e59 100644 --- a/clock-generator-ui/src/hooks/useConfigForm.ts +++ b/clock-generator-ui/src/hooks/useConfigForm.ts @@ -230,22 +230,27 @@ export interface UseConfigFormResult { addMachine: () => void; updateMachine: (index: number, field: keyof MachineFormData, value: string | number) => void; removeMachine: (index: number) => void; + mergeMachines: (machines: MachineFormData[]) => void; + replaceMachines: (machines: MachineFormData[]) => void; // Inserters addInserter: () => void; updateInserter: (index: number, updates: Partial) => void; removeInserter: (index: number) => void; + replaceInserters: (inserters: InserterFormData[]) => void; // Belts addBelt: () => void; updateBelt: (index: number, updates: Partial) => void; removeBelt: (index: number) => void; + replaceBelts: (belts: BeltFormData[]) => void; // Chests addChest: (chestType?: ChestType) => void; updateChest: (index: number, updates: Partial) => void; switchChestType: (index: number, newType: ChestType) => void; removeChest: (index: number) => void; + replaceChests: (chests: ChestFormData[]) => void; // Drills enableDrills: () => void; @@ -254,6 +259,8 @@ export interface UseConfigFormResult { addDrill: () => void; updateDrill: (index: number, updates: Partial) => void; removeDrill: (index: number) => void; + mergeDrills: (drills: DrillFormData[]) => void; + replaceDrills: (drills: DrillFormData[]) => void; // Overrides updateOverrides: (field: keyof NonNullable, value: number | boolean | undefined) => void; @@ -325,6 +332,28 @@ export function useConfigForm(): UseConfigFormResult { })); }, []); + const mergeMachines = useCallback((newMachines: MachineFormData[]) => { + setConfig((prev) => { + // Reassign IDs to avoid conflicts + const maxId = Math.max(0, ...prev.machines.map((m) => m.id)); + const machinesWithNewIds = newMachines.map((m, i) => ({ + ...m, + id: maxId + i + 1, + })); + return { + ...prev, + machines: [...prev.machines, ...machinesWithNewIds], + }; + }); + }, []); + + const replaceMachines = useCallback((newMachines: MachineFormData[]) => { + setConfig((prev) => ({ + ...prev, + machines: newMachines, + })); + }, []); + // Inserters const addInserter = useCallback(() => { setConfig((prev) => ({ @@ -356,6 +385,13 @@ export function useConfigForm(): UseConfigFormResult { })); }, []); + const replaceInserters = useCallback((newInserters: InserterFormData[]) => { + setConfig((prev) => ({ + ...prev, + inserters: newInserters, + })); + }, []); + // Belts const addBelt = useCallback(() => { setConfig((prev) => { @@ -390,6 +426,13 @@ export function useConfigForm(): UseConfigFormResult { })); }, []); + const replaceBelts = useCallback((newBelts: BeltFormData[]) => { + setConfig((prev) => ({ + ...prev, + belts: newBelts, + })); + }, []); + // Chests const addChest = useCallback((chestType: ChestType = ChestType.BUFFER_CHEST) => { setConfig((prev) => { @@ -453,6 +496,13 @@ export function useConfigForm(): UseConfigFormResult { })); }, []); + const replaceChests = useCallback((newChests: ChestFormData[]) => { + setConfig((prev) => ({ + ...prev, + chests: newChests, + })); + }, []); + // Drills const enableDrills = useCallback(() => { setConfig((prev) => ({ @@ -536,6 +586,49 @@ export function useConfigForm(): UseConfigFormResult { }); }, []); + const mergeDrills = useCallback((newDrills: DrillFormData[]) => { + setConfig((prev) => { + // If drills aren't enabled, enable them first + const currentDrills = prev.drills ?? { + mining_productivity_level: 0, + configs: [], + }; + + // Reassign IDs to avoid conflicts + const maxId = Math.max(0, ...currentDrills.configs.map((d) => d.id)); + const drillsWithNewIds = newDrills.map((d, i) => ({ + ...d, + id: maxId + i + 1, + })); + + return { + ...prev, + drills: { + ...currentDrills, + configs: [...currentDrills.configs, ...drillsWithNewIds], + }, + }; + }); + }, []); + + const replaceDrills = useCallback((newDrills: DrillFormData[]) => { + setConfig((prev) => { + // If drills aren't enabled, enable them first + const currentDrills = prev.drills ?? { + mining_productivity_level: 0, + configs: [], + }; + + return { + ...prev, + drills: { + ...currentDrills, + configs: newDrills, + }, + }; + }); + }, []); + // Overrides const updateOverrides = useCallback(( field: keyof NonNullable, @@ -682,22 +775,29 @@ export function useConfigForm(): UseConfigFormResult { addMachine, updateMachine, removeMachine, + mergeMachines, + replaceMachines, addInserter, updateInserter, removeInserter, + replaceInserters, addBelt, updateBelt, removeBelt, + replaceBelts, addChest, updateChest, switchChestType, removeChest, + replaceChests, enableDrills, disableDrills, updateDrillsConfig, addDrill, updateDrill, removeDrill, + mergeDrills, + replaceDrills, updateOverrides, importConfig, exportConfig, diff --git a/clock-generator/src/browser.ts b/clock-generator/src/browser.ts index a2e6aaa..1bcfd79 100644 --- a/clock-generator/src/browser.ts +++ b/clock-generator/src/browser.ts @@ -20,6 +20,9 @@ export { MiningDrillConfigSchema, InserterConfigSchema, BeltConfigSchema, + ChestConfigSchema, + BufferChestConfigSchema, + InfinityChestConfigSchema, ConfigOverridesSchema, // Enable Control Enums EnableControlMode,