From 454397bcc4a33c8d1af2e2f43f9adab8cf2360fc Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 16:57:44 -0500 Subject: [PATCH 01/19] initial mod scaffolding and import feature for online tool --- clock-generator-ui/src/App.tsx | 2 + .../src/components/ConfigImportExport.tsx | 75 +++- clock-generator-ui/src/hooks/useConfigForm.ts | 17 + crafting-speed-extractor/build.sh | 77 ++++ crafting-speed-extractor/control.lua | 423 ++++++++++++++++++ .../crafting-speed-extractor_0.1.0.zip | Bin 0 -> 5530 bytes crafting-speed-extractor/data.lua | 50 +++ crafting-speed-extractor/info.json | 13 + crafting-speed-extractor/locale/en/locale.cfg | 23 + 9 files changed, 678 insertions(+), 2 deletions(-) create mode 100755 crafting-speed-extractor/build.sh create mode 100644 crafting-speed-extractor/control.lua create mode 100644 crafting-speed-extractor/crafting-speed-extractor_0.1.0.zip create mode 100644 crafting-speed-extractor/data.lua create mode 100644 crafting-speed-extractor/info.json create mode 100644 crafting-speed-extractor/locale/en/locale.cfg diff --git a/clock-generator-ui/src/App.tsx b/clock-generator-ui/src/App.tsx index 1ee48b6..7ff1285 100644 --- a/clock-generator-ui/src/App.tsx +++ b/clock-generator-ui/src/App.tsx @@ -83,6 +83,7 @@ function App() { addMachine, updateMachine, removeMachine, + mergeMachines, addInserter, updateInserter, removeInserter, @@ -163,6 +164,7 @@ function App() { diff --git a/clock-generator-ui/src/components/ConfigImportExport.tsx b/clock-generator-ui/src/components/ConfigImportExport.tsx index 23a53f9..cf3c8aa 100644 --- a/clock-generator-ui/src/components/ConfigImportExport.tsx +++ b/clock-generator-ui/src/components/ConfigImportExport.tsx @@ -1,17 +1,23 @@ -import { Download, Upload } from '@mui/icons-material'; -import { Box, Button, Snackbar, Alert } from '@mui/material'; +import { Download, Upload, ContentPaste } from '@mui/icons-material'; +import { Box, Button, Snackbar, Alert, Tooltip } from '@mui/material'; import { useRef, useState, useCallback } from 'react'; import type { Config } from 'clock-generator/browser'; +import { MachineConfigurationSchema } from 'clock-generator/browser'; +import type { z } from 'zod'; + +type MachineConfiguration = z.infer; interface ConfigImportExportProps { config: Config; onImport: (config: Config) => void; + onMergeMachines: (machines: MachineConfiguration[]) => void; parseConfig: (content: string) => Promise; } export function ConfigImportExport({ config, onImport, + onMergeMachines, parseConfig, }: ConfigImportExportProps) { const fileInputRef = useRef(null); @@ -86,6 +92,60 @@ export function ConfigImportExport({ setSnackbar((prev) => ({ ...prev, open: false })); }, []); + const handlePasteFromFactorio = useCallback(async () => { + try { + const clipboardText = await navigator.clipboard.readText(); + + // Try to parse as JSON array + 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.'); + } + + // Validate it's an array + if (!Array.isArray(parsed)) { + throw new Error('Expected an array of machines from Factorio mod.'); + } + + if (parsed.length === 0) { + throw new Error('No machines found in clipboard data.'); + } + + // Validate each machine entry + const validatedMachines: MachineConfiguration[] = []; + const existingMaxId = config.machines.reduce((max, m) => Math.max(max, m.id), 0); + + 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'}`); + } + // Reassign ID to avoid conflicts + validatedMachines.push({ + ...result.data, + id: existingMaxId + i + 1 + }); + } + + onMergeMachines(validatedMachines); + setSnackbar({ + open: true, + message: `Successfully imported ${validatedMachines.length} machines from Factorio!`, + severity: 'success', + }); + } 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', + }); + } + }, [config.machines, onMergeMachines]); + return ( <> @@ -105,6 +165,17 @@ export function ConfigImportExport({ > Export Config + + + void; updateMachine: (index: number, field: keyof MachineFormData, value: string | number) => void; removeMachine: (index: number) => void; + mergeMachines: (machines: MachineFormData[]) => void; // Inserters addInserter: () => void; @@ -241,6 +242,21 @@ 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], + }; + }); + }, []); + // Inserters const addInserter = useCallback(() => { setConfig((prev) => ({ @@ -529,6 +545,7 @@ export function useConfigForm(): UseConfigFormResult { addMachine, updateMachine, removeMachine, + mergeMachines, addInserter, updateInserter, removeInserter, diff --git a/crafting-speed-extractor/build.sh b/crafting-speed-extractor/build.sh new file mode 100755 index 0000000..5e097c2 --- /dev/null +++ b/crafting-speed-extractor/build.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Build script for Crafting Speed Extractor 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/" + +# 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 "" +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" + +# 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/crafting-speed-extractor/control.lua b/crafting-speed-extractor/control.lua new file mode 100644 index 0000000..e149724 --- /dev/null +++ b/crafting-speed-extractor/control.lua @@ -0,0 +1,423 @@ +-- Crafting Speed Extractor - Control Stage +-- Runtime logic for selecting machines and extracting crafting data + +-- ============================================================================ +-- Global State Management +-- ============================================================================ + +---@class MachineData +---@field name string Machine entity name +---@field recipe string Recipe name +---@field crafting_speed number Effective crafting speed with bonuses +---@field productivity number Productivity bonus as percentage (0-100+) +---@field type "machine"|"furnace" Entity type category + +---@class PlayerData +---@field machines MachineData[] Extracted machine data +---@field gui LuaGuiElement? Reference to the GUI frame + +---@type table +storage = storage or {} + +local function init_player_data(player_index) + storage[player_index] = storage[player_index] or { + machines = {}, + gui = nil + } +end + +-- ============================================================================ +-- Machine Data Extraction +-- ============================================================================ + +---Extract crafting data from a single entity +---@param entity LuaEntity +---@return MachineData|nil +local function extract_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 + + -- Extract data + ---@type MachineData + local data = { + name = entity.name, + recipe = recipe.name, + crafting_speed = entity.crafting_speed, + productivity = (entity.productivity_bonus or 0) * 100, + type = entity_type + } + + return data +end + +---Extract data from all selected entities +---@param entities LuaEntity[] +---@return MachineData[] +local function extract_all_machines(entities) + local machines = {} + + for _, entity in pairs(entities) do + local data = extract_machine_data(entity) + if data then + table.insert(machines, data) + end + end + + return machines +end + +-- ============================================================================ +-- JSON Export +-- ============================================================================ + +---Convert machine data to clock-generator compatible JSON +---@param machines MachineData[] +---@return string +local function machines_to_json(machines) + local export = {} + + for i, machine in ipairs(machines) do + table.insert(export, { + id = i, + recipe = machine.recipe, + crafting_speed = machine.crafting_speed, + productivity = machine.productivity, + type = machine.type + }) + end + + return helpers.table_to_json(export) +end + +-- ============================================================================ +-- GUI Management +-- ============================================================================ + +local GUI_NAME = "crafting_speed_extractor_frame" +local COPY_GUI_NAME = "crafting_speed_extractor_copy_frame" + +---Destroy the main results GUI for a player (not the copy popup) +---@param player LuaPlayer +local function destroy_gui(player) + local frame = player.gui.screen[GUI_NAME] + if frame and frame.valid then + frame.destroy() + end + if storage[player.index] then + storage[player.index].gui = nil + end +end + +---Destroy all GUIs for a player (including copy popup) +---@param player LuaPlayer +local function destroy_all_gui(player) + destroy_gui(player) + local copy_frame = player.gui.screen[COPY_GUI_NAME] + if copy_frame and copy_frame.valid then + copy_frame.destroy() + end +end + +---Create the copy/paste popup with selectable JSON text +---@param player LuaPlayer +---@param json string +local function create_copy_popup(player, json) + -- Remove any existing copy popup + local existing = player.gui.screen[COPY_GUI_NAME] + if existing and existing.valid then + existing.destroy() + end + + -- Create popup frame + local frame = player.gui.screen.add({ + type = "frame", + name = COPY_GUI_NAME, + direction = "vertical", + caption = { "crafting-speed-extractor.copy-popup-title" } + }) + frame.auto_center = true + + -- Instructions + frame.add({ + type = "label", + caption = { "crafting-speed-extractor.copy-instructions" } + }) + + -- Text box with JSON (user can select all and copy) + local textbox = frame.add({ + type = "text-box", + name = "crafting_speed_extractor_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 = "crafting_speed_extractor_copy_close", + caption = { "crafting-speed-extractor.close-button" } + }) + + -- Don't set player.opened here - it would close the main GUI and trigger destroy_gui + -- The popup will stay open alongside the main GUI +end + +---Create the extraction results GUI +---@param player LuaPlayer +---@param machines MachineData[] +local function create_gui(player, machines) + -- Remove existing GUI + destroy_gui(player) + + -- Main frame + local frame = player.gui.screen.add({ + type = "frame", + name = GUI_NAME, + direction = "vertical", + caption = { "crafting-speed-extractor.gui-title" } + }) + frame.auto_center = true + + -- Store reference + storage[player.index].gui = frame + + -- Header flow with machine count + local header = frame.add({ + type = "flow", + direction = "horizontal" + }) + header.style.horizontal_spacing = 8 + header.style.bottom_margin = 8 + + header.add({ + type = "label", + caption = { "crafting-speed-extractor.machine-count", #machines }, + 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 = 500 + content_frame.style.maximal_height = 400 + + if #machines == 0 then + content_frame.add({ + type = "label", + caption = { "crafting-speed-extractor.no-machines" }, + style = "bold_label" + }) + else + -- Scroll pane for machine list + local scroll = content_frame.add({ + type = "scroll-pane", + direction = "vertical", + vertical_scroll_policy = "auto", + horizontal_scroll_policy = "never" + }) + scroll.style.maximal_height = 350 + + -- Table header + local header_table = scroll.add({ + type = "table", + column_count = 5, + draw_horizontal_lines = true, + draw_vertical_lines = false + }) + header_table.style.horizontal_spacing = 16 + header_table.style.column_alignments[1] = "center" + header_table.style.column_alignments[4] = "right" + header_table.style.column_alignments[5] = "right" + + -- Header labels + local headers = { "#", { "crafting-speed-extractor.col-machine" }, { "crafting-speed-extractor.col-recipe" }, { "crafting-speed-extractor.col-speed" }, { "crafting-speed-extractor.col-productivity" } } + for _, h in ipairs(headers) do + header_table.add({ + type = "label", + caption = h, + style = "bold_label" + }) + end + + -- Machine rows + for i, machine in ipairs(machines) do + header_table.add({ + type = "label", + caption = tostring(i) + }) + header_table.add({ + type = "label", + caption = machine.name + }) + header_table.add({ + type = "label", + caption = machine.recipe + }) + header_table.add({ + type = "label", + caption = string.format("%.2f", machine.crafting_speed) + }) + header_table.add({ + type = "label", + caption = string.format("%.1f%%", machine.productivity) + }) + 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 machines) + if #machines > 0 then + button_flow.add({ + type = "button", + name = "crafting_speed_extractor_copy", + caption = { "crafting-speed-extractor.copy-button" }, + style = "confirm_button" + }) + end + + -- Close button + button_flow.add({ + type = "button", + name = "crafting_speed_extractor_close", + caption = { "crafting-speed-extractor.close-button" } + }) + + player.opened = frame +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 ~= "crafting-speed-analyzer" then + return + end + + local player = game.get_player(event.player_index) + if not player then + return + end + + init_player_data(event.player_index) + + -- Extract machine data + local machines = extract_all_machines(event.entities) + storage[event.player_index].machines = machines + + -- Show GUI + create_gui(player, machines) + + if #machines == 0 then + player.print({ "crafting-speed-extractor.no-machines-selected" }) + else + player.print({ "crafting-speed-extractor.machines-found", #machines }) + 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 + + if element.name == "crafting_speed_extractor_copy" then + -- Show copy popup with JSON text + local player_data = storage[event.player_index] + if player_data and player_data.machines and #player_data.machines > 0 then + local json = machines_to_json(player_data.machines) + create_copy_popup(player, json) + else + player.print("[Crafting Speed Extractor] No machine data found. Please select machines first.") + end + elseif element.name == "crafting_speed_extractor_close" then + destroy_all_gui(player) + elseif element.name == "crafting_speed_extractor_copy_close" then + -- Close just the copy popup + local copy_frame = player.gui.screen[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_NAME then + local player = game.get_player(event.player_index) + if player then + destroy_gui(player) + 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/crafting-speed-extractor/crafting-speed-extractor_0.1.0.zip b/crafting-speed-extractor/crafting-speed-extractor_0.1.0.zip new file mode 100644 index 0000000000000000000000000000000000000000..49fbc2d2494b9a3205e8a4b18187e4d772fa8883 GIT binary patch literal 5530 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 2.0" + ] +} diff --git a/crafting-speed-extractor/locale/en/locale.cfg b/crafting-speed-extractor/locale/en/locale.cfg new file mode 100644 index 0000000..ad147ce --- /dev/null +++ b/crafting-speed-extractor/locale/en/locale.cfg @@ -0,0 +1,23 @@ +[item-name] +crafting-speed-analyzer=Crafting Speed Analyzer + +[item-description] +crafting-speed-analyzer=Select machines to extract crafting speeds and productivity bonuses. Use the shortcut (ALT+E) or toolbar button for quick access. + +[shortcut-name] +crafting-speed-analyzer-shortcut=Crafting Speed Analyzer + +[crafting-speed-extractor] +gui-title=Crafting Speed Extractor +machine-count=__1__ machines 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 From 77322bf2fcda4b03ebb20a2e5ab0797b263cbefe Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 17:09:12 -0500 Subject: [PATCH 02/19] include recipe force productivity from research --- crafting-speed-extractor/control.lua | 22 +++++++++++++++++- .../crafting-speed-extractor_0.1.0.zip | Bin 5530 -> 0 bytes 2 files changed, 21 insertions(+), 1 deletion(-) delete mode 100644 crafting-speed-extractor/crafting-speed-extractor_0.1.0.zip diff --git a/crafting-speed-extractor/control.lua b/crafting-speed-extractor/control.lua index e149724..368e04c 100644 --- a/crafting-speed-extractor/control.lua +++ b/crafting-speed-extractor/control.lua @@ -50,13 +50,33 @@ local function extract_machine_data(entity) entity_type = "furnace" end + -- Debug: Log all productivity-related values + 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 = (entity.productivity_bonus or 0) * 100, + productivity = total_productivity * 100, type = entity_type } diff --git a/crafting-speed-extractor/crafting-speed-extractor_0.1.0.zip b/crafting-speed-extractor/crafting-speed-extractor_0.1.0.zip deleted file mode 100644 index 49fbc2d2494b9a3205e8a4b18187e4d772fa8883..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5530 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 Date: Fri, 2 Jan 2026 17:09:30 -0500 Subject: [PATCH 03/19] ignore zip files --- crafting-speed-extractor/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 crafting-speed-extractor/.gitignore diff --git a/crafting-speed-extractor/.gitignore b/crafting-speed-extractor/.gitignore new file mode 100644 index 0000000..1125f9d --- /dev/null +++ b/crafting-speed-extractor/.gitignore @@ -0,0 +1 @@ +.zip \ No newline at end of file From 919eef7ac4605045d5477c75400ab4d2724092cb Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 17:44:21 -0500 Subject: [PATCH 04/19] properly handle extraction of mining drill data --- clock-generator-ui/src/App.tsx | 6 +- .../src/components/ConfigImportExport.tsx | 86 ++++- clock-generator-ui/src/hooks/useConfigForm.ts | 56 +++ crafting-speed-extractor/.gitignore | 2 +- crafting-speed-extractor/control.lua | 324 +++++++++++++----- 5 files changed, 377 insertions(+), 97 deletions(-) diff --git a/clock-generator-ui/src/App.tsx b/clock-generator-ui/src/App.tsx index 7ff1285..f4b3cc0 100644 --- a/clock-generator-ui/src/App.tsx +++ b/clock-generator-ui/src/App.tsx @@ -83,7 +83,7 @@ function App() { addMachine, updateMachine, removeMachine, - mergeMachines, + replaceMachines, addInserter, updateInserter, removeInserter, @@ -99,6 +99,7 @@ function App() { addDrill, updateDrill, removeDrill, + replaceDrills, updateOverrides, importConfig, exportConfig, @@ -164,7 +165,8 @@ function App() { diff --git a/clock-generator-ui/src/components/ConfigImportExport.tsx b/clock-generator-ui/src/components/ConfigImportExport.tsx index cf3c8aa..c35283c 100644 --- a/clock-generator-ui/src/components/ConfigImportExport.tsx +++ b/clock-generator-ui/src/components/ConfigImportExport.tsx @@ -2,22 +2,25 @@ import { Download, Upload, ContentPaste } from '@mui/icons-material'; import { Box, Button, Snackbar, Alert, Tooltip } from '@mui/material'; import { useRef, useState, useCallback } from 'react'; import type { Config } from 'clock-generator/browser'; -import { MachineConfigurationSchema } from 'clock-generator/browser'; +import { MachineConfigurationSchema, MiningDrillConfigSchema } from 'clock-generator/browser'; import type { z } from 'zod'; type MachineConfiguration = z.infer; +type MiningDrillConfiguration = z.infer; interface ConfigImportExportProps { config: Config; onImport: (config: Config) => void; - onMergeMachines: (machines: MachineConfiguration[]) => void; + onReplaceMachines: (machines: MachineConfiguration[]) => void; + onReplaceDrills: (drills: MiningDrillConfiguration[]) => void; parseConfig: (content: string) => Promise; } export function ConfigImportExport({ config, onImport, - onMergeMachines, + onReplaceMachines, + onReplaceDrills, parseConfig, }: ConfigImportExportProps) { const fileInputRef = useRef(null); @@ -96,7 +99,7 @@ export function ConfigImportExport({ try { const clipboardText = await navigator.clipboard.readText(); - // Try to parse as JSON array + // Try to parse as JSON let parsed: unknown; try { parsed = JSON.parse(clipboardText); @@ -104,18 +107,73 @@ export function ConfigImportExport({ throw new Error('Clipboard does not contain valid JSON. Make sure to copy from the Factorio mod.'); } - // Validate it's an array + // 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?: unknown[] }; + + const machineCount = Array.isArray(data.machines) ? data.machines.length : 0; + const drillCount = Array.isArray(data.drills) ? data.drills.length : 0; + + if (machineCount === 0 && drillCount === 0) { + throw new Error('No machines or drills 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(data.drills)) { + for (let i = 0; i < data.drills.length; i++) { + const entry = data.drills[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); + } + } + + if (validatedMachines.length > 0) { + onReplaceMachines(validatedMachines); + } + if (validatedDrills.length > 0) { + onReplaceDrills(validatedDrills); + } + + const parts: string[] = []; + if (validatedMachines.length > 0) parts.push(`${validatedMachines.length} machines`); + if (validatedDrills.length > 0) parts.push(`${validatedDrills.length} drills`); + + setSnackbar({ + open: true, + message: `Replaced with ${parts.join(' and ')} from Factorio!`, + severity: 'success', + }); + return; + } + + // Legacy format: array of machines if (!Array.isArray(parsed)) { - throw new Error('Expected an array of machines from Factorio mod.'); + 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 + // Validate each machine entry (IDs are already assigned by the mod) const validatedMachines: MachineConfiguration[] = []; - const existingMaxId = config.machines.reduce((max, m) => Math.max(max, m.id), 0); for (let i = 0; i < parsed.length; i++) { const entry = parsed[i]; @@ -123,17 +181,13 @@ export function ConfigImportExport({ if (!result.success) { throw new Error(`Invalid machine at index ${i}: ${result.error.issues[0]?.message || 'Unknown error'}`); } - // Reassign ID to avoid conflicts - validatedMachines.push({ - ...result.data, - id: existingMaxId + i + 1 - }); + validatedMachines.push(result.data); } - onMergeMachines(validatedMachines); + onReplaceMachines(validatedMachines); setSnackbar({ open: true, - message: `Successfully imported ${validatedMachines.length} machines from Factorio!`, + message: `Replaced with ${validatedMachines.length} machines from Factorio!`, severity: 'success', }); } catch (error) { @@ -144,7 +198,7 @@ export function ConfigImportExport({ severity: 'error', }); } - }, [config.machines, onMergeMachines]); + }, [onReplaceMachines, onReplaceDrills]); return ( <> diff --git a/clock-generator-ui/src/hooks/useConfigForm.ts b/clock-generator-ui/src/hooks/useConfigForm.ts index 830cde5..d75cca9 100644 --- a/clock-generator-ui/src/hooks/useConfigForm.ts +++ b/clock-generator-ui/src/hooks/useConfigForm.ts @@ -148,6 +148,7 @@ export interface UseConfigFormResult { 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; @@ -171,6 +172,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 | undefined) => void; @@ -257,6 +260,13 @@ export function useConfigForm(): UseConfigFormResult { }); }, []); + const replaceMachines = useCallback((newMachines: MachineFormData[]) => { + setConfig((prev) => ({ + ...prev, + machines: newMachines, + })); + }, []); + // Inserters const addInserter = useCallback(() => { setConfig((prev) => ({ @@ -438,6 +448,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, @@ -546,6 +599,7 @@ export function useConfigForm(): UseConfigFormResult { updateMachine, removeMachine, mergeMachines, + replaceMachines, addInserter, updateInserter, removeInserter, @@ -561,6 +615,8 @@ export function useConfigForm(): UseConfigFormResult { addDrill, updateDrill, removeDrill, + mergeDrills, + replaceDrills, updateOverrides, importConfig, exportConfig, diff --git a/crafting-speed-extractor/.gitignore b/crafting-speed-extractor/.gitignore index 1125f9d..6f66c74 100644 --- a/crafting-speed-extractor/.gitignore +++ b/crafting-speed-extractor/.gitignore @@ -1 +1 @@ -.zip \ No newline at end of file +*.zip \ No newline at end of file diff --git a/crafting-speed-extractor/control.lua b/crafting-speed-extractor/control.lua index 368e04c..a04b91e 100644 --- a/crafting-speed-extractor/control.lua +++ b/crafting-speed-extractor/control.lua @@ -7,10 +7,17 @@ ---@class MachineData ---@field name string Machine entity name ----@field recipe string Recipe name ----@field crafting_speed number Effective crafting speed with bonuses +---@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" Entity type category +---@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 PlayerData ---@field machines MachineData[] Extracted machine data @@ -30,10 +37,48 @@ end -- Machine Data Extraction -- ============================================================================ ----Extract crafting data from a single entity +---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, -- e.g., "electric-mining-drill" + mined_item_name = mining_target.name, -- Resource being mined + speed_bonus = speed_bonus, -- Speed bonus from modules/beacons + productivity = total_productivity * 100, -- For display purposes + drop_target_unit_number = drop_target_unit_number, -- For cross-referencing + } + + return data +end + +---Extract crafting data from a single crafting machine entity ---@param entity LuaEntity ---@return MachineData|nil -local function extract_machine_data(entity) +local function extract_crafting_machine_data(entity) if not entity or not entity.valid then return nil end @@ -50,7 +95,7 @@ local function extract_machine_data(entity) entity_type = "furnace" end - -- Debug: Log all productivity-related values + -- Get entity productivity from modules/beacons local entity_prod_bonus = entity.productivity_bonus or 0 -- Get research productivity from the force's recipe @@ -83,34 +128,83 @@ local function extract_machine_data(entity) return data end ----Extract data from all selected entities +---Extract crafting data from a single entity (dispatches to appropriate handler) +---@param entity LuaEntity +---@return MachineData|nil +local function extract_machine_data(entity) + if not entity or not entity.valid then + return nil + end + + -- Handle mining drills separately + if entity.type == "mining-drill" then + return extract_mining_drill_data(entity), "drill" + end + + -- Handle crafting machines (assemblers, furnaces, etc.) + return extract_crafting_machine_data(entity), "machine" +end + +---@class ExtractionResult +---@field machines MachineData[] +---@field drills DrillData[] +---@field unit_number_to_id table Maps entity unit_number to machine ID + +---Extract data from all selected entities, separating machines and drills ---@param entities LuaEntity[] ----@return MachineData[] -local function extract_all_machines(entities) - local machines = {} +---@return ExtractionResult +local function extract_all_entities(entities) + local result = { + machines = {}, + drills = {}, + unit_number_to_id = {} + } + + -- 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" 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 drills (now we have the unit_number mapping) for _, entity in pairs(entities) do - local data = extract_machine_data(entity) - if data then - table.insert(machines, data) + if entity.type == "mining-drill" then + local data = extract_mining_drill_data(entity) + if data then + table.insert(result.drills, data) + end end end - return machines + return result end -- ============================================================================ -- JSON Export -- ============================================================================ ----Convert machine data to clock-generator compatible JSON ----@param machines MachineData[] +---Convert extraction result to clock-generator compatible JSON +---@param result ExtractionResult ---@return string -local function machines_to_json(machines) - local export = {} +local function to_export_json(result) + local export = { + machines = {}, + drills = {} + } - for i, machine in ipairs(machines) do - table.insert(export, { + -- Format machines for clock-generator + for i, machine in ipairs(result.machines) do + table.insert(export.machines, { id = i, recipe = machine.recipe, crafting_speed = machine.crafting_speed, @@ -118,6 +212,29 @@ local function machines_to_json(machines) type = machine.type }) 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(export.drills, { + 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 return helpers.table_to_json(export) end @@ -213,10 +330,15 @@ end ---Create the extraction results GUI ---@param player LuaPlayer ---@param machines MachineData[] -local function create_gui(player, machines) +---Create the GUI to display extracted data +---@param player LuaPlayer +---@param result ExtractionResult +local function create_gui(player, result) -- Remove existing GUI destroy_gui(player) + local total_count = #result.machines + #result.drills + -- Main frame local frame = player.gui.screen.add({ type = "frame", @@ -229,7 +351,7 @@ local function create_gui(player, machines) -- Store reference storage[player.index].gui = frame - -- Header flow with machine count + -- Header flow with count local header = frame.add({ type = "flow", direction = "horizontal" @@ -239,7 +361,7 @@ local function create_gui(player, machines) header.add({ type = "label", - caption = { "crafting-speed-extractor.machine-count", #machines }, + caption = { "crafting-speed-extractor.machine-count", total_count }, style = "heading_2_label" }) @@ -249,69 +371,109 @@ local function create_gui(player, machines) direction = "vertical", style = "inside_shallow_frame_with_padding" }) - content_frame.style.minimal_width = 500 - content_frame.style.maximal_height = 400 + content_frame.style.minimal_width = 550 + content_frame.style.maximal_height = 450 - if #machines == 0 then + if total_count == 0 then content_frame.add({ type = "label", caption = { "crafting-speed-extractor.no-machines" }, style = "bold_label" }) else - -- Scroll pane for machine list + -- 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 = 350 - - -- Table header - local header_table = scroll.add({ - type = "table", - column_count = 5, - draw_horizontal_lines = true, - draw_vertical_lines = false - }) - header_table.style.horizontal_spacing = 16 - header_table.style.column_alignments[1] = "center" - header_table.style.column_alignments[4] = "right" - header_table.style.column_alignments[5] = "right" - - -- Header labels - local headers = { "#", { "crafting-speed-extractor.col-machine" }, { "crafting-speed-extractor.col-recipe" }, { "crafting-speed-extractor.col-speed" }, { "crafting-speed-extractor.col-productivity" } } - for _, h in ipairs(headers) do - header_table.add({ - type = "label", - caption = h, - style = "bold_label" - }) - end + scroll.style.maximal_height = 400 - -- Machine rows - for i, machine in ipairs(machines) do - header_table.add({ + -- Display machines if any + if #result.machines > 0 then + local machines_header = scroll.add({ type = "label", - caption = tostring(i) + caption = "Machines & Furnaces (" .. #result.machines .. ")", + style = "heading_2_label" }) - header_table.add({ - type = "label", - caption = machine.name - }) - header_table.add({ - type = "label", - caption = machine.recipe + machines_header.style.bottom_margin = 4 + + local machine_table = scroll.add({ + type = "table", + column_count = 5, + draw_horizontal_lines = true, + draw_vertical_lines = false }) - header_table.add({ + machine_table.style.horizontal_spacing = 16 + machine_table.style.column_alignments[1] = "center" + machine_table.style.column_alignments[4] = "right" + machine_table.style.column_alignments[5] = "right" + + -- Header labels for machines + local m_headers = { "#", { "crafting-speed-extractor.col-machine" }, { "crafting-speed-extractor.col-recipe" }, { "crafting-speed-extractor.col-speed" }, { "crafting-speed-extractor.col-productivity" } } + for _, h in ipairs(m_headers) do + machine_table.add({ + type = "label", + caption = h, + style = "bold_label" + }) + end + + -- 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 + + -- Add spacing if drills follow + if #result.drills > 0 then + local spacer = scroll.add({ type = "flow" }) + spacer.style.height = 16 + end + end + + -- Display drills if any + if #result.drills > 0 then + local drills_header = scroll.add({ type = "label", - caption = string.format("%.2f", machine.crafting_speed) + caption = "Mining Drills (" .. #result.drills .. ")", + style = "heading_2_label" }) - header_table.add({ - type = "label", - caption = string.format("%.1f%%", machine.productivity) + drills_header.style.bottom_margin = 4 + + local drill_table = scroll.add({ + type = "table", + column_count = 5, + draw_horizontal_lines = true, + draw_vertical_lines = false }) + drill_table.style.horizontal_spacing = 16 + drill_table.style.column_alignments[1] = "center" + drill_table.style.column_alignments[4] = "right" + drill_table.style.column_alignments[5] = "right" + + -- Header labels for drills + local d_headers = { "#", "Drill Type", "Resource", "Speed Bonus", { "crafting-speed-extractor.col-productivity" } } + for _, h in ipairs(d_headers) do + drill_table.add({ + type = "label", + caption = h, + style = "bold_label" + }) + end + + -- 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 end end @@ -325,8 +487,8 @@ local function create_gui(player, machines) button_flow.style.horizontally_stretchable = true button_flow.style.horizontal_align = "right" - -- Copy button (only if we have machines) - if #machines > 0 then + -- Copy button (only if we have data) + if total_count > 0 then button_flow.add({ type = "button", name = "crafting_speed_extractor_copy", @@ -363,17 +525,18 @@ local function on_player_selected_area(event) init_player_data(event.player_index) - -- Extract machine data - local machines = extract_all_machines(event.entities) - storage[event.player_index].machines = machines + -- Extract entity data (machines and drills separately) + local result = extract_all_entities(event.entities) + storage[event.player_index].extraction_result = result -- Show GUI - create_gui(player, machines) + create_gui(player, result) - if #machines == 0 then + local total = #result.machines + #result.drills + if total == 0 then player.print({ "crafting-speed-extractor.no-machines-selected" }) else - player.print({ "crafting-speed-extractor.machines-found", #machines }) + player.print({ "crafting-speed-extractor.machines-found", total }) end end @@ -393,11 +556,16 @@ local function on_gui_click(event) if element.name == "crafting_speed_extractor_copy" then -- Show copy popup with JSON text local player_data = storage[event.player_index] - if player_data and player_data.machines and #player_data.machines > 0 then - local json = machines_to_json(player_data.machines) - create_copy_popup(player, json) + if player_data and player_data.extraction_result then + local result = player_data.extraction_result + if #result.machines > 0 or #result.drills > 0 then + local json = to_export_json(result) + create_copy_popup(player, json) + else + player.print("[Crafting Speed Extractor] No data found. Please select machines or drills first.") + end else - player.print("[Crafting Speed Extractor] No machine data found. Please select machines first.") + player.print("[Crafting Speed Extractor] No data found. Please select machines or drills first.") end elseif element.name == "crafting_speed_extractor_close" then destroy_all_gui(player) From 4f7e6b1982d8656f92696837e1e6caa12a976a07 Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 17:47:31 -0500 Subject: [PATCH 05/19] rename to clock generator sidecar --- .../.gitignore | 0 .../build.sh | 2 +- .../control.lua | 48 +++++++++---------- .../data.lua | 2 +- .../info.json | 6 +-- .../locale/en/locale.cfg | 4 +- 6 files changed, 31 insertions(+), 31 deletions(-) rename {crafting-speed-extractor => clock-generator-sidecar}/.gitignore (100%) rename {crafting-speed-extractor => clock-generator-sidecar}/build.sh (97%) rename {crafting-speed-extractor => clock-generator-sidecar}/control.lua (91%) rename {crafting-speed-extractor => clock-generator-sidecar}/data.lua (97%) rename {crafting-speed-extractor => clock-generator-sidecar}/info.json (58%) rename {crafting-speed-extractor => clock-generator-sidecar}/locale/en/locale.cfg (93%) diff --git a/crafting-speed-extractor/.gitignore b/clock-generator-sidecar/.gitignore similarity index 100% rename from crafting-speed-extractor/.gitignore rename to clock-generator-sidecar/.gitignore diff --git a/crafting-speed-extractor/build.sh b/clock-generator-sidecar/build.sh similarity index 97% rename from crafting-speed-extractor/build.sh rename to clock-generator-sidecar/build.sh index 5e097c2..0358353 100755 --- a/crafting-speed-extractor/build.sh +++ b/clock-generator-sidecar/build.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Build script for Crafting Speed Extractor Factorio mod +# Build script for Clock Generator Sidecar Factorio mod # Creates a zip file ready for installation in Factorio's mods folder set -e diff --git a/crafting-speed-extractor/control.lua b/clock-generator-sidecar/control.lua similarity index 91% rename from crafting-speed-extractor/control.lua rename to clock-generator-sidecar/control.lua index a04b91e..faa1471 100644 --- a/crafting-speed-extractor/control.lua +++ b/clock-generator-sidecar/control.lua @@ -1,4 +1,4 @@ --- Crafting Speed Extractor - Control Stage +-- Clock Generator Sidecar - Control Stage -- Runtime logic for selecting machines and extracting crafting data -- ============================================================================ @@ -243,8 +243,8 @@ end -- GUI Management -- ============================================================================ -local GUI_NAME = "crafting_speed_extractor_frame" -local COPY_GUI_NAME = "crafting_speed_extractor_copy_frame" +local GUI_NAME = "clock_generator_sidecar_frame" +local COPY_GUI_NAME = "clock_generator_sidecar_copy_frame" ---Destroy the main results GUI for a player (not the copy popup) ---@param player LuaPlayer @@ -283,20 +283,20 @@ local function create_copy_popup(player, json) type = "frame", name = COPY_GUI_NAME, direction = "vertical", - caption = { "crafting-speed-extractor.copy-popup-title" } + caption = { "clock-generator-sidecar.copy-popup-title" } }) frame.auto_center = true -- Instructions frame.add({ type = "label", - caption = { "crafting-speed-extractor.copy-instructions" } + caption = { "clock-generator-sidecar.copy-instructions" } }) -- Text box with JSON (user can select all and copy) local textbox = frame.add({ type = "text-box", - name = "crafting_speed_extractor_json_text", + name = "clock_generator_sidecar_json_text", text = json }) textbox.style.width = 600 @@ -319,8 +319,8 @@ local function create_copy_popup(player, json) button_flow.add({ type = "button", - name = "crafting_speed_extractor_copy_close", - caption = { "crafting-speed-extractor.close-button" } + name = "clock_generator_sidecar_copy_close", + caption = { "clock-generator-sidecar.close-button" } }) -- Don't set player.opened here - it would close the main GUI and trigger destroy_gui @@ -344,7 +344,7 @@ local function create_gui(player, result) type = "frame", name = GUI_NAME, direction = "vertical", - caption = { "crafting-speed-extractor.gui-title" } + caption = { "clock-generator-sidecar.gui-title" } }) frame.auto_center = true @@ -361,7 +361,7 @@ local function create_gui(player, result) header.add({ type = "label", - caption = { "crafting-speed-extractor.machine-count", total_count }, + caption = { "clock-generator-sidecar.machine-count", total_count }, style = "heading_2_label" }) @@ -377,7 +377,7 @@ local function create_gui(player, result) if total_count == 0 then content_frame.add({ type = "label", - caption = { "crafting-speed-extractor.no-machines" }, + caption = { "clock-generator-sidecar.no-machines" }, style = "bold_label" }) else @@ -411,7 +411,7 @@ local function create_gui(player, result) machine_table.style.column_alignments[5] = "right" -- Header labels for machines - local m_headers = { "#", { "crafting-speed-extractor.col-machine" }, { "crafting-speed-extractor.col-recipe" }, { "crafting-speed-extractor.col-speed" }, { "crafting-speed-extractor.col-productivity" } } + local m_headers = { "#", { "clock-generator-sidecar.col-machine" }, { "clock-generator-sidecar.col-recipe" }, { "clock-generator-sidecar.col-speed" }, { "clock-generator-sidecar.col-productivity" } } for _, h in ipairs(m_headers) do machine_table.add({ type = "label", @@ -457,7 +457,7 @@ local function create_gui(player, result) drill_table.style.column_alignments[5] = "right" -- Header labels for drills - local d_headers = { "#", "Drill Type", "Resource", "Speed Bonus", { "crafting-speed-extractor.col-productivity" } } + local d_headers = { "#", "Drill Type", "Resource", "Speed Bonus", { "clock-generator-sidecar.col-productivity" } } for _, h in ipairs(d_headers) do drill_table.add({ type = "label", @@ -491,8 +491,8 @@ local function create_gui(player, result) if total_count > 0 then button_flow.add({ type = "button", - name = "crafting_speed_extractor_copy", - caption = { "crafting-speed-extractor.copy-button" }, + name = "clock_generator_sidecar_copy", + caption = { "clock-generator-sidecar.copy-button" }, style = "confirm_button" }) end @@ -500,8 +500,8 @@ local function create_gui(player, result) -- Close button button_flow.add({ type = "button", - name = "crafting_speed_extractor_close", - caption = { "crafting-speed-extractor.close-button" } + name = "clock_generator_sidecar_close", + caption = { "clock-generator-sidecar.close-button" } }) player.opened = frame @@ -534,9 +534,9 @@ local function on_player_selected_area(event) local total = #result.machines + #result.drills if total == 0 then - player.print({ "crafting-speed-extractor.no-machines-selected" }) + player.print({ "clock-generator-sidecar.no-machines-selected" }) else - player.print({ "crafting-speed-extractor.machines-found", total }) + player.print({ "clock-generator-sidecar.machines-found", total }) end end @@ -553,7 +553,7 @@ local function on_gui_click(event) return end - if element.name == "crafting_speed_extractor_copy" then + if element.name == "clock_generator_sidecar_copy" then -- Show copy popup with JSON text local player_data = storage[event.player_index] if player_data and player_data.extraction_result then @@ -562,14 +562,14 @@ local function on_gui_click(event) local json = to_export_json(result) create_copy_popup(player, json) else - player.print("[Crafting Speed Extractor] No data found. Please select machines or drills first.") + player.print("[Clock Generator Sidecar] No data found. Please select machines or drills first.") end else - player.print("[Crafting Speed Extractor] No data found. Please select machines or drills first.") + player.print("[Clock Generator Sidecar] No data found. Please select machines or drills first.") end - elseif element.name == "crafting_speed_extractor_close" then + elseif element.name == "clock_generator_sidecar_close" then destroy_all_gui(player) - elseif element.name == "crafting_speed_extractor_copy_close" then + elseif element.name == "clock_generator_sidecar_copy_close" then -- Close just the copy popup local copy_frame = player.gui.screen[COPY_GUI_NAME] if copy_frame and copy_frame.valid then diff --git a/crafting-speed-extractor/data.lua b/clock-generator-sidecar/data.lua similarity index 97% rename from crafting-speed-extractor/data.lua rename to clock-generator-sidecar/data.lua index 3f33486..774396b 100644 --- a/crafting-speed-extractor/data.lua +++ b/clock-generator-sidecar/data.lua @@ -1,4 +1,4 @@ --- Crafting Speed Extractor - Data Stage +-- Clock Generator Sidecar - Data Stage -- Defines the selection tool prototype for selecting machines data:extend({ diff --git a/crafting-speed-extractor/info.json b/clock-generator-sidecar/info.json similarity index 58% rename from crafting-speed-extractor/info.json rename to clock-generator-sidecar/info.json index 307ebc1..450f4a6 100644 --- a/crafting-speed-extractor/info.json +++ b/clock-generator-sidecar/info.json @@ -1,11 +1,11 @@ { - "name": "crafting-speed-extractor", + "name": "clock-generator-sidecar", "version": "0.1.0", - "title": "Crafting Speed Extractor", + "title": "Clock Generator Sidecar", "author": "abucnasty", "contact": "", "homepage": "", - "description": "Select machines to extract crafting speeds and productivity bonuses. Export data to clipboard for use with the Clock Generator tool.", + "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/crafting-speed-extractor/locale/en/locale.cfg b/clock-generator-sidecar/locale/en/locale.cfg similarity index 93% rename from crafting-speed-extractor/locale/en/locale.cfg rename to clock-generator-sidecar/locale/en/locale.cfg index ad147ce..d00f1af 100644 --- a/crafting-speed-extractor/locale/en/locale.cfg +++ b/clock-generator-sidecar/locale/en/locale.cfg @@ -7,8 +7,8 @@ crafting-speed-analyzer=Select machines to extract crafting speeds and productiv [shortcut-name] crafting-speed-analyzer-shortcut=Crafting Speed Analyzer -[crafting-speed-extractor] -gui-title=Crafting Speed Extractor +[clock-generator-sidecar] +gui-title=Clock Generator Sidecar machine-count=__1__ machines 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. From 0a7a475490d4991504cb0fa8ab726db5d3a6483f Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 18:53:06 -0500 Subject: [PATCH 06/19] add support for extracting inserter information --- clock-generator-sidecar/control.lua | 240 +++++++++++++++++- clock-generator-sidecar/data.lua | 4 +- clock-generator-ui/src/App.tsx | 2 + .../src/components/ConfigImportExport.tsx | 31 ++- clock-generator-ui/src/hooks/useConfigForm.ts | 9 + 5 files changed, 270 insertions(+), 16 deletions(-) diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua index faa1471..68d9b7f 100644 --- a/clock-generator-sidecar/control.lua +++ b/clock-generator-sidecar/control.lua @@ -75,6 +75,103 @@ local function extract_mining_drill_data(entity) return data end +---Determine the target type for an entity +---@param entity LuaEntity +---@return "machine"|"belt"|"chest"|nil +local function 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 + +---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 pickup_target = entity.pickup_target + if pickup_target and pickup_target.valid then + local target_type = get_target_type(pickup_target) + if target_type then + source = { + type = target_type, + unit_number = pickup_target.unit_number + } + 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 = 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 + } + + return data +end + ---Extract crafting data from a single crafting machine entity ---@param entity LuaEntity ---@return MachineData|nil @@ -148,22 +245,24 @@ end ---@class ExtractionResult ---@field machines MachineData[] ---@field drills DrillData[] +---@field inserters InserterData[] ---@field unit_number_to_id table Maps entity unit_number to machine ID ----Extract data from all selected entities, separating machines and drills +---Extract data from all selected entities, separating machines, drills, and inserters ---@param entities LuaEntity[] ---@return ExtractionResult local function extract_all_entities(entities) local result = { machines = {}, drills = {}, + inserters = {}, unit_number_to_id = {} } -- 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" then + if entity.type ~= "mining-drill" and entity.type ~= "inserter" then local data, entity_category = extract_machine_data(entity) if data and entity_category == "machine" then machine_id = machine_id + 1 @@ -185,6 +284,16 @@ local function extract_all_entities(entities) end end end + + -- Third 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 return result end @@ -199,7 +308,8 @@ end local function to_export_json(result) local export = { machines = {}, - drills = {} + drills = {}, + inserters = {} } -- Format machines for clock-generator @@ -235,6 +345,57 @@ local function to_export_json(result) } }) end + + -- Format inserters for clock-generator + for _, inserter in ipairs(result.inserters) do + -- Resolve source target ID + local source_config = nil + if inserter.source then + local source_id = 1 -- Default fallback + if inserter.source.unit_number then + local mapped_id = result.unit_number_to_id[inserter.source.unit_number] + if mapped_id then + source_id = mapped_id + end + end + source_config = { + type = inserter.source.type, + id = source_id + } + end + + -- Resolve sink target ID + local sink_config = nil + if inserter.sink then + local sink_id = 1 -- Default fallback + if inserter.sink.unit_number then + local mapped_id = result.unit_number_to_id[inserter.sink.unit_number] + if mapped_id then + sink_id = mapped_id + end + end + sink_config = { + type = inserter.sink.type, + id = sink_id + } + end + + -- 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 + } + + -- Add filters if any exist + if #inserter.filters > 0 then + inserter_export.filters = inserter.filters + end + + table.insert(export.inserters, inserter_export) + end + end return helpers.table_to_json(export) end @@ -337,7 +498,7 @@ local function create_gui(player, result) -- Remove existing GUI destroy_gui(player) - local total_count = #result.machines + #result.drills + local total_count = #result.machines + #result.drills + #result.inserters -- Main frame local frame = player.gui.screen.add({ @@ -474,6 +635,67 @@ local function create_gui(player, result) 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 + + -- Add spacing if inserters follow + if #result.inserters > 0 then + local spacer = scroll.add({ type = "flow" }) + spacer.style.height = 16 + end + end + + -- Display inserters if any + if #result.inserters > 0 then + local inserters_header = scroll.add({ + type = "label", + caption = "Inserters (" .. #result.inserters .. ")", + style = "heading_2_label" + }) + inserters_header.style.bottom_margin = 4 + + local inserter_table = scroll.add({ + type = "table", + column_count = 5, + draw_horizontal_lines = true, + draw_vertical_lines = false + }) + inserter_table.style.horizontal_spacing = 16 + inserter_table.style.column_alignments[1] = "center" + inserter_table.style.column_alignments[2] = "left" + inserter_table.style.column_alignments[3] = "left" + inserter_table.style.column_alignments[4] = "center" + inserter_table.style.column_alignments[5] = "left" + + -- Header labels for inserters + local i_headers = { "#", "Type", "Source", "Stack", "Sink" } + for _, h in ipairs(i_headers) do + inserter_table.add({ + type = "label", + caption = h, + style = "bold_label" + }) + end + + -- 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 end end @@ -525,14 +747,14 @@ local function on_player_selected_area(event) init_player_data(event.player_index) - -- Extract entity data (machines and drills separately) + -- Extract entity data (machines, drills, and inserters) local result = extract_all_entities(event.entities) storage[event.player_index].extraction_result = result -- Show GUI create_gui(player, result) - local total = #result.machines + #result.drills + local total = #result.machines + #result.drills + #result.inserters if total == 0 then player.print({ "clock-generator-sidecar.no-machines-selected" }) else @@ -558,14 +780,14 @@ local function on_gui_click(event) local player_data = storage[event.player_index] if player_data and player_data.extraction_result then local result = player_data.extraction_result - if #result.machines > 0 or #result.drills > 0 then + if #result.machines > 0 or #result.drills > 0 or #result.inserters > 0 then local json = to_export_json(result) create_copy_popup(player, json) else - player.print("[Clock Generator Sidecar] No data found. Please select machines or drills first.") + player.print("[Clock Generator Sidecar] No data found. Please select entities first.") end else - player.print("[Clock Generator Sidecar] No data found. Please select machines or drills first.") + player.print("[Clock Generator Sidecar] No data found. Please select entities first.") end elseif element.name == "clock_generator_sidecar_close" then destroy_all_gui(player) diff --git a/clock-generator-sidecar/data.lua b/clock-generator-sidecar/data.lua index 774396b..da94467 100644 --- a/clock-generator-sidecar/data.lua +++ b/clock-generator-sidecar/data.lua @@ -15,13 +15,13 @@ data:extend({ 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" } + entity_type_filters = { "assembling-machine", "furnace", "mining-drill", "lab", "inserter" } }, 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" } + entity_type_filters = { "assembling-machine", "furnace", "mining-drill", "lab", "inserter" } }, flags = { "only-in-cursor", "spawnable" } }, diff --git a/clock-generator-ui/src/App.tsx b/clock-generator-ui/src/App.tsx index f4b3cc0..a45411a 100644 --- a/clock-generator-ui/src/App.tsx +++ b/clock-generator-ui/src/App.tsx @@ -100,6 +100,7 @@ function App() { updateDrill, removeDrill, replaceDrills, + replaceInserters, updateOverrides, importConfig, exportConfig, @@ -167,6 +168,7 @@ function App() { onImport={handleImportConfig} onReplaceMachines={replaceMachines} onReplaceDrills={replaceDrills} + onReplaceInserters={replaceInserters} parseConfig={parseConfig} /> diff --git a/clock-generator-ui/src/components/ConfigImportExport.tsx b/clock-generator-ui/src/components/ConfigImportExport.tsx index c35283c..a0086ac 100644 --- a/clock-generator-ui/src/components/ConfigImportExport.tsx +++ b/clock-generator-ui/src/components/ConfigImportExport.tsx @@ -2,17 +2,19 @@ import { Download, Upload, ContentPaste } from '@mui/icons-material'; import { Box, Button, Snackbar, Alert, Tooltip } from '@mui/material'; import { useRef, useState, useCallback } from 'react'; import type { Config } from 'clock-generator/browser'; -import { MachineConfigurationSchema, MiningDrillConfigSchema } from 'clock-generator/browser'; +import { MachineConfigurationSchema, MiningDrillConfigSchema, InserterConfigSchema } from 'clock-generator/browser'; import type { z } from 'zod'; type MachineConfiguration = z.infer; type MiningDrillConfiguration = z.infer; +type InserterConfiguration = z.infer; interface ConfigImportExportProps { config: Config; onImport: (config: Config) => void; onReplaceMachines: (machines: MachineConfiguration[]) => void; onReplaceDrills: (drills: MiningDrillConfiguration[]) => void; + onReplaceInserters: (inserters: InserterConfiguration[]) => void; parseConfig: (content: string) => Promise; } @@ -21,6 +23,7 @@ export function ConfigImportExport({ onImport, onReplaceMachines, onReplaceDrills, + onReplaceInserters, parseConfig, }: ConfigImportExportProps) { const fileInputRef = useRef(null); @@ -109,13 +112,14 @@ export function ConfigImportExport({ // 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?: unknown[] }; + const data = parsed as { machines?: unknown[]; drills?: unknown[]; inserters?: unknown[] }; const machineCount = Array.isArray(data.machines) ? data.machines.length : 0; const drillCount = Array.isArray(data.drills) ? data.drills.length : 0; + const inserterCount = Array.isArray(data.inserters) ? data.inserters.length : 0; - if (machineCount === 0 && drillCount === 0) { - throw new Error('No machines or drills found in clipboard data.'); + if (machineCount === 0 && drillCount === 0 && inserterCount === 0) { + throw new Error('No machines, drills, or inserters found in clipboard data.'); } // Validate machines (IDs are already assigned by the mod) @@ -144,16 +148,33 @@ export function ConfigImportExport({ } } + // 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); + } + } + if (validatedMachines.length > 0) { onReplaceMachines(validatedMachines); } if (validatedDrills.length > 0) { onReplaceDrills(validatedDrills); } + if (validatedInserters.length > 0) { + onReplaceInserters(validatedInserters); + } const parts: string[] = []; if (validatedMachines.length > 0) parts.push(`${validatedMachines.length} machines`); if (validatedDrills.length > 0) parts.push(`${validatedDrills.length} drills`); + if (validatedInserters.length > 0) parts.push(`${validatedInserters.length} inserters`); setSnackbar({ open: true, @@ -198,7 +219,7 @@ export function ConfigImportExport({ severity: 'error', }); } - }, [onReplaceMachines, onReplaceDrills]); + }, [onReplaceMachines, onReplaceDrills, onReplaceInserters]); return ( <> diff --git a/clock-generator-ui/src/hooks/useConfigForm.ts b/clock-generator-ui/src/hooks/useConfigForm.ts index d75cca9..d9cfe54 100644 --- a/clock-generator-ui/src/hooks/useConfigForm.ts +++ b/clock-generator-ui/src/hooks/useConfigForm.ts @@ -154,6 +154,7 @@ export interface UseConfigFormResult { addInserter: () => void; updateInserter: (index: number, updates: Partial) => void; removeInserter: (index: number) => void; + replaceInserters: (inserters: InserterFormData[]) => void; // Belts addBelt: () => void; @@ -298,6 +299,13 @@ export function useConfigForm(): UseConfigFormResult { })); }, []); + const replaceInserters = useCallback((newInserters: InserterFormData[]) => { + setConfig((prev) => ({ + ...prev, + inserters: newInserters, + })); + }, []); + // Belts const addBelt = useCallback(() => { setConfig((prev) => { @@ -603,6 +611,7 @@ export function useConfigForm(): UseConfigFormResult { addInserter, updateInserter, removeInserter, + replaceInserters, addBelt, updateBelt, removeBelt, From 29dd858a4bac7070eb460830ece773eee50e6762 Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 20:12:49 -0500 Subject: [PATCH 07/19] determine belts for inserters --- clock-generator-sidecar/control.lua | 358 ++++++++++++++++-- clock-generator-sidecar/data.lua | 4 +- clock-generator-sidecar/locale/en/locale.cfg | 2 +- clock-generator-ui/src/App.tsx | 2 + .../src/components/ConfigImportExport.tsx | 31 +- clock-generator-ui/src/hooks/useConfigForm.ts | 9 + 6 files changed, 376 insertions(+), 30 deletions(-) diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua index 68d9b7f..634f3ad 100644 --- a/clock-generator-sidecar/control.lua +++ b/clock-generator-sidecar/control.lua @@ -19,6 +19,15 @@ ---@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 PlayerData ---@field machines MachineData[] Extracted machine data ---@field gui LuaGuiElement? Reference to the GUI frame @@ -105,6 +114,100 @@ local function get_target_type(entity) return nil end +---Get the primary ingredient from a transport line (first item found, or nil if mixed/empty) +---@param transport_line LuaTransportLine +---@return string|nil ingredient The primary ingredient name, or nil +local function get_lane_ingredient(transport_line) + if not transport_line or not transport_line.valid then + return nil + end + + local contents = transport_line.get_contents() + if not contents or #contents == 0 then + return nil + end + + -- Get the first item found on this lane + -- contents is an array of {name, quality, count} + local first_item = contents[1] + if first_item and first_item.name then + return first_item.name + end + + return nil +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") +local function 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 + +---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 lanes = {} + + -- Transport belts have 2 lines: 1 = right lane, 2 = left lane + local max_lines = entity.get_max_transport_line_index() + + local has_items = false + for i = 1, math.min(max_lines, 2) do + local transport_line = entity.get_transport_line(i) + local ingredient = get_lane_ingredient(transport_line) + + if ingredient then + has_items = true + end + + table.insert(lanes, { + ingredient = ingredient, + stack_size = 1 -- Default stack size, can be adjusted based on belt type + }) + end + + -- Skip belts with no items on them + if not has_items then + return nil + end + + ---@type BeltData + local data = { + belt_type = normalize_belt_type(entity.name), + unit_number = entity.unit_number, + lanes = lanes + } + + return data +end + ---Extract data from an inserter ---@param entity LuaEntity ---@return InserterData|nil @@ -136,6 +239,8 @@ local function extract_inserter_data(entity) -- 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 = get_target_type(pickup_target) @@ -144,6 +249,43 @@ local function extract_inserter_data(entity) 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 + + 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 = get_lane_ingredient(transport_line) + if ingredient then + table.insert(source_belt_lanes, { + lane = i, + ingredient = ingredient + }) + end + end + end + end end end @@ -166,7 +308,9 @@ local function extract_inserter_data(entity) stack_size = stack_size, filters = filters, source = source, - sink = sink + sink = sink, + source_recipe_outputs = source_recipe_outputs, + source_belt_lanes = source_belt_lanes } return data @@ -238,6 +382,18 @@ local function extract_machine_data(entity) 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 + end + -- Handle crafting machines (assemblers, furnaces, etc.) return extract_crafting_machine_data(entity), "machine" end @@ -246,9 +402,11 @@ end ---@field machines MachineData[] ---@field drills DrillData[] ---@field inserters InserterData[] +---@field belts BeltData[] ---@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 ----Extract data from all selected entities, separating machines, drills, and inserters +---Extract data from all selected entities, separating machines, drills, inserters, and belts ---@param entities LuaEntity[] ---@return ExtractionResult local function extract_all_entities(entities) @@ -256,13 +414,15 @@ local function extract_all_entities(entities) machines = {}, drills = {}, inserters = {}, - unit_number_to_id = {} + belts = {}, + unit_number_to_id = {}, + belt_unit_number_to_id = {} } -- 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" then + 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 @@ -275,7 +435,51 @@ local function extract_all_entities(entities) end end - -- Second pass: extract drills (now we have the unit_number mapping) + -- 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) @@ -285,7 +489,7 @@ local function extract_all_entities(entities) end end - -- Third pass: extract inserters + -- Fourth pass: extract inserters for _, entity in pairs(entities) do if entity.type == "inserter" then local data = extract_inserter_data(entity) @@ -309,7 +513,8 @@ local function to_export_json(result) local export = { machines = {}, drills = {}, - inserters = {} + inserters = {}, + belts = {} } -- Format machines for clock-generator @@ -323,6 +528,23 @@ local function to_export_json(result) }) 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(export.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 @@ -348,14 +570,23 @@ local function to_export_json(result) -- Format inserters for clock-generator for _, inserter in ipairs(result.inserters) do - -- Resolve source target ID + -- Resolve source target ID (machines or belts) local source_config = nil if inserter.source then local source_id = 1 -- Default fallback if inserter.source.unit_number then - local mapped_id = result.unit_number_to_id[inserter.source.unit_number] - if mapped_id then - source_id = mapped_id + if inserter.source.type == "belt" then + -- Look up belt ID + local mapped_id = result.belt_unit_number_to_id[inserter.source.unit_number] + if mapped_id then + source_id = mapped_id + end + else + -- Look up machine/chest ID + local mapped_id = result.unit_number_to_id[inserter.source.unit_number] + if mapped_id then + source_id = mapped_id + end end end source_config = { @@ -364,14 +595,23 @@ local function to_export_json(result) } end - -- Resolve sink target ID + -- Resolve sink target ID (machines or belts) local sink_config = nil if inserter.sink then local sink_id = 1 -- Default fallback if inserter.sink.unit_number then - local mapped_id = result.unit_number_to_id[inserter.sink.unit_number] - if mapped_id then - sink_id = mapped_id + if inserter.sink.type == "belt" then + -- Look up belt ID + local mapped_id = result.belt_unit_number_to_id[inserter.sink.unit_number] + if mapped_id then + sink_id = mapped_id + end + else + -- Look up machine/chest ID + local mapped_id = result.unit_number_to_id[inserter.sink.unit_number] + if mapped_id then + sink_id = mapped_id + end end end sink_config = { @@ -388,9 +628,25 @@ local function to_export_json(result) stack_size = inserter.stack_size } - -- Add filters if any exist + -- Determine filters - use explicit filters first, then infer from source + local filters = {} if #inserter.filters > 0 then - inserter_export.filters = inserter.filters + 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(export.inserters, inserter_export) @@ -498,7 +754,7 @@ local function create_gui(player, result) -- Remove existing GUI destroy_gui(player) - local total_count = #result.machines + #result.drills + #result.inserters + local total_count = #result.machines + #result.drills + #result.inserters + #result.belts -- Main frame local frame = player.gui.screen.add({ @@ -522,7 +778,7 @@ local function create_gui(player, result) header.add({ type = "label", - caption = { "clock-generator-sidecar.machine-count", total_count }, + caption = { "clock-generator-sidecar.entity-count", total_count }, style = "heading_2_label" }) @@ -532,8 +788,8 @@ local function create_gui(player, result) direction = "vertical", style = "inside_shallow_frame_with_padding" }) - content_frame.style.minimal_width = 550 - content_frame.style.maximal_height = 450 + content_frame.style.minimal_width = 600 + content_frame.style.maximal_height = 500 if total_count == 0 then content_frame.add({ @@ -549,7 +805,7 @@ local function create_gui(player, result) vertical_scroll_policy = "auto", horizontal_scroll_policy = "never" }) - scroll.style.maximal_height = 400 + scroll.style.maximal_height = 500 -- Display machines if any if #result.machines > 0 then @@ -696,6 +952,64 @@ local function create_gui(player, result) end inserter_table.add({ type = "label", caption = sink_text }) end + + -- Add spacing if belts follow + if #result.belts > 0 then + local spacer = scroll.add({ type = "flow" }) + spacer.style.height = 16 + end + end + + -- Display belts if any + if #result.belts > 0 then + local belts_header = scroll.add({ + type = "label", + caption = "Transport Belts (" .. #result.belts .. ")", + style = "heading_2_label" + }) + belts_header.style.bottom_margin = 4 + + local belt_table = scroll.add({ + type = "table", + column_count = 4, + draw_horizontal_lines = true, + draw_vertical_lines = false + }) + belt_table.style.horizontal_spacing = 16 + belt_table.style.column_alignments[1] = "center" + belt_table.style.column_alignments[2] = "left" + belt_table.style.column_alignments[3] = "left" + belt_table.style.column_alignments[4] = "left" + + -- Header labels for belts + local b_headers = { "#", "Type", "Lane 1", "Lane 2" } + for _, h in ipairs(b_headers) do + belt_table.add({ + type = "label", + caption = h, + style = "bold_label" + }) + end + + -- 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 end end diff --git a/clock-generator-sidecar/data.lua b/clock-generator-sidecar/data.lua index da94467..07b4112 100644 --- a/clock-generator-sidecar/data.lua +++ b/clock-generator-sidecar/data.lua @@ -15,13 +15,13 @@ data:extend({ 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" } + entity_type_filters = { "assembling-machine", "furnace", "mining-drill", "lab", "inserter", "transport-belt", "underground-belt", "splitter" } }, 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" } + entity_type_filters = { "assembling-machine", "furnace", "mining-drill", "lab", "inserter", "transport-belt", "underground-belt", "splitter" } }, flags = { "only-in-cursor", "spawnable" } }, diff --git a/clock-generator-sidecar/locale/en/locale.cfg b/clock-generator-sidecar/locale/en/locale.cfg index d00f1af..51bdbf9 100644 --- a/clock-generator-sidecar/locale/en/locale.cfg +++ b/clock-generator-sidecar/locale/en/locale.cfg @@ -9,7 +9,7 @@ crafting-speed-analyzer-shortcut=Crafting Speed Analyzer [clock-generator-sidecar] gui-title=Clock Generator Sidecar -machine-count=__1__ machines selected +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. diff --git a/clock-generator-ui/src/App.tsx b/clock-generator-ui/src/App.tsx index a45411a..62fe336 100644 --- a/clock-generator-ui/src/App.tsx +++ b/clock-generator-ui/src/App.tsx @@ -90,6 +90,7 @@ function App() { addBelt, updateBelt, removeBelt, + replaceBelts, addChest, updateChest, removeChest, @@ -169,6 +170,7 @@ function App() { onReplaceMachines={replaceMachines} onReplaceDrills={replaceDrills} onReplaceInserters={replaceInserters} + onReplaceBelts={replaceBelts} parseConfig={parseConfig} /> diff --git a/clock-generator-ui/src/components/ConfigImportExport.tsx b/clock-generator-ui/src/components/ConfigImportExport.tsx index a0086ac..ce410d3 100644 --- a/clock-generator-ui/src/components/ConfigImportExport.tsx +++ b/clock-generator-ui/src/components/ConfigImportExport.tsx @@ -2,12 +2,13 @@ import { Download, Upload, ContentPaste } from '@mui/icons-material'; import { Box, Button, Snackbar, Alert, Tooltip } from '@mui/material'; import { useRef, useState, useCallback } from 'react'; import type { Config } from 'clock-generator/browser'; -import { MachineConfigurationSchema, MiningDrillConfigSchema, InserterConfigSchema } from 'clock-generator/browser'; +import { MachineConfigurationSchema, MiningDrillConfigSchema, InserterConfigSchema, BeltConfigSchema } 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; interface ConfigImportExportProps { config: Config; @@ -15,6 +16,7 @@ interface ConfigImportExportProps { onReplaceMachines: (machines: MachineConfiguration[]) => void; onReplaceDrills: (drills: MiningDrillConfiguration[]) => void; onReplaceInserters: (inserters: InserterConfiguration[]) => void; + onReplaceBelts: (belts: BeltConfiguration[]) => void; parseConfig: (content: string) => Promise; } @@ -24,6 +26,7 @@ export function ConfigImportExport({ onReplaceMachines, onReplaceDrills, onReplaceInserters, + onReplaceBelts, parseConfig, }: ConfigImportExportProps) { const fileInputRef = useRef(null); @@ -112,14 +115,15 @@ export function ConfigImportExport({ // 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?: unknown[]; inserters?: unknown[] }; + const data = parsed as { machines?: unknown[]; drills?: unknown[]; inserters?: unknown[]; belts?: unknown[] }; const machineCount = Array.isArray(data.machines) ? data.machines.length : 0; const drillCount = Array.isArray(data.drills) ? data.drills.length : 0; const inserterCount = Array.isArray(data.inserters) ? data.inserters.length : 0; + const beltCount = Array.isArray(data.belts) ? data.belts.length : 0; - if (machineCount === 0 && drillCount === 0 && inserterCount === 0) { - throw new Error('No machines, drills, or inserters found in clipboard data.'); + if (machineCount === 0 && drillCount === 0 && inserterCount === 0 && beltCount === 0) { + throw new Error('No machines, drills, inserters, or belts found in clipboard data.'); } // Validate machines (IDs are already assigned by the mod) @@ -161,6 +165,19 @@ export function ConfigImportExport({ } } + // 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); + } + } + if (validatedMachines.length > 0) { onReplaceMachines(validatedMachines); } @@ -170,11 +187,15 @@ export function ConfigImportExport({ if (validatedInserters.length > 0) { onReplaceInserters(validatedInserters); } + if (validatedBelts.length > 0) { + onReplaceBelts(validatedBelts); + } const parts: string[] = []; if (validatedMachines.length > 0) parts.push(`${validatedMachines.length} machines`); if (validatedDrills.length > 0) parts.push(`${validatedDrills.length} drills`); if (validatedInserters.length > 0) parts.push(`${validatedInserters.length} inserters`); + if (validatedBelts.length > 0) parts.push(`${validatedBelts.length} belts`); setSnackbar({ open: true, @@ -219,7 +240,7 @@ export function ConfigImportExport({ severity: 'error', }); } - }, [onReplaceMachines, onReplaceDrills, onReplaceInserters]); + }, [onReplaceMachines, onReplaceDrills, onReplaceInserters, onReplaceBelts]); return ( <> diff --git a/clock-generator-ui/src/hooks/useConfigForm.ts b/clock-generator-ui/src/hooks/useConfigForm.ts index d9cfe54..9d2a1e4 100644 --- a/clock-generator-ui/src/hooks/useConfigForm.ts +++ b/clock-generator-ui/src/hooks/useConfigForm.ts @@ -160,6 +160,7 @@ export interface UseConfigFormResult { addBelt: () => void; updateBelt: (index: number, updates: Partial) => void; removeBelt: (index: number) => void; + replaceBelts: (belts: BeltFormData[]) => void; // Chests addChest: () => void; @@ -340,6 +341,13 @@ export function useConfigForm(): UseConfigFormResult { })); }, []); + const replaceBelts = useCallback((newBelts: BeltFormData[]) => { + setConfig((prev) => ({ + ...prev, + belts: newBelts, + })); + }, []); + // Chests const addChest = useCallback(() => { setConfig((prev) => { @@ -615,6 +623,7 @@ export function useConfigForm(): UseConfigFormResult { addBelt, updateBelt, removeBelt, + replaceBelts, addChest, updateChest, removeChest, From 15f5df21bdc96c67cc8ad47366bdff853fa80d09 Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 20:16:46 -0500 Subject: [PATCH 08/19] clear extraction result on closing the window --- clock-generator-sidecar/control.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua index 634f3ad..44a45e6 100644 --- a/clock-generator-sidecar/control.lua +++ b/clock-generator-sidecar/control.lua @@ -672,6 +672,7 @@ local function destroy_gui(player) end if storage[player.index] then storage[player.index].gui = nil + storage[player.index].extraction_result = nil end end From b957a01dbf5f5bb5f1c9e49f9fa2593b58747e25 Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 21:22:29 -0500 Subject: [PATCH 09/19] automatically determine belt stack size --- clock-generator-sidecar/control.lua | 47 ++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua index 44a45e6..c22259b 100644 --- a/clock-generator-sidecar/control.lua +++ b/clock-generator-sidecar/control.lua @@ -114,27 +114,37 @@ local function get_target_type(entity) return nil end ----Get the primary ingredient from a transport line (first item found, or nil if mixed/empty) +---Get the primary ingredient and max stack size from a transport line ---@param transport_line LuaTransportLine ---@return string|nil ingredient The primary ingredient name, or nil -local function get_lane_ingredient(transport_line) +---@return number stack_size The maximum stack size found on this lane (default 1) +local function get_lane_info(transport_line) if not transport_line or not transport_line.valid then - return nil + return nil, 1 end local contents = transport_line.get_contents() if not contents or #contents == 0 then - return nil + return nil, 1 end -- Get the first item found on this lane -- contents is an array of {name, quality, count} local first_item = contents[1] - if first_item and first_item.name then - return first_item.name + 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 nil + return ingredient, max_stack end ---Normalize belt name to transport belt type (converts underground belts and splitters) @@ -181,7 +191,7 @@ local function extract_belt_data(entity) local has_items = false for i = 1, math.min(max_lines, 2) do local transport_line = entity.get_transport_line(i) - local ingredient = get_lane_ingredient(transport_line) + local ingredient, stack_size = get_lane_info(transport_line) if ingredient then has_items = true @@ -189,7 +199,7 @@ local function extract_belt_data(entity) table.insert(lanes, { ingredient = ingredient, - stack_size = 1 -- Default stack size, can be adjusted based on belt type + stack_size = stack_size }) end @@ -276,7 +286,7 @@ local function extract_inserter_data(entity) -- 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 = get_lane_ingredient(transport_line) + local ingredient, _ = get_lane_info(transport_line) if ingredient then table.insert(source_belt_lanes, { lane = i, @@ -665,21 +675,28 @@ local COPY_GUI_NAME = "clock_generator_sidecar_copy_frame" ---Destroy the main results GUI for a player (not the copy popup) ---@param player LuaPlayer -local function destroy_gui(player) +---@param clear_data boolean? Whether to also clear the extraction result data (default: false) +local function destroy_gui(player, clear_data) local frame = player.gui.screen[GUI_NAME] if frame and frame.valid then frame.destroy() end if storage[player.index] then storage[player.index].gui = nil - storage[player.index].extraction_result = nil + if clear_data then + storage[player.index].extraction_result = nil + end end end ---Destroy all GUIs for a player (including copy popup) ---@param player LuaPlayer -local function destroy_all_gui(player) - destroy_gui(player) +---@param clear_data boolean? Whether to also clear the extraction result data (default: true) +local function destroy_all_gui(player, clear_data) + if clear_data == nil then + clear_data = true + end + destroy_gui(player, clear_data) local copy_frame = player.gui.screen[COPY_GUI_NAME] if copy_frame and copy_frame.valid then copy_frame.destroy() @@ -1121,7 +1138,7 @@ local function on_gui_closed(event) if event.element and event.element.valid and event.element.name == GUI_NAME then local player = game.get_player(event.player_index) if player then - destroy_gui(player) + destroy_gui(player, true) -- Clear data when user closes the window end end end From d57f5c7b340836c8cae9f001f0660189ecc01404 Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 21:31:56 -0500 Subject: [PATCH 10/19] use global stack size research as fallback for output stack inserters --- clock-generator-sidecar/control.lua | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua index c22259b..d15dccb 100644 --- a/clock-generator-sidecar/control.lua +++ b/clock-generator-sidecar/control.lua @@ -116,16 +116,19 @@ 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 (default 1) -local function get_lane_info(transport_line) +---@return number stack_size The maximum stack size found on this lane, or default_stack_size +local function 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, 1 + return nil, default_stack_size end local contents = transport_line.get_contents() if not contents or #contents == 0 then - return nil, 1 + return nil, default_stack_size end -- Get the first item found on this lane @@ -183,6 +186,12 @@ local function extract_belt_data(entity) return nil end + -- Get the researched belt stack size for this force (1 + bonus) + local default_belt_stack_size = 1 + if entity.force then + default_belt_stack_size = 1 + (entity.force.belt_stack_size_bonus or 0) + end + local lanes = {} -- Transport belts have 2 lines: 1 = right lane, 2 = left lane @@ -191,7 +200,7 @@ local function extract_belt_data(entity) local has_items = false for i = 1, math.min(max_lines, 2) do local transport_line = entity.get_transport_line(i) - local ingredient, stack_size = get_lane_info(transport_line) + local ingredient, stack_size = get_lane_info(transport_line, default_belt_stack_size) if ingredient then has_items = true @@ -279,6 +288,12 @@ local function extract_inserter_data(entity) local picks_left = entity.pickup_from_left_lane local picks_right = entity.pickup_from_right_lane + -- Get the researched belt stack size for this force + local default_belt_stack_size = 1 + if pickup_target.force then + default_belt_stack_size = 1 + (pickup_target.force.belt_stack_size_bonus or 0) + end + for i = 1, math.min(max_lines, 2) do local is_right_lane = (i == 1) local is_left_lane = (i == 2) @@ -286,7 +301,7 @@ local function extract_inserter_data(entity) -- 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, _ = get_lane_info(transport_line) + local ingredient, _ = get_lane_info(transport_line, default_belt_stack_size) if ingredient then table.insert(source_belt_lanes, { lane = i, From ebaba3adc5ecaf2106d4716fbd91d204d42b255d Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 21:49:24 -0500 Subject: [PATCH 11/19] support extracting the current mining productivity level --- clock-generator-sidecar/control.lua | 21 ++++++++++--- clock-generator-ui/src/App.tsx | 1 + .../src/components/ConfigImportExport.tsx | 31 +++++++++++++++---- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua index d15dccb..9d63234 100644 --- a/clock-generator-sidecar/control.lua +++ b/clock-generator-sidecar/control.lua @@ -430,18 +430,26 @@ end ---@field belts BeltData[] ---@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 mining_productivity_level number The researched mining productivity level ---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 -local function extract_all_entities(entities) +local function 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 = {}, unit_number_to_id = {}, - belt_unit_number_to_id = {} + belt_unit_number_to_id = {}, + mining_productivity_level = mining_productivity_level } -- First pass: extract machines and build unit_number -> id mapping @@ -537,7 +545,10 @@ end local function to_export_json(result) local export = { machines = {}, - drills = {}, + drills = { + mining_productivity_level = result.mining_productivity_level, + configs = {} + }, inserters = {}, belts = {} } @@ -581,7 +592,7 @@ local function to_export_json(result) end end - table.insert(export.drills, { + table.insert(export.drills.configs, { id = i, type = drill.drill_type, mined_item_name = drill.mined_item_name, @@ -1095,7 +1106,7 @@ local function on_player_selected_area(event) init_player_data(event.player_index) -- Extract entity data (machines, drills, and inserters) - local result = extract_all_entities(event.entities) + local result = extract_all_entities(event.entities, player.force) storage[event.player_index].extraction_result = result -- Show GUI diff --git a/clock-generator-ui/src/App.tsx b/clock-generator-ui/src/App.tsx index 62fe336..22d6ed7 100644 --- a/clock-generator-ui/src/App.tsx +++ b/clock-generator-ui/src/App.tsx @@ -171,6 +171,7 @@ function App() { onReplaceDrills={replaceDrills} onReplaceInserters={replaceInserters} onReplaceBelts={replaceBelts} + onUpdateMiningProductivityLevel={(level) => updateDrillsConfig('mining_productivity_level', level)} parseConfig={parseConfig} /> diff --git a/clock-generator-ui/src/components/ConfigImportExport.tsx b/clock-generator-ui/src/components/ConfigImportExport.tsx index ce410d3..3527b5c 100644 --- a/clock-generator-ui/src/components/ConfigImportExport.tsx +++ b/clock-generator-ui/src/components/ConfigImportExport.tsx @@ -17,6 +17,7 @@ interface ConfigImportExportProps { onReplaceDrills: (drills: MiningDrillConfiguration[]) => void; onReplaceInserters: (inserters: InserterConfiguration[]) => void; onReplaceBelts: (belts: BeltConfiguration[]) => void; + onUpdateMiningProductivityLevel: (level: number) => void; parseConfig: (content: string) => Promise; } @@ -27,6 +28,7 @@ export function ConfigImportExport({ onReplaceDrills, onReplaceInserters, onReplaceBelts, + onUpdateMiningProductivityLevel, parseConfig, }: ConfigImportExportProps) { const fileInputRef = useRef(null); @@ -115,10 +117,23 @@ export function ConfigImportExport({ // 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?: unknown[]; inserters?: unknown[]; belts?: unknown[] }; + const data = parsed as { + machines?: unknown[]; + drills?: { mining_productivity_level?: number; configs?: unknown[] } | unknown[]; + inserters?: unknown[]; + belts?: unknown[] + }; const machineCount = Array.isArray(data.machines) ? data.machines.length : 0; - const drillCount = Array.isArray(data.drills) ? data.drills.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; @@ -141,9 +156,9 @@ export function ConfigImportExport({ // Validate drills (IDs are already assigned by the mod) const validatedDrills: MiningDrillConfiguration[] = []; - if (Array.isArray(data.drills)) { - for (let i = 0; i < data.drills.length; i++) { - const entry = data.drills[i]; + 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'}`); @@ -184,6 +199,10 @@ export function ConfigImportExport({ if (validatedDrills.length > 0) { onReplaceDrills(validatedDrills); } + // Update mining productivity level if provided + if (miningProductivityLevel !== undefined) { + onUpdateMiningProductivityLevel(miningProductivityLevel); + } if (validatedInserters.length > 0) { onReplaceInserters(validatedInserters); } @@ -240,7 +259,7 @@ export function ConfigImportExport({ severity: 'error', }); } - }, [onReplaceMachines, onReplaceDrills, onReplaceInserters, onReplaceBelts]); + }, [onReplaceMachines, onReplaceDrills, onReplaceInserters, onReplaceBelts, onUpdateMiningProductivityLevel]); return ( <> From 71a2485fc7e2561a40c8d7b8821812fefc2afa72 Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 22:30:15 -0500 Subject: [PATCH 12/19] change reference names to clock-generator-sidecar --- clock-generator-sidecar/control.lua | 2 +- clock-generator-sidecar/data.lua | 14 +++++++------- clock-generator-sidecar/locale/en/locale.cfg | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua index 9d63234..882e7a3 100644 --- a/clock-generator-sidecar/control.lua +++ b/clock-generator-sidecar/control.lua @@ -1094,7 +1094,7 @@ end ---Handle selection tool area selection ---@param event EventData.on_player_selected_area local function on_player_selected_area(event) - if event.item ~= "crafting-speed-analyzer" then + if event.item ~= "clock-generator-sidecar" then return end diff --git a/clock-generator-sidecar/data.lua b/clock-generator-sidecar/data.lua index 07b4112..dc7f53e 100644 --- a/clock-generator-sidecar/data.lua +++ b/clock-generator-sidecar/data.lua @@ -5,11 +5,11 @@ data:extend({ -- Selection tool for extracting crafting speeds { type = "selection-tool", - name = "crafting-speed-analyzer", + name = "clock-generator-sidecar", icon = "__base__/graphics/icons/blueprint.png", icon_size = 64, subgroup = "tool", - order = "c[automated-construction]-d[crafting-speed-analyzer]", + order = "c[automated-construction]-d[clock-generator-sidecar]", stack_size = 1, select = { border_color = { r = 0, g = 1, b = 0.5 }, @@ -29,22 +29,22 @@ data:extend({ -- Shortcut button for quick access { type = "shortcut", - name = "crafting-speed-analyzer-shortcut", + name = "clock-generator-sidecar-shortcut", action = "spawn-item", - item_to_spawn = "crafting-speed-analyzer", + item_to_spawn = "clock-generator-sidecar", icon = "__base__/graphics/icons/blueprint.png", icon_size = 64, small_icon = "__base__/graphics/icons/blueprint.png", small_icon_size = 64, - associated_control_input = "crafting-speed-analyzer-toggle" + associated_control_input = "clock-generator-sidecar-toggle" }, -- Custom input for keyboard shortcut { type = "custom-input", - name = "crafting-speed-analyzer-toggle", + name = "clock-generator-sidecar-toggle", key_sequence = "ALT + E", action = "spawn-item", - item_to_spawn = "crafting-speed-analyzer" + item_to_spawn = "clock-generator-sidecar" } }) diff --git a/clock-generator-sidecar/locale/en/locale.cfg b/clock-generator-sidecar/locale/en/locale.cfg index 51bdbf9..8004df6 100644 --- a/clock-generator-sidecar/locale/en/locale.cfg +++ b/clock-generator-sidecar/locale/en/locale.cfg @@ -1,11 +1,11 @@ [item-name] -crafting-speed-analyzer=Crafting Speed Analyzer +clock-generator-sidecar=Clock Generator Sidecar [item-description] -crafting-speed-analyzer=Select machines to extract crafting speeds and productivity bonuses. Use the shortcut (ALT+E) or toolbar button for quick access. +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] -crafting-speed-analyzer-shortcut=Crafting Speed Analyzer +clock-generator-sidecar-shortcut=Clock Generator Sidecar [clock-generator-sidecar] gui-title=Clock Generator Sidecar From fac212b43adccdeeed4f4284eb24960693030dc9 Mon Sep 17 00:00:00 2001 From: abucnasty Date: Fri, 2 Jan 2026 23:52:09 -0500 Subject: [PATCH 13/19] gotta have custom graphics... right? --- clock-generator-sidecar/build.sh | 1 + clock-generator-sidecar/data.lua | 6 +++--- clock-generator-sidecar/graphics/bbq_right.png | Bin 0 -> 8770 bytes 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 clock-generator-sidecar/graphics/bbq_right.png diff --git a/clock-generator-sidecar/build.sh b/clock-generator-sidecar/build.sh index 0358353..20a9a7e 100755 --- a/clock-generator-sidecar/build.sh +++ b/clock-generator-sidecar/build.sh @@ -32,6 +32,7 @@ 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/" # Create the zip file cd "$TEMP_DIR" diff --git a/clock-generator-sidecar/data.lua b/clock-generator-sidecar/data.lua index dc7f53e..11add04 100644 --- a/clock-generator-sidecar/data.lua +++ b/clock-generator-sidecar/data.lua @@ -6,7 +6,7 @@ data:extend({ { type = "selection-tool", name = "clock-generator-sidecar", - icon = "__base__/graphics/icons/blueprint.png", + icon = "__clock-generator-sidecar__/graphics/bbq_right.png", icon_size = 64, subgroup = "tool", order = "c[automated-construction]-d[clock-generator-sidecar]", @@ -32,9 +32,9 @@ data:extend({ name = "clock-generator-sidecar-shortcut", action = "spawn-item", item_to_spawn = "clock-generator-sidecar", - icon = "__base__/graphics/icons/blueprint.png", + icon = "__clock-generator-sidecar__/graphics/bbq_right.png", icon_size = 64, - small_icon = "__base__/graphics/icons/blueprint.png", + small_icon = "__clock-generator-sidecar__/graphics/bbq_right.png", small_icon_size = 64, associated_control_input = "clock-generator-sidecar-toggle" }, diff --git a/clock-generator-sidecar/graphics/bbq_right.png b/clock-generator-sidecar/graphics/bbq_right.png new file mode 100644 index 0000000000000000000000000000000000000000..7e6d754234216df663766dfc6c387b486940696c GIT binary patch literal 8770 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 sGjbN-dSC<4bb3Yk7H} Date: Sat, 3 Jan 2026 10:37:28 -0500 Subject: [PATCH 14/19] cleanup build to not log install instructions when install flag is set --- clock-generator-sidecar/build.sh | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/clock-generator-sidecar/build.sh b/clock-generator-sidecar/build.sh index 20a9a7e..c2f82b8 100755 --- a/clock-generator-sidecar/build.sh +++ b/clock-generator-sidecar/build.sh @@ -44,13 +44,16 @@ rm -rf "$TEMP_DIR" echo "" echo "✅ Built: $OUTPUT_FILE" echo "" -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" + +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 From 0a295e6a86fc18c3bf03ed3f47a785db72cbf77d Mon Sep 17 00:00:00 2001 From: abucnasty Date: Sat, 10 Jan 2026 19:12:04 -0500 Subject: [PATCH 15/19] refactor monolithic control.lua --- clock-generator-sidecar/build.sh | 1 + clock-generator-sidecar/control.lua | 1119 +---------------- clock-generator-sidecar/scripts/export.lua | 150 +++ .../scripts/extraction.lua | 396 ++++++ clock-generator-sidecar/scripts/gui.lua | 366 ++++++ clock-generator-sidecar/scripts/helpers.lua | 105 ++ clock-generator-sidecar/scripts/types.lua | 55 + 7 files changed, 1099 insertions(+), 1093 deletions(-) create mode 100644 clock-generator-sidecar/scripts/export.lua create mode 100644 clock-generator-sidecar/scripts/extraction.lua create mode 100644 clock-generator-sidecar/scripts/gui.lua create mode 100644 clock-generator-sidecar/scripts/helpers.lua create mode 100644 clock-generator-sidecar/scripts/types.lua diff --git a/clock-generator-sidecar/build.sh b/clock-generator-sidecar/build.sh index c2f82b8..77cbbb5 100755 --- a/clock-generator-sidecar/build.sh +++ b/clock-generator-sidecar/build.sh @@ -33,6 +33,7 @@ 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" diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua index 882e7a3..cc2fcfd 100644 --- a/clock-generator-sidecar/control.lua +++ b/clock-generator-sidecar/control.lua @@ -1,40 +1,19 @@ --- Clock Generator Sidecar - Control Stage --- Runtime logic for selecting machines and extracting crafting data +-- 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/. --- ============================================================================ --- Global State Management --- ============================================================================ +require("scripts.types") +local extraction = require("scripts.extraction") +local export = require("scripts.export") +local gui = require("scripts.gui") ----@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 PlayerData ----@field machines MachineData[] Extracted machine data ----@field gui LuaGuiElement? Reference to the GUI frame +-- 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 = {}, @@ -42,1054 +21,7 @@ local function init_player_data(player_index) } end --- ============================================================================ --- Machine Data Extraction --- ============================================================================ - ----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, -- e.g., "electric-mining-drill" - mined_item_name = mining_target.name, -- Resource being mined - speed_bonus = speed_bonus, -- Speed bonus from modules/beacons - productivity = total_productivity * 100, -- For display purposes - drop_target_unit_number = drop_target_unit_number, -- For cross-referencing - } - - return data -end - ----Determine the target type for an entity ----@param entity LuaEntity ----@return "machine"|"belt"|"chest"|nil -local function 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 -local function 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") -local function 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 - ----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 - - -- Get the researched belt stack size for this force (1 + bonus) - local default_belt_stack_size = 1 - if entity.force then - default_belt_stack_size = 1 + (entity.force.belt_stack_size_bonus or 0) - end - - local lanes = {} - - -- Transport belts have 2 lines: 1 = right lane, 2 = left lane - local max_lines = entity.get_max_transport_line_index() - - local has_items = false - for i = 1, math.min(max_lines, 2) do - local transport_line = entity.get_transport_line(i) - local ingredient, stack_size = get_lane_info(transport_line, default_belt_stack_size) - - if ingredient then - has_items = true - end - - table.insert(lanes, { - ingredient = ingredient, - stack_size = stack_size - }) - end - - -- Skip belts with no items on them - if not has_items then - return nil - end - - ---@type BeltData - local data = { - belt_type = normalize_belt_type(entity.name), - unit_number = entity.unit_number, - lanes = lanes - } - - return data -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 = 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 - - -- Get the researched belt stack size for this force - local default_belt_stack_size = 1 - if pickup_target.force then - default_belt_stack_size = 1 + (pickup_target.force.belt_stack_size_bonus or 0) - end - - 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, _ = 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 = 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 -local function extract_machine_data(entity) - if not entity or not entity.valid then - return 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 - end - - -- Handle crafting machines (assemblers, furnaces, etc.) - return extract_crafting_machine_data(entity), "machine" -end - ----@class ExtractionResult ----@field machines MachineData[] ----@field drills DrillData[] ----@field inserters InserterData[] ----@field belts BeltData[] ----@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 mining_productivity_level number The researched mining productivity level - ----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 -local function 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 = {}, - unit_number_to_id = {}, - belt_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 - - return result -end - --- ============================================================================ --- JSON Export --- ============================================================================ - ----Convert extraction result to clock-generator compatible JSON ----@param result ExtractionResult ----@return string -local function to_export_json(result) - local export = { - machines = {}, - drills = { - mining_productivity_level = result.mining_productivity_level, - configs = {} - }, - inserters = {}, - belts = {} - } - - -- Format machines for clock-generator - for i, machine in ipairs(result.machines) do - table.insert(export.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(export.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(export.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 - -- Resolve source target ID (machines or belts) - local source_config = nil - if inserter.source then - local source_id = 1 -- Default fallback - if inserter.source.unit_number then - if inserter.source.type == "belt" then - -- Look up belt ID - local mapped_id = result.belt_unit_number_to_id[inserter.source.unit_number] - if mapped_id then - source_id = mapped_id - end - else - -- Look up machine/chest ID - local mapped_id = result.unit_number_to_id[inserter.source.unit_number] - if mapped_id then - source_id = mapped_id - end - end - end - source_config = { - type = inserter.source.type, - id = source_id - } - end - - -- Resolve sink target ID (machines or belts) - local sink_config = nil - if inserter.sink then - local sink_id = 1 -- Default fallback - if inserter.sink.unit_number then - if inserter.sink.type == "belt" then - -- Look up belt ID - local mapped_id = result.belt_unit_number_to_id[inserter.sink.unit_number] - if mapped_id then - sink_id = mapped_id - end - else - -- Look up machine/chest ID - local mapped_id = result.unit_number_to_id[inserter.sink.unit_number] - if mapped_id then - sink_id = mapped_id - end - end - end - sink_config = { - type = inserter.sink.type, - id = sink_id - } - end - - -- 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(export.inserters, inserter_export) - end - end - - return helpers.table_to_json(export) -end - --- ============================================================================ --- GUI Management --- ============================================================================ - -local GUI_NAME = "clock_generator_sidecar_frame" -local COPY_GUI_NAME = "clock_generator_sidecar_copy_frame" - ----Destroy the main results GUI for a player (not the copy popup) ----@param player LuaPlayer ----@param clear_data boolean? Whether to also clear the extraction result data (default: false) -local function destroy_gui(player, clear_data) - local frame = player.gui.screen[GUI_NAME] - if frame and frame.valid then - frame.destroy() - end - if storage[player.index] then - storage[player.index].gui = nil - if clear_data then - storage[player.index].extraction_result = nil - end - end -end - ----Destroy all GUIs for a player (including copy popup) ----@param player LuaPlayer ----@param clear_data boolean? Whether to also clear the extraction result data (default: true) -local function destroy_all_gui(player, clear_data) - if clear_data == nil then - clear_data = true - end - destroy_gui(player, clear_data) - local copy_frame = player.gui.screen[COPY_GUI_NAME] - if copy_frame and copy_frame.valid then - copy_frame.destroy() - end -end - ----Create the copy/paste popup with selectable JSON text ----@param player LuaPlayer ----@param json string -local function create_copy_popup(player, json) - -- Remove any existing copy popup - local existing = player.gui.screen[COPY_GUI_NAME] - if existing and existing.valid then - existing.destroy() - end - - -- Create popup frame - local frame = player.gui.screen.add({ - type = "frame", - name = 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" } - }) - - -- Don't set player.opened here - it would close the main GUI and trigger destroy_gui - -- The popup will stay open alongside the main GUI -end - ----Create the extraction results GUI ----@param player LuaPlayer ----@param machines MachineData[] ----Create the GUI to display extracted data ----@param player LuaPlayer ----@param result ExtractionResult -local function create_gui(player, result) - -- Remove existing GUI - destroy_gui(player) - - local total_count = #result.machines + #result.drills + #result.inserters + #result.belts - - -- Main frame - local frame = player.gui.screen.add({ - type = "frame", - name = GUI_NAME, - direction = "vertical", - caption = { "clock-generator-sidecar.gui-title" } - }) - frame.auto_center = true - - -- Store reference - storage[player.index].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 machines_header = scroll.add({ - type = "label", - caption = "Machines & Furnaces (" .. #result.machines .. ")", - style = "heading_2_label" - }) - machines_header.style.bottom_margin = 4 - - local machine_table = scroll.add({ - type = "table", - column_count = 5, - draw_horizontal_lines = true, - draw_vertical_lines = false - }) - machine_table.style.horizontal_spacing = 16 - machine_table.style.column_alignments[1] = "center" - machine_table.style.column_alignments[4] = "right" - machine_table.style.column_alignments[5] = "right" - - -- Header labels for machines - local m_headers = { "#", { "clock-generator-sidecar.col-machine" }, { "clock-generator-sidecar.col-recipe" }, { "clock-generator-sidecar.col-speed" }, { "clock-generator-sidecar.col-productivity" } } - for _, h in ipairs(m_headers) do - machine_table.add({ - type = "label", - caption = h, - style = "bold_label" - }) - end - - -- 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 - - -- Add spacing if drills follow - if #result.drills > 0 then - local spacer = scroll.add({ type = "flow" }) - spacer.style.height = 16 - end - end - - -- Display drills if any - if #result.drills > 0 then - local drills_header = scroll.add({ - type = "label", - caption = "Mining Drills (" .. #result.drills .. ")", - style = "heading_2_label" - }) - drills_header.style.bottom_margin = 4 - - local drill_table = scroll.add({ - type = "table", - column_count = 5, - draw_horizontal_lines = true, - draw_vertical_lines = false - }) - drill_table.style.horizontal_spacing = 16 - drill_table.style.column_alignments[1] = "center" - drill_table.style.column_alignments[4] = "right" - drill_table.style.column_alignments[5] = "right" - - -- Header labels for drills - local d_headers = { "#", "Drill Type", "Resource", "Speed Bonus", { "clock-generator-sidecar.col-productivity" } } - for _, h in ipairs(d_headers) do - drill_table.add({ - type = "label", - caption = h, - style = "bold_label" - }) - end - - -- 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 - - -- Add spacing if inserters follow - if #result.inserters > 0 then - local spacer = scroll.add({ type = "flow" }) - spacer.style.height = 16 - end - end - - -- Display inserters if any - if #result.inserters > 0 then - local inserters_header = scroll.add({ - type = "label", - caption = "Inserters (" .. #result.inserters .. ")", - style = "heading_2_label" - }) - inserters_header.style.bottom_margin = 4 - - local inserter_table = scroll.add({ - type = "table", - column_count = 5, - draw_horizontal_lines = true, - draw_vertical_lines = false - }) - inserter_table.style.horizontal_spacing = 16 - inserter_table.style.column_alignments[1] = "center" - inserter_table.style.column_alignments[2] = "left" - inserter_table.style.column_alignments[3] = "left" - inserter_table.style.column_alignments[4] = "center" - inserter_table.style.column_alignments[5] = "left" - - -- Header labels for inserters - local i_headers = { "#", "Type", "Source", "Stack", "Sink" } - for _, h in ipairs(i_headers) do - inserter_table.add({ - type = "label", - caption = h, - style = "bold_label" - }) - end - - -- 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 - - -- Add spacing if belts follow - if #result.belts > 0 then - local spacer = scroll.add({ type = "flow" }) - spacer.style.height = 16 - end - end - - -- Display belts if any - if #result.belts > 0 then - local belts_header = scroll.add({ - type = "label", - caption = "Transport Belts (" .. #result.belts .. ")", - style = "heading_2_label" - }) - belts_header.style.bottom_margin = 4 - - local belt_table = scroll.add({ - type = "table", - column_count = 4, - draw_horizontal_lines = true, - draw_vertical_lines = false - }) - belt_table.style.horizontal_spacing = 16 - belt_table.style.column_alignments[1] = "center" - belt_table.style.column_alignments[2] = "left" - belt_table.style.column_alignments[3] = "left" - belt_table.style.column_alignments[4] = "left" - - -- Header labels for belts - local b_headers = { "#", "Type", "Lane 1", "Lane 2" } - for _, h in ipairs(b_headers) do - belt_table.add({ - type = "label", - caption = h, - style = "bold_label" - }) - end - - -- 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 - 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 - --- ============================================================================ -- Event Handlers --- ============================================================================ ---Handle selection tool area selection ---@param event EventData.on_player_selected_area @@ -1104,15 +36,16 @@ local function on_player_selected_area(event) end init_player_data(event.player_index) + local player_data = storage[event.player_index] - -- Extract entity data (machines, drills, and inserters) - local result = extract_all_entities(event.entities, player.force) - storage[event.player_index].extraction_result = result + -- Extract entity data (machines, drills, inserters, and belts) + local result = extraction.extract_all_entities(event.entities, player.force) + player_data.extraction_result = result -- Show GUI - create_gui(player, result) + gui.create(player, player_data, result) - local total = #result.machines + #result.drills + #result.inserters + local total = #result.machines + #result.drills + #result.inserters + #result.belts if total == 0 then player.print({ "clock-generator-sidecar.no-machines-selected" }) else @@ -1133,14 +66,15 @@ local function on_gui_click(event) return end + local player_data = storage[event.player_index] + if element.name == "clock_generator_sidecar_copy" then -- Show copy popup with JSON text - local player_data = storage[event.player_index] 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 then - local json = to_export_json(result) - create_copy_popup(player, json) + if #result.machines > 0 or #result.drills > 0 or #result.inserters > 0 or #result.belts > 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 @@ -1148,10 +82,10 @@ local function on_gui_click(event) player.print("[Clock Generator Sidecar] No data found. Please select entities first.") end elseif element.name == "clock_generator_sidecar_close" then - destroy_all_gui(player) + 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[COPY_GUI_NAME] + local copy_frame = player.gui.screen[gui.COPY_GUI_NAME] if copy_frame and copy_frame.valid then copy_frame.destroy() end @@ -1161,17 +95,16 @@ 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_NAME then + 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 - destroy_gui(player, true) -- Clear data when user closes the window + 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) diff --git a/clock-generator-sidecar/scripts/export.lua b/clock-generator-sidecar/scripts/export.lua new file mode 100644 index 0000000..733b2ab --- /dev/null +++ b/clock-generator-sidecar/scripts/export.lua @@ -0,0 +1,150 @@ +-- 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 + else + -- Look up machine/chest 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 = {} + } + + -- 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 + + 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..eba0d92 --- /dev/null +++ b/clock-generator-sidecar/scripts/extraction.lua @@ -0,0 +1,396 @@ +-- 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() + + local has_items = false + for i = 1, math.min(max_lines, 2) do + local transport_line = entity.get_transport_line(i) + local ingredient, stack_size = helpers.get_lane_info(transport_line, default_belt_stack_size) + + if ingredient then + has_items = true + end + + table.insert(lanes, { + ingredient = ingredient, + stack_size = stack_size + }) + end + + -- Skip belts with no items on them + if not has_items then + return nil + end + + ---@type BeltData + local data = { + belt_type = helpers.normalize_belt_type(entity.name), + unit_number = entity.unit_number, + lanes = lanes + } + + return data +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 = {}, + unit_number_to_id = {}, + belt_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 + + 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..efff06e --- /dev/null +++ b/clock-generator-sidecar/scripts/gui.lua @@ -0,0 +1,366 @@ +-- 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 + + -- 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 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 + 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..56d7729 --- /dev/null +++ b/clock-generator-sidecar/scripts/types.lua @@ -0,0 +1,55 @@ +---@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 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 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 mining_productivity_level number The researched mining productivity level + +return {} From a7f0a544b799ed1675d125f46b7c6dc710eaccbf Mon Sep 17 00:00:00 2001 From: abucnasty Date: Sat, 10 Jan 2026 19:40:29 -0500 Subject: [PATCH 16/19] ability to import chest info --- clock-generator-sidecar/control.lua | 6 +- clock-generator-sidecar/data.lua | 4 +- clock-generator-sidecar/scripts/export.lua | 38 +++- .../scripts/extraction.lua | 166 ++++++++++++++---- clock-generator-sidecar/scripts/gui.lua | 45 ++++- clock-generator-sidecar/scripts/types.lua | 19 ++ clock-generator-ui/src/App.tsx | 2 + .../src/components/ConfigImportExport.tsx | 30 +++- clock-generator-ui/src/hooks/useConfigForm.ts | 9 + clock-generator/src/browser.ts | 3 + 10 files changed, 275 insertions(+), 47 deletions(-) diff --git a/clock-generator-sidecar/control.lua b/clock-generator-sidecar/control.lua index cc2fcfd..6e01ff1 100644 --- a/clock-generator-sidecar/control.lua +++ b/clock-generator-sidecar/control.lua @@ -38,14 +38,14 @@ local function on_player_selected_area(event) init_player_data(event.player_index) local player_data = storage[event.player_index] - -- Extract entity data (machines, drills, inserters, and belts) + -- 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 + 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 @@ -72,7 +72,7 @@ local function on_gui_click(event) -- 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 then + 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 diff --git a/clock-generator-sidecar/data.lua b/clock-generator-sidecar/data.lua index 11add04..342225a 100644 --- a/clock-generator-sidecar/data.lua +++ b/clock-generator-sidecar/data.lua @@ -15,13 +15,13 @@ data:extend({ 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" } + 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" } + 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" } }, diff --git a/clock-generator-sidecar/scripts/export.lua b/clock-generator-sidecar/scripts/export.lua index 733b2ab..c86d386 100644 --- a/clock-generator-sidecar/scripts/export.lua +++ b/clock-generator-sidecar/scripts/export.lua @@ -24,8 +24,14 @@ local function resolve_target_id(target, result) 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/chest ID + -- Look up machine ID local mapped_id = result.unit_number_to_id[target.unit_number] if mapped_id then target_id = mapped_id @@ -52,7 +58,8 @@ function export.to_json(result) configs = {} }, inserters = {}, - belts = {} + belts = {}, + chests = {} } -- Format machines for clock-generator @@ -143,6 +150,33 @@ function export.to_json(result) 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 diff --git a/clock-generator-sidecar/scripts/extraction.lua b/clock-generator-sidecar/scripts/extraction.lua index eba0d92..61c27c5 100644 --- a/clock-generator-sidecar/scripts/extraction.lua +++ b/clock-generator-sidecar/scripts/extraction.lua @@ -27,7 +27,7 @@ local function extract_mining_drill_data(entity) -- 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 @@ -54,49 +54,130 @@ 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() - + local has_items = false for i = 1, math.min(max_lines, 2) do local transport_line = entity.get_transport_line(i) local ingredient, stack_size = helpers.get_lane_info(transport_line, default_belt_stack_size) - + if ingredient then has_items = true end - + table.insert(lanes, { ingredient = ingredient, stack_size = stack_size }) end - + -- Skip belts with no items on them if not has_items then return nil 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 @@ -104,18 +185,18 @@ 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 @@ -125,7 +206,7 @@ local function extract_inserter_data(entity) table.insert(filters, filter.name) end end - + -- Get source (pickup target) local source = nil local source_recipe_outputs = nil @@ -138,7 +219,7 @@ local function extract_inserter_data(entity) 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() @@ -150,20 +231,20 @@ local function extract_inserter_data(entity) end end end - -- If source is a belt, get contents from each lane + -- 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) @@ -179,7 +260,7 @@ local function extract_inserter_data(entity) end end end - + -- Get sink (drop target) local sink = nil local drop_target = entity.drop_target @@ -192,7 +273,7 @@ local function extract_inserter_data(entity) } end end - + ---@type InserterData local data = { inserter_type = entity.name, @@ -203,7 +284,7 @@ local function extract_inserter_data(entity) source_recipe_outputs = source_recipe_outputs, source_belt_lanes = source_belt_lanes } - + return data end @@ -229,7 +310,7 @@ local function extract_crafting_machine_data(entity) -- 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 @@ -241,7 +322,7 @@ local function extract_crafting_machine_data(entity) -- 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 @@ -278,7 +359,7 @@ local function extract_machine_data(entity) ["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 @@ -299,17 +380,19 @@ function extraction.extract_all_entities(entities, force) 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 @@ -325,12 +408,12 @@ function extraction.extract_all_entities(entities, force) 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} - + 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 @@ -348,7 +431,7 @@ function extraction.extract_all_entities(entities, force) 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 @@ -369,7 +452,7 @@ function extraction.extract_all_entities(entities, force) 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 @@ -379,7 +462,7 @@ function extraction.extract_all_entities(entities, force) end end end - + -- Fourth pass: extract inserters for _, entity in pairs(entities) do if entity.type == "inserter" then @@ -390,6 +473,21 @@ function extraction.extract_all_entities(entities, force) 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 diff --git a/clock-generator-sidecar/scripts/gui.lua b/clock-generator-sidecar/scripts/gui.lua index efff06e..4113d2b 100644 --- a/clock-generator-sidecar/scripts/gui.lua +++ b/clock-generator-sidecar/scripts/gui.lua @@ -163,7 +163,7 @@ 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 + local total_count = #result.machines + #result.drills + #result.inserters + #result.belts + #result.chests -- Main frame local frame = player.gui.screen.add({ @@ -296,7 +296,7 @@ function gui.create(player, player_data, result) inserter_table.add({ type = "label", caption = sink_text }) end - if #result.belts > 0 then + if #result.belts > 0 or #result.chests > 0 then add_spacer(scroll) end end @@ -330,6 +330,47 @@ function gui.create(player, player_data, result) 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 diff --git a/clock-generator-sidecar/scripts/types.lua b/clock-generator-sidecar/scripts/types.lua index 56d7729..4a66b6e 100644 --- a/clock-generator-sidecar/scripts/types.lua +++ b/clock-generator-sidecar/scripts/types.lua @@ -29,6 +29,23 @@ ---@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 @@ -48,8 +65,10 @@ ---@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 e7dbea2..5be940e 100644 --- a/clock-generator-ui/src/App.tsx +++ b/clock-generator-ui/src/App.tsx @@ -109,6 +109,7 @@ function App() { updateChest, switchChestType, removeChest, + replaceChests, enableDrills, disableDrills, updateDrillsConfig, @@ -186,6 +187,7 @@ function App() { onReplaceDrills={replaceDrills} onReplaceInserters={replaceInserters} onReplaceBelts={replaceBelts} + onReplaceChests={replaceChests} onUpdateMiningProductivityLevel={(level) => updateDrillsConfig('mining_productivity_level', level)} parseConfig={parseConfig} /> diff --git a/clock-generator-ui/src/components/ConfigImportExport.tsx b/clock-generator-ui/src/components/ConfigImportExport.tsx index 3527b5c..acac29d 100644 --- a/clock-generator-ui/src/components/ConfigImportExport.tsx +++ b/clock-generator-ui/src/components/ConfigImportExport.tsx @@ -2,13 +2,14 @@ import { Download, Upload, ContentPaste } from '@mui/icons-material'; import { Box, Button, Snackbar, Alert, Tooltip } from '@mui/material'; import { useRef, useState, useCallback } from 'react'; import type { Config } from 'clock-generator/browser'; -import { MachineConfigurationSchema, MiningDrillConfigSchema, InserterConfigSchema, BeltConfigSchema } 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 ConfigImportExportProps { config: Config; @@ -17,6 +18,7 @@ interface ConfigImportExportProps { onReplaceDrills: (drills: MiningDrillConfiguration[]) => void; onReplaceInserters: (inserters: InserterConfiguration[]) => void; onReplaceBelts: (belts: BeltConfiguration[]) => void; + onReplaceChests: (chests: ChestConfiguration[]) => void; onUpdateMiningProductivityLevel: (level: number) => void; parseConfig: (content: string) => Promise; } @@ -28,6 +30,7 @@ export function ConfigImportExport({ onReplaceDrills, onReplaceInserters, onReplaceBelts, + onReplaceChests, onUpdateMiningProductivityLevel, parseConfig, }: ConfigImportExportProps) { @@ -121,7 +124,8 @@ export function ConfigImportExport({ machines?: unknown[]; drills?: { mining_productivity_level?: number; configs?: unknown[] } | unknown[]; inserters?: unknown[]; - belts?: unknown[] + belts?: unknown[]; + chests?: unknown[]; }; const machineCount = Array.isArray(data.machines) ? data.machines.length : 0; @@ -136,9 +140,10 @@ export function ConfigImportExport({ 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) { - throw new Error('No machines, drills, inserters, or belts found in clipboard data.'); + 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) @@ -193,6 +198,19 @@ export function ConfigImportExport({ } } + // 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); + } + } + if (validatedMachines.length > 0) { onReplaceMachines(validatedMachines); } @@ -209,12 +227,16 @@ export function ConfigImportExport({ if (validatedBelts.length > 0) { onReplaceBelts(validatedBelts); } + if (validatedChests.length > 0) { + onReplaceChests(validatedChests); + } const parts: string[] = []; if (validatedMachines.length > 0) parts.push(`${validatedMachines.length} machines`); if (validatedDrills.length > 0) parts.push(`${validatedDrills.length} drills`); if (validatedInserters.length > 0) parts.push(`${validatedInserters.length} inserters`); if (validatedBelts.length > 0) parts.push(`${validatedBelts.length} belts`); + if (validatedChests.length > 0) parts.push(`${validatedChests.length} chests`); setSnackbar({ open: true, diff --git a/clock-generator-ui/src/hooks/useConfigForm.ts b/clock-generator-ui/src/hooks/useConfigForm.ts index 13ad5ca..b540e59 100644 --- a/clock-generator-ui/src/hooks/useConfigForm.ts +++ b/clock-generator-ui/src/hooks/useConfigForm.ts @@ -250,6 +250,7 @@ export interface UseConfigFormResult { updateChest: (index: number, updates: Partial) => void; switchChestType: (index: number, newType: ChestType) => void; removeChest: (index: number) => void; + replaceChests: (chests: ChestFormData[]) => void; // Drills enableDrills: () => void; @@ -495,6 +496,13 @@ export function useConfigForm(): UseConfigFormResult { })); }, []); + const replaceChests = useCallback((newChests: ChestFormData[]) => { + setConfig((prev) => ({ + ...prev, + chests: newChests, + })); + }, []); + // Drills const enableDrills = useCallback(() => { setConfig((prev) => ({ @@ -781,6 +789,7 @@ export function useConfigForm(): UseConfigFormResult { updateChest, switchChestType, removeChest, + replaceChests, enableDrills, disableDrills, updateDrillsConfig, 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, From 85a77bf48a29354a517a292e77e0dd2f5a93f8be Mon Sep 17 00:00:00 2001 From: abucnasty Date: Sat, 10 Jan 2026 19:49:16 -0500 Subject: [PATCH 17/19] move transport belt 2 to 1 if 1 has no items --- .../scripts/extraction.lua | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/clock-generator-sidecar/scripts/extraction.lua b/clock-generator-sidecar/scripts/extraction.lua index 61c27c5..484d4fb 100644 --- a/clock-generator-sidecar/scripts/extraction.lua +++ b/clock-generator-sidecar/scripts/extraction.lua @@ -67,24 +67,40 @@ local function extract_belt_data(entity) -- Transport belts have 2 lines: 1 = right lane, 2 = left lane local max_lines = entity.get_max_transport_line_index() - local has_items = false - for i = 1, math.min(max_lines, 2) do - local transport_line = entity.get_transport_line(i) - local ingredient, stack_size = helpers.get_lane_info(transport_line, default_belt_stack_size) + -- 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) - if ingredient then - has_items = true - end + -- 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, - stack_size = stack_size + 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 }) - end - -- Skip belts with no items on them - if not has_items then - return nil + -- 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 From 5b06e6c718c1ffc3c98f038e030a705813a6699e Mon Sep 17 00:00:00 2001 From: abucnasty Date: Sat, 10 Jan 2026 19:54:07 -0500 Subject: [PATCH 18/19] confirmation modal upon pasting from factorio --- .../src/components/ConfigImportExport.tsx | 231 +++++++++++++++--- 1 file changed, 193 insertions(+), 38 deletions(-) diff --git a/clock-generator-ui/src/components/ConfigImportExport.tsx b/clock-generator-ui/src/components/ConfigImportExport.tsx index acac29d..9b45f54 100644 --- a/clock-generator-ui/src/components/ConfigImportExport.tsx +++ b/clock-generator-ui/src/components/ConfigImportExport.tsx @@ -1,5 +1,5 @@ import { Download, Upload, ContentPaste } from '@mui/icons-material'; -import { Box, Button, Snackbar, Alert, Tooltip } from '@mui/material'; +import { Box, Button, Snackbar, Alert, Tooltip, Dialog, DialogTitle, DialogContent, DialogActions, FormControlLabel, Checkbox, Typography } 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'; @@ -11,6 +11,23 @@ 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; @@ -41,6 +58,16 @@ export function ConfigImportExport({ severity: 'success' | 'error'; }>({ open: false, message: '', severity: 'success' }); + // 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); @@ -211,37 +238,22 @@ export function ConfigImportExport({ } } - if (validatedMachines.length > 0) { - onReplaceMachines(validatedMachines); - } - if (validatedDrills.length > 0) { - onReplaceDrills(validatedDrills); - } - // Update mining productivity level if provided - if (miningProductivityLevel !== undefined) { - onUpdateMiningProductivityLevel(miningProductivityLevel); - } - if (validatedInserters.length > 0) { - onReplaceInserters(validatedInserters); - } - if (validatedBelts.length > 0) { - onReplaceBelts(validatedBelts); - } - if (validatedChests.length > 0) { - onReplaceChests(validatedChests); - } - - const parts: string[] = []; - if (validatedMachines.length > 0) parts.push(`${validatedMachines.length} machines`); - if (validatedDrills.length > 0) parts.push(`${validatedDrills.length} drills`); - if (validatedInserters.length > 0) parts.push(`${validatedInserters.length} inserters`); - if (validatedBelts.length > 0) parts.push(`${validatedBelts.length} belts`); - if (validatedChests.length > 0) parts.push(`${validatedChests.length} chests`); - - setSnackbar({ - open: true, - message: `Replaced with ${parts.join(' and ')} from Factorio!`, - severity: 'success', + // 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; } @@ -267,11 +279,20 @@ export function ConfigImportExport({ validatedMachines.push(result.data); } - onReplaceMachines(validatedMachines); - setSnackbar({ - open: true, - message: `Replaced with ${validatedMachines.length} machines from Factorio!`, - severity: 'success', + // 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); @@ -281,7 +302,65 @@ export function ConfigImportExport({ severity: 'error', }); } - }, [onReplaceMachines, onReplaceDrills, onReplaceInserters, onReplaceBelts, onUpdateMiningProductivityLevel]); + }, []); + + 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], + })); + }, []); return ( <> @@ -333,6 +412,82 @@ export function ConfigImportExport({ {snackbar.message} + + {/* 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})`} + /> + + )} + + + + + + ); } From 25dba4e5e3d5a4ea8806ccc9facec9d7d1b9a59e Mon Sep 17 00:00:00 2001 From: abucnasty Date: Sat, 10 Jan 2026 19:57:44 -0500 Subject: [PATCH 19/19] refactor to move paste and reset actions to secondary drop down option --- clock-generator-ui/src/App.tsx | 12 +--- .../src/components/ConfigImportExport.tsx | 70 ++++++++++++++++--- 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/clock-generator-ui/src/App.tsx b/clock-generator-ui/src/App.tsx index 5be940e..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, @@ -189,17 +187,9 @@ function App() { onReplaceBelts={replaceBelts} onReplaceChests={replaceChests} onUpdateMiningProductivityLevel={(level) => 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 9b45f54..c5d4d8f 100644 --- a/clock-generator-ui/src/components/ConfigImportExport.tsx +++ b/clock-generator-ui/src/components/ConfigImportExport.tsx @@ -1,5 +1,5 @@ -import { Download, Upload, ContentPaste } from '@mui/icons-material'; -import { Box, Button, Snackbar, Alert, Tooltip, Dialog, DialogTitle, DialogContent, DialogActions, FormControlLabel, Checkbox, Typography } 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'; @@ -37,6 +37,7 @@ interface ConfigImportExportProps { onReplaceBelts: (belts: BeltConfiguration[]) => void; onReplaceChests: (chests: ChestConfiguration[]) => void; onUpdateMiningProductivityLevel: (level: number) => void; + onReset: () => void; parseConfig: (content: string) => Promise; } @@ -49,6 +50,7 @@ export function ConfigImportExport({ onReplaceBelts, onReplaceChests, onUpdateMiningProductivityLevel, + onReset, parseConfig, }: ConfigImportExportProps) { const fileInputRef = useRef(null); @@ -58,6 +60,10 @@ 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({ @@ -362,9 +368,27 @@ export function ConfigImportExport({ })); }, []); + 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 + +