Myros27

spatialHelperBot

May 30th, 2025 (edited)
32
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 56.34 KB | None | 0 0
  1. --[[
  2. Spatial Helper Turtle
  3. Version 1.7
  4. --]]
  5.  
  6. -- Configuration
  7. local DISK_DATA_FILE = "disks.json"
  8. local HELPER_NAME_FILE = "helper_name.json"
  9. local CHAT_MODULE_NAME = "chatBox"
  10. local TEMP_SLOT_FOR_STAGING_AND_SUCKING = 16 -- Utility slot for sucking and staging new disks
  11. local MAX_REGISTERED_DISKS = 15
  12.  
  13. -- Chat Appearance
  14. local HELPER_CHAT_BRACKET_COLOR = "&9"
  15. local HELPER_CHAT_ERROR_BRACKET_COLOR = "&c"
  16.  
  17. -- Turtle State
  18. local disk_inventory_data = {}
  19. local chat_box = nil
  20. local helper_turtle_name = "" -- e.g., "Cube123"
  21. local in_error_state = false
  22. -- pending_registration_info stores context between 'start' and 'end' commands for new disk registration.
  23. -- Structure: { staging_slot = number, target_slot_if_direct = number_or_nil, existing_reg_at_staging_slot = table_or_nil }
  24. local pending_registration_info = nil
  25.  
  26. --#region Helper Functions (Core Utilities)
  27. local function get_helper_chat_tag_text()
  28.     -- Generates the "[CubeXXX]" part of the chat messages.
  29.     if helper_turtle_name and helper_turtle_name ~= "" then
  30.         return "[" .. helper_turtle_name .. "]"
  31.     else
  32.         return "[Helper]" -- Fallback if name isn't set (should only be during initial prompt)
  33.     end
  34. end
  35.  
  36. function error_state(reason)
  37.     -- Centralized error handling: logs, sends chat (if possible), and halts turtle.
  38.     term.setTextColor(colors.red)
  39.     print("ERROR: " .. reason)
  40.     term.setTextColor(colors.white)
  41.     print("Helper Turtle (" .. (helper_turtle_name or "Unnamed") .. ") is now locked down.")
  42.     in_error_state = true
  43.     if chat_box then
  44.         pcall(chat_box.sendMessage, "CRITICAL ERROR: " .. reason .. " Halting operations.", get_helper_chat_tag_text(), "[]", HELPER_CHAT_ERROR_BRACKET_COLOR)
  45.     end
  46.     while true do os.sleep(3600) end -- Effectively halts the turtle
  47. end
  48.  
  49. local function tonumber_strict(val, context_msg)
  50.     -- Converts a value to a number, calling error_state if conversion fails or value is nil.
  51.     local num = tonumber(val)
  52.     if num == nil then
  53.         error_state("Type error: Expected a number for " .. (context_msg or "value") .. ", got " .. type(val) .. " (" .. tostring(val) .. ")")
  54.     end
  55.     return num
  56. end
  57.  
  58. local function canonicalize_name(name_str) -- For standardizing operator turtle names
  59.     if type(name_str) ~= "string" then return "" end
  60.     local lower_name = name_str:lower()
  61.     local letters_part = {}
  62.     for char in lower_name:gmatch("[a-z]") do table.insert(letters_part, char) end
  63.     table.sort(letters_part)
  64.     local numbers_part = {}
  65.     for char in lower_name:gmatch("[0-9]") do table.insert(numbers_part, char) end
  66.     table.sort(numbers_part)
  67.     return table.concat(letters_part) .. table.concat(numbers_part)
  68. end
  69.  
  70. local function send_chat_message(message_body, is_error_level)
  71.     -- Sends a formatted message to chat via the chatBox peripheral.
  72.     if chat_box then
  73.         local bracket_color_to_use = is_error_level and HELPER_CHAT_ERROR_BRACKET_COLOR or HELPER_CHAT_BRACKET_COLOR
  74.         local success, err = pcall(chat_box.sendMessage, message_body, get_helper_chat_tag_text(), "[]", bracket_color_to_use)
  75.         if not success then
  76.             print("Warning: Failed to send chat message: " .. tostring(err))
  77.         end
  78.         os.sleep(tonumber_strict(1, "chat cooldown sleep")) -- Prevent spam
  79.     else
  80.         -- Fallback to console if chat_box is not available (e.g., during early init or error)
  81.         local console_prefix = (is_error_level and HELPER_CHAT_ERROR_BRACKET_COLOR or HELPER_CHAT_BRACKET_COLOR) .. get_helper_chat_tag_text() .. "&r"
  82.         print("Chat: " .. console_prefix .. " " .. message_body)
  83.     end
  84. end
  85.  
  86. local function file_exists(path) return fs.exists(path) end
  87.  
  88. local function load_json_file(path, context_for_error)
  89.     -- Generic JSON file loader with error handling.
  90.     if not file_exists(path) then return nil end -- File not existing is not an error here, handled by caller.
  91.     local handle = fs.open(path, "r")
  92.     if not handle then error_state("Cannot open " .. path .. " for reading (" .. context_for_error .. ").") return nil end
  93.     local content = handle.readAll()
  94.     handle.close()
  95.     local success, data = pcall(textutils.unserialiseJSON, content)
  96.     if not success then error_state("Cannot parse JSON from " .. path .. " (" .. context_for_error .. "). Error: " .. tostring(data)) return nil end
  97.     return data
  98. end
  99.  
  100. local function save_json_file(path, data, context_for_error)
  101.     -- Generic JSON file saver with error handling.
  102.     local success, serialized_data = pcall(textutils.serialiseJSON, data, {compact=false}) -- Use compact=true for smaller files if preferred
  103.     if not success or not serialized_data then error_state("Failed to serialize " .. context_for_error .. ". Error: " .. tostring(serialized_data)) return false end
  104.     local handle = fs.open(path, "w")
  105.     if not handle then error_state("Cannot open " .. path .. " for writing (" .. context_for_error .. ").") return false end
  106.     handle.write(serialized_data)
  107.     handle.close()
  108.     print(context_for_error .. " saved to " .. path)
  109.     return true
  110. end
  111.  
  112. local function load_disk_data()
  113.     -- Loads and validates the disk inventory from DISK_DATA_FILE.
  114.     local data = load_json_file(DISK_DATA_FILE, "disk inventory data")
  115.     if not data then
  116.         disk_inventory_data = {} -- Initialize empty if file doesn't exist
  117.         return file_exists(DISK_DATA_FILE) == false -- True if genuinely new, false if load_json_file failed on existing file
  118.     end
  119.  
  120.     disk_inventory_data = data
  121.     -- Validate each entry
  122.     for i, disk_entry in ipairs(disk_inventory_data) do
  123.         if not disk_entry.slot_number then
  124.             error_state("Corrupt " .. DISK_DATA_FILE .. ": 'slot_number' missing for disk entry '"..tostring(disk_entry.user_given_name).."'.")
  125.             return false
  126.         end
  127.         disk_entry.slot_number = tonumber_strict(disk_entry.slot_number, "disk_entry.slot_number for '"..tostring(disk_entry.user_given_name).."' in " .. DISK_DATA_FILE)
  128.  
  129.         if disk_entry.slot_number == TEMP_SLOT_FOR_STAGING_AND_SUCKING then
  130.             error_state("Corrupt " .. DISK_DATA_FILE .. ": Disk '" .. tostring(disk_entry.user_given_name) .. "' is registered to the TEMP_SLOT (" .. TEMP_SLOT_FOR_STAGING_AND_SUCKING .. "). This slot is reserved for utility.")
  131.             return false
  132.         end
  133.         if disk_entry.slot_number < 1 or disk_entry.slot_number > MAX_REGISTERED_DISKS then
  134.             error_state("Corrupt " .. DISK_DATA_FILE .. ": Disk '" .. tostring(disk_entry.user_given_name) .. "' is registered to invalid slot " .. disk_entry.slot_number .. ". Valid slots are 1-" .. MAX_REGISTERED_DISKS ..".")
  135.             return false
  136.         end
  137.         -- Further validation (e.g., for user_given_name, nbt presence) could be added here.
  138.     end
  139.  
  140.     if #disk_inventory_data > MAX_REGISTERED_DISKS then
  141.         error_state("Corrupt " .. DISK_DATA_FILE .. ": Contains " .. #disk_inventory_data .. " disk entries, but maximum allowed is " .. MAX_REGISTERED_DISKS .. ".")
  142.         return false
  143.     end
  144.     return true
  145. end
  146.  
  147. local function save_disk_data()
  148.     -- Saves the current disk_inventory_data to DISK_DATA_FILE.
  149.     if #disk_inventory_data > MAX_REGISTERED_DISKS then
  150.         -- This check is crucial to prevent saving an oversized inventory.
  151.         error_state("Attempted to save disk data with " .. #disk_inventory_data .. " entries, exceeding maximum of " .. MAX_REGISTERED_DISKS .. ". Critical error, data not saved.")
  152.         return false
  153.     end
  154.     return save_json_file(DISK_DATA_FILE, disk_inventory_data, "Disk inventory data")
  155. end
  156.  
  157. local function compare_nbt_data(nbt1, nbt2)
  158.     -- Compares two NBT data tables by serializing them to JSON strings.
  159.     if nbt1 == nil and nbt2 == nil then return true end -- Both nil means they are the same
  160.     if type(nbt1) ~= type(nbt2) then return false end   -- Different types means not the same
  161.  
  162.     if type(nbt1) == "table" then -- If they are tables, compare their JSON string representations
  163.         local s_nbt1_ok, s_nbt1 = pcall(textutils.serialiseJSON, nbt1)
  164.         local s_nbt2_ok, s_nbt2 = pcall(textutils.serialiseJSON, nbt2)
  165.         if s_nbt1_ok and s_nbt2_ok then
  166.             return s_nbt1 == s_nbt2
  167.         else
  168.             print("Warning: Could not serialize NBT data for comparison. Assuming not equal.")
  169.             return false -- If serialization fails, cannot reliably compare; assume not equal.
  170.         end
  171.     end
  172.     return nbt1 == nbt2 -- For non-table types (though NBT usually is table or nil)
  173. end
  174.  
  175. local function find_disk_by_slot(slot_num_val) -- Searches registered storage slots (1-MAX_REGISTERED_DISKS)
  176.     local slot_num = tonumber_strict(slot_num_val, "find_disk_by_slot argument")
  177.     if slot_num == TEMP_SLOT_FOR_STAGING_AND_SUCKING then return nil end -- Registered disks are not in the temp slot.
  178.     for i, disk_entry in ipairs(disk_inventory_data) do
  179.         if disk_entry.slot_number == slot_num then
  180.             return disk_entry, i -- Return the disk entry table and its index in disk_inventory_data
  181.         end
  182.     end
  183.     return nil -- Not found
  184. end
  185.  
  186. local function find_disk_by_user_name(user_name)
  187.     -- Finds a registered disk by its user-given name (case-insensitive).
  188.     if type(user_name) ~= "string" then return nil end
  189.     local lower_user_name = user_name:lower()
  190.     for i, disk_entry in ipairs(disk_inventory_data) do
  191.         if type(disk_entry.user_given_name) == "string" and disk_entry.user_given_name:lower() == lower_user_name then
  192.             return disk_entry, i -- Return the disk entry table and its index
  193.         end
  194.     end
  195.     return nil -- Not found
  196. end
  197.  
  198. local function find_disk_lent_to_operator(operator_id_canonical)
  199.     -- Finds a disk that is currently marked as lent to a specific operator.
  200.     for i, disk_entry in ipairs(disk_inventory_data) do
  201.         if disk_entry.lent_to_operator_id == operator_id_canonical then
  202.             return disk_entry, i -- Return the disk entry table and its index
  203.         end
  204.     end
  205.     return nil -- Not found
  206. end
  207.  
  208. local function get_next_available_storage_slot()
  209.     -- Finds the first available (unused) slot number between 1 and MAX_REGISTERED_DISKS.
  210.     local used_slots = {}
  211.     for _, disk_entry in ipairs(disk_inventory_data) do
  212.         used_slots[disk_entry.slot_number] = true
  213.     end
  214.     for i = 1, MAX_REGISTERED_DISKS do
  215.         if not used_slots[i] then
  216.             return i -- Return the first unused slot number
  217.         end
  218.     end
  219.     return nil -- No storage slot available
  220. end
  221.  
  222. local function start_timed_action(delay_val, action_type, data_for_action)
  223.     -- Starts a timer for a delayed action and stores action details.
  224.     local delay = tonumber_strict(delay_val, "start_timed_action delay")
  225.     local timer_id = os.startTimer(delay)
  226.     if not _G.active_timers then _G.active_timers = {} end -- Ensure the global table for timers exists
  227.     _G.active_timers[timer_id] = {
  228.         type = action_type,
  229.         data = data_for_action,
  230.         attempts_left = data_for_action.max_attempts or 20 -- Default max attempts for retriable actions
  231.     }
  232.     print("Timer " .. timer_id .. " started for action '" .. action_type .. "' in " .. delay .. "s.")
  233.     return timer_id
  234. end
  235. --#endregion
  236.  
  237. --#region Name Initialization
  238. local function initialize_helper_name()
  239.     -- Loads the helper turtle's name from HELPER_NAME_FILE or prompts the user if not found/invalid.
  240.     local name_data = load_json_file(HELPER_NAME_FILE, "helper name data")
  241.     local valid_name_loaded = false
  242.  
  243.     if name_data and type(name_data.name) == "string" then
  244.         -- Validate the "CubeXXX" format (1-999)
  245.         local num_part_str = name_data.name:match("^Cube([1-9]%d*)$") -- Number must start with 1-9
  246.         if num_part_str then
  247.             local num = tonumber(num_part_str)
  248.             if num and num >= 1 and num <= 999 then
  249.                 helper_turtle_name = name_data.name
  250.                 print("Helper name loaded: " .. helper_turtle_name)
  251.                 valid_name_loaded = true
  252.             end
  253.         end
  254.     end
  255.  
  256.     if not valid_name_loaded then
  257.         if name_data then print("Invalid name data found in " .. HELPER_NAME_FILE .. ". Expected format: Cube<1-999>.") else print(HELPER_NAME_FILE .. " not found.") end
  258.         print("Please assign a number (1-999) for this Helper Cube.")
  259.         local num_str_input, num_input
  260.         local initial_prompt_prefix = HELPER_CHAT_BRACKET_COLOR .. "[Helper]" .. "&r" -- Static prefix for this one-time prompt
  261.  
  262.         repeat
  263.             term.write(initial_prompt_prefix .. " Enter number (1-999): ")
  264.             num_str_input = read()
  265.             num_input = tonumber(num_str_input)
  266.             if not (num_input and num_input >= 1 and num_input <= 999 and math.floor(num_input) == num_input) then
  267.                 print("Invalid input. Please enter a whole number between 1 and 999.")
  268.                 num_input = nil -- Reset to continue loop
  269.             end
  270.         until num_input
  271.  
  272.         helper_turtle_name = "Cube" .. num_input
  273.         if not save_json_file(HELPER_NAME_FILE, {name = helper_turtle_name}, "Helper name data") then
  274.             error_state("Critical: Failed to save helper name to " .. HELPER_NAME_FILE .. " after initial setup!")
  275.         end
  276.         print("Helper name set to: " .. helper_turtle_name)
  277.     end
  278. end
  279. --#endregion
  280.  
  281. --#region Startup and Inventory Audit
  282. local function verify_inventory_on_startup()
  283.     -- Checks the turtle's physical inventory against the registered disk data.
  284.     print("Verifying inventory (" .. helper_turtle_name .. ")...")
  285.     local all_registered_slots_accounted_for = {} -- Keeps track of which storage slots have a verified registered disk
  286.  
  287.     -- Step 1: Verify all registered disks (expected in slots 1-MAX_REGISTERED_DISKS)
  288.     for _, disk_reg_entry in ipairs(disk_inventory_data) do
  289.         local slot_num = tonumber_strict(disk_reg_entry.slot_number, "disk_reg_entry.slot_number in verify_inventory")
  290.  
  291.         -- Double-check that no registered disk is in the TEMP_SLOT (should be caught by load_disk_data too)
  292.         if slot_num == TEMP_SLOT_FOR_STAGING_AND_SUCKING then
  293.             error_state("Startup Error: Disk '" .. disk_reg_entry.user_given_name .. "' is registered to the TEMP_SLOT (" .. slot_num .. "). This slot is reserved for utility purposes.")
  294.         end
  295.         all_registered_slots_accounted_for[slot_num] = true
  296.         local item_details_in_slot = turtle.getItemDetail(slot_num)
  297.  
  298.         if disk_reg_entry.lent_to_operator_id and disk_reg_entry.lent_to_operator_id ~= "" then
  299.             -- Disk is marked as lent, so the slot should be empty.
  300.             if item_details_in_slot then
  301.                 error_state("Startup Error: Disk '" .. disk_reg_entry.user_given_name .. "' in slot " .. slot_num .. " is physically present but its registration indicates it is lent to operator '" .. disk_reg_entry.lent_to_operator_id .. "'.")
  302.             end
  303.         else
  304.             -- Disk should be present in its registered slot.
  305.             if not item_details_in_slot then
  306.                 error_state("Startup Error: Registered disk '" .. disk_reg_entry.user_given_name .. "' is missing from its designated slot " .. slot_num .. ".")
  307.             end
  308.             -- Verify NBT data if disk is present.
  309.             if not compare_nbt_data(item_details_in_slot.nbt, disk_reg_entry.nbt) then
  310.                 error_state("Startup Error: NBT data mismatch for registered disk '" .. disk_reg_entry.user_given_name .. "' in slot " .. slot_num .. ".")
  311.             end
  312.         end
  313.     end
  314.  
  315.     -- Step 2: Check all physical storage slots (1-MAX_REGISTERED_DISKS) for any unregistered items.
  316.     for i = 1, MAX_REGISTERED_DISKS do
  317.         local item_details_in_slot = turtle.getItemDetail(i)
  318.         if item_details_in_slot and not all_registered_slots_accounted_for[i] then
  319.             -- An item exists in a storage slot that has no corresponding registration.
  320.             error_state("Startup Error: Unregistered item '" .. item_details_in_slot.name .. "' found in storage slot " .. i .. ". All items in storage slots 1-" .. MAX_REGISTERED_DISKS .. " must be registered.")
  321.         end
  322.     end
  323.    
  324.     -- Step 3: Check the TEMP_SLOT_FOR_STAGING_AND_SUCKING (slot 16).
  325.     -- This slot should ideally be empty on startup. If not, it might be leftover from a crash.
  326.     local item_in_temp_slot = turtle.getItemDetail(TEMP_SLOT_FOR_STAGING_AND_SUCKING)
  327.     if item_in_temp_slot then
  328.         print("Warning: Item '" .. item_in_temp_slot.name .. "' found in TEMP_SLOT (" .. TEMP_SLOT_FOR_STAGING_AND_SUCKING .. ") on startup. This slot should ideally be clear. Manual check recommended.")
  329.         -- Future enhancement: Could attempt to identify and return unknown items from temp slot to chest.
  330.     end
  331.  
  332.     print("Inventory verification successful for " .. helper_turtle_name .. ".")
  333.     return true
  334. end
  335.  
  336. local function initialize()
  337.     -- Main initialization sequence for the turtle.
  338.     print("Helper Turtle Initializing...")
  339.     initialize_helper_name() -- Sets helper_turtle_name, crucial for logging and chat.
  340.     _G.active_timers = {}    -- Initialize global table for managing active timers.
  341.  
  342.     chat_box = peripheral.find(CHAT_MODULE_NAME)
  343.     if not chat_box then error_state("Chat Box peripheral ('" .. CHAT_MODULE_NAME .. "') not found.") end
  344.     print("Chat Box peripheral found for " .. helper_turtle_name .. ".")
  345.  
  346.     if not load_disk_data() then
  347.         -- load_disk_data calls error_state on critical failure or returns false.
  348.         -- If it returns false without error_state, it means file didn't exist and was initialized empty (which is fine).
  349.         -- If error_state was called, script would halt there. This check ensures we don't proceed if load truly failed.
  350.         if file_exists(DISK_DATA_FILE) then -- If file existed but load failed
  351.              error_state("Failed to correctly load existing disk data. Please check " .. DISK_DATA_FILE)
  352.         end
  353.         -- If we are here, and load_disk_data returned false, it means disk_inventory_data is empty.
  354.     end
  355.     print("Disk data loaded for " .. helper_turtle_name .. ". " .. #disk_inventory_data .. " of " .. MAX_REGISTERED_DISKS .. " maximum disks registered.")
  356.  
  357.     if not verify_inventory_on_startup() then
  358.         -- verify_inventory_on_startup calls error_state on failure.
  359.         return false
  360.     end
  361.  
  362.     print("Initialization complete. " .. helper_turtle_name .. " is operational.")
  363.     send_chat_message("Online and ready.")
  364.     return true
  365. end
  366. --#endregion
  367.  
  368. --#region Disk Interaction Logic (Providing and Retrieving Disks)
  369. local function attempt_suck_and_verify(expected_disk_entry, operation_context)
  370.     -- This function attempts to suck a disk from the chest (front),
  371.     -- verifies its NBT against expected_disk_entry.nbt,
  372.     -- and if it matches, moves it from the TEMP_SLOT to the disk's registered storage slot.
  373.     print(helper_turtle_name .. ": Attempting to suck disk for '" .. operation_context .. "' (expecting '" .. expected_disk_entry.user_given_name .. "') into temp slot " .. TEMP_SLOT_FOR_STAGING_AND_SUCKING)
  374.     local original_selected_slot = tonumber_strict(turtle.getSelectedSlot(), "original_selected_slot in attempt_suck")
  375.  
  376.     turtle.select(tonumber_strict(TEMP_SLOT_FOR_STAGING_AND_SUCKING, "suck attempt select temp slot"))
  377.     if turtle.getItemCount(TEMP_SLOT_FOR_STAGING_AND_SUCKING) > 0 then
  378.         print("Warning (" .. helper_turtle_name .. "): TEMP_SLOT ("..TEMP_SLOT_FOR_STAGING_AND_SUCKING..") is not empty before sucking. Current item will be overwritten if suck is successful.")
  379.     end
  380.  
  381.     if turtle.suck() then -- Suck from front into the selected TEMP_SLOT
  382.         local sucked_item_detail = turtle.getItemDetail(TEMP_SLOT_FOR_STAGING_AND_SUCKING)
  383.         if not sucked_item_detail then
  384.             print(helper_turtle_name .. ": Sucked something but failed to get its details (from temp slot " .. TEMP_SLOT_FOR_STAGING_AND_SUCKING .. "). No action taken with sucked item.")
  385.             turtle.select(original_selected_slot)
  386.             return false -- Can't verify if we can't get details
  387.         end
  388.  
  389.         -- NBT Verification
  390.         if compare_nbt_data(sucked_item_detail.nbt, expected_disk_entry.nbt) then
  391.             local target_storage_slot = tonumber_strict(expected_disk_entry.slot_number, "target_storage_slot for retrieved disk")
  392.  
  393.             -- Sanity check: Registered disks should NEVER have TEMP_SLOT as their target_storage_slot.
  394.             if target_storage_slot == TEMP_SLOT_FOR_STAGING_AND_SUCKING then
  395.                  error_state("CRITICAL LOGIC ERROR (" .. helper_turtle_name .. "): Retrieved disk '"..expected_disk_entry.user_given_name.."' NBT matches, but its target storage slot IS the TEMP_SLOT. Registered disks cannot be stored in TEMP_SLOT.")
  396.             end
  397.  
  398.             print(helper_turtle_name .. ": NBT MATCH for '" .. expected_disk_entry.user_given_name .. "'. Moving from temp slot " .. TEMP_SLOT_FOR_STAGING_AND_SUCKING .. " to target slot " .. target_storage_slot)
  399.  
  400.             -- Ensure target storage slot is empty before transferring
  401.             turtle.select(target_storage_slot)
  402.             if turtle.getItemCount(target_storage_slot) > 0 then
  403.                 local item_in_target_details = turtle.getItemDetail(target_storage_slot)
  404.                 error_state("CRITICAL (" .. helper_turtle_name .. "): Target storage slot " .. target_storage_slot .. " for '" .. expected_disk_entry.user_given_name ..
  405.                             "' is ALREADY OCCUPIED by '" .. (item_in_target_details and item_in_target_details.name or "UNKNOWN ITEM") .. "' before transfer from temp slot!")
  406.             end
  407.  
  408.             -- Perform the transfer from TEMP_SLOT to target_storage_slot
  409.             turtle.select(tonumber_strict(TEMP_SLOT_FOR_STAGING_AND_SUCKING, "transfer select temp slot"))
  410.             if not turtle.transferTo(target_storage_slot, sucked_item_detail.count) then
  411.                 error_state("CRITICAL (" .. helper_turtle_name .. "): Failed to transfer retrieved disk '" .. expected_disk_entry.user_given_name .. "' from temp slot " .. TEMP_SLOT_FOR_STAGING_AND_SUCKING .. " to target slot " .. target_storage_slot)
  412.             end
  413.            
  414.             -- Update disk registration: no longer lent out
  415.             expected_disk_entry.lent_to_operator_id = nil
  416.             if not save_disk_data() then
  417.                  error_state("CRITICAL (" .. helper_turtle_name .. "): Failed to save disk data after retrieving disk '"..expected_disk_entry.user_given_name .."'.")
  418.             end
  419.             send_chat_message("Successfully retrieved and stored disk '" .. expected_disk_entry.user_given_name .. "' (" .. operation_context .. ").")
  420.             turtle.select(original_selected_slot)
  421.             return true
  422.         else
  423.             -- NBT Mismatch: Sucked item is not the expected disk
  424.             send_chat_message("WARNING: Retrieved an item into temp slot " .. TEMP_SLOT_FOR_STAGING_AND_SUCKING .. " while expecting '" .. expected_disk_entry.user_given_name ..
  425.                               "', but NBT MISMATCH (" .. operation_context .. "). Returning item to chest.", true)
  426.             print(helper_turtle_name .. ": NBT Mismatch. Expected NBT for '" .. expected_disk_entry.user_given_name .. "'. Got item: '" .. sucked_item_detail.name .. "' with NBT: " .. textutils.serialize(sucked_item_detail.nbt or {}))
  427.            
  428.             -- Return the incorrect item to the chest
  429.             turtle.select(tonumber_strict(TEMP_SLOT_FOR_STAGING_AND_SUCKING, "NBT mismatch return select temp slot"))
  430.             turtle.drop()
  431.             turtle.select(original_selected_slot)
  432.             return false
  433.         end
  434.     end
  435.  
  436.     -- turtle.suck() failed, nothing was picked up from the chest
  437.     print(helper_turtle_name .. ": Suck attempt failed for '".. operation_context .."'; chest might be empty or item not accessible.")
  438.     turtle.select(original_selected_slot)
  439.     return false
  440. end
  441.  
  442. local function handle_provide_disk(disk_to_provide_entry, operator_id_canonical, operator_name_str)
  443.     -- This function takes a registered disk entry, drops it from its storage slot into the chest (front),
  444.     -- and marks it as lent to the specified operator.
  445.     if disk_to_provide_entry.lent_to_operator_id and disk_to_provide_entry.lent_to_operator_id ~= "" then
  446.         send_chat_message("Disk '" .. disk_to_provide_entry.user_given_name .. "' is already lent to '" .. disk_to_provide_entry.lent_to_operator_id .. "'. Cannot provide to '"..operator_name_str.."'.")
  447.         return
  448.     end
  449.  
  450.     local storage_slot_to_use = tonumber_strict(disk_to_provide_entry.slot_number, "storage_slot_to_use in handle_provide_disk")
  451.     -- Sanity check: Registered disks should not be in the TEMP_SLOT for providing.
  452.     if storage_slot_to_use == TEMP_SLOT_FOR_STAGING_AND_SUCKING then
  453.         error_state("LOGIC ERROR (" .. helper_turtle_name .. "): Attempted to provide disk from TEMP_SLOT. Registered disks are stored in slots 1-" .. MAX_REGISTERED_DISKS .. ".")
  454.     end
  455.  
  456.     local original_selected_slot = tonumber_strict(turtle.getSelectedSlot(), "original_selected_slot in handle_provide_disk")
  457.     turtle.select(storage_slot_to_use)
  458.  
  459.     if turtle.getItemCount(storage_slot_to_use) == 0 then
  460.         error_state("CRITICAL (" .. helper_turtle_name .. "): Storage slot " .. storage_slot_to_use .. " for disk '" .. disk_to_provide_entry.user_given_name .. "' is empty, but it should be here for providing.")
  461.     end
  462.    
  463.     print(helper_turtle_name .. ": Providing disk '" .. disk_to_provide_entry.user_given_name .. "' (from slot " .. storage_slot_to_use .. ") for Operator '" .. operator_name_str .. "' into chest (front).")
  464.     if turtle.drop() then -- Drop from selected slot (storage_slot_to_use) into the chest in front
  465.         disk_to_provide_entry.lent_to_operator_id = operator_id_canonical
  466.         if not save_disk_data() then
  467.             error_state("CRITICAL (" .. helper_turtle_name .. "): Failed to save disk data after marking disk '"..disk_to_provide_entry.user_given_name .."' as lent.")
  468.         end
  469.         print(helper_turtle_name .. ": Disk '" .. disk_to_provide_entry.user_given_name .. "' placed in chest for Operator '" .. operator_name_str .. "'. Marked as lent.")
  470.         -- Schedule a reclaim check in case the operator doesn't pick it up
  471.         start_timed_action(7, "reclaim_provided_disk", { disk_user_name = disk_to_provide_entry.user_given_name, operator_id = operator_id_canonical, max_attempts = 1 })
  472.     else
  473.         send_chat_message("ERROR: Failed to drop disk '" .. disk_to_provide_entry.user_given_name .. "' into chest for Operator '" .. operator_name_str .. "'.", true)
  474.     end
  475.     turtle.select(original_selected_slot)
  476. end
  477. --#endregion
  478.  
  479. --#region Chat Command Processing (Operator and New Disk Commands)
  480. -- Handles @spatial load/unload commands from operator turtles
  481. local function process_operator_load_command(operator_name_str, requested_disk_user_name)
  482.     local operator_id_canonical = canonicalize_name(operator_name_str)
  483.     print(helper_turtle_name .. ": Processing Operator Load: Operator='" .. operator_name_str .. "' (" .. operator_id_canonical .. "), Requested Disk='" .. requested_disk_user_name .. "'")
  484.  
  485.     -- Check if this operator was borrowing another disk that should now be returned
  486.     local disk_operator_should_return, _ = find_disk_lent_to_operator(operator_id_canonical)
  487.     if disk_operator_should_return then
  488.         send_chat_message("Awaiting return of disk '" .. disk_operator_should_return.user_given_name .. "' from Operator " .. operator_name_str .. ".")
  489.         start_timed_action(3, "expect_return_from_operator", {
  490.             disk_user_name = disk_operator_should_return.user_given_name,
  491.             operator_id = operator_id_canonical,
  492.             reason = "Operator " .. operator_name_str .. " is loading a new disk",
  493.             max_attempts = 20, -- Will try to suck for ~2 seconds (20 * 0.1s interval)
  494.             suck_interval = 0.1
  495.         })
  496.     end
  497.  
  498.     -- Check if we have the disk the operator is requesting to load
  499.     local disk_to_provide, _ = find_disk_by_user_name(requested_disk_user_name)
  500.     if disk_to_provide then
  501.         handle_provide_disk(disk_to_provide, operator_id_canonical, operator_name_str)
  502.     else
  503.         -- Not an error, another helper might have it. Just log locally.
  504.         print(helper_turtle_name .. ": We do not have the requested disk '" .. requested_disk_user_name .. "' for Operator " .. operator_name_str .. ".")
  505.     end
  506. end
  507.  
  508. local function process_operator_unload_command(operator_name_str)
  509.     local operator_id_canonical = canonicalize_name(operator_name_str)
  510.     print(helper_turtle_name .. ": Processing Operator Unload: Operator='" .. operator_name_str .. "' (" .. operator_id_canonical .. ")")
  511.  
  512.     -- Check if the unloading operator was borrowing a disk from us
  513.     local disk_being_unloaded, _ = find_disk_lent_to_operator(operator_id_canonical)
  514.     if disk_being_unloaded then
  515.         send_chat_message("Awaiting return of disk '" .. disk_being_unloaded.user_given_name .. "' from Operator " .. operator_name_str .. ".")
  516.         start_timed_action(3, "expect_return_from_operator", {
  517.             disk_user_name = disk_being_unloaded.user_given_name,
  518.             operator_id = operator_id_canonical,
  519.             reason = "Operator " .. operator_name_str .. " is unloading a disk",
  520.             is_critical_return = true, -- NBT mismatch on this return is more serious
  521.             max_attempts = 20,
  522.             suck_interval = 0.1
  523.         })
  524.     else
  525.         -- Not an error, the operator might have unloaded a disk they got from elsewhere or had nothing.
  526.         print(helper_turtle_name .. ": Operator " .. operator_name_str .. " unloaded, but they were not borrowing any disk from us.")
  527.     end
  528. end
  529.  
  530. -- Handles the '@newDisk register start [slot_num]' command
  531. local function handle_new_disk_register_start(optional_slot_num_str)
  532.     if pending_registration_info then
  533.         send_chat_message("A disk registration process is already active. Please complete it with the 'end' command or restart the turtle to cancel.", true)
  534.         return
  535.     end
  536.  
  537.     local specified_storage_slot = nil
  538.     if optional_slot_num_str and optional_slot_num_str ~= "" then
  539.         specified_storage_slot = tonumber(optional_slot_num_str)
  540.         if not (specified_storage_slot and specified_storage_slot >= 1 and specified_storage_slot <= MAX_REGISTERED_DISKS) then
  541.             send_chat_message("Invalid slot number '" .. optional_slot_num_str .. "' provided for 'start'. Must be a storage slot between 1 and " .. MAX_REGISTERED_DISKS .. ".", true)
  542.             return
  543.         end
  544.     end
  545.  
  546.     if specified_storage_slot then
  547.         -- User specified a storage slot (1-MAX_REGISTERED_DISKS) to check for coal.
  548.         print(helper_turtle_name .. ": Checking specified slot " .. specified_storage_slot .. ".")
  549.         local item_in_spec_slot = turtle.getItemDetail(specified_storage_slot)
  550.         if item_in_spec_slot and item_in_spec_slot.name == "minecraft:coal_block" then
  551.             local existing_reg_at_spec_slot, _ = find_disk_by_slot(specified_storage_slot)
  552.             if existing_reg_at_spec_slot then
  553.                 send_chat_message("Slot " .. specified_storage_slot .. " is already registered to disk '" .. existing_reg_at_spec_slot.user_given_name .. "'. To overwrite this slot's registration with a new disk use force'.")
  554.                 pending_registration_info = {
  555.                     staging_slot = specified_storage_slot,       -- The new disk will replace coal here.
  556.                     target_slot_if_direct = specified_storage_slot, -- This slot is the final destination for the new disk.
  557.                     existing_reg_at_staging_slot = existing_reg_at_spec_slot -- Store info about what's being overwritten.
  558.                 }
  559.             else -- Coal is in a specified, available (empty and unregistered) storage slot.
  560.                 send_chat_message("Slot " .. specified_storage_slot .. " is ready.")
  561.                 pending_registration_info = {
  562.                     staging_slot = specified_storage_slot,
  563.                     target_slot_if_direct = specified_storage_slot,
  564.                     existing_reg_at_staging_slot = nil
  565.                 }
  566.             end
  567.         end
  568.     else
  569.         -- No specific slot provided by user, search for coal block, prioritizing utility slot.
  570.         print(helper_turtle_name .. ": No slot specified")
  571.         local item_in_utility_slot = turtle.getItemDetail(TEMP_SLOT_FOR_STAGING_AND_SUCKING)
  572.         if item_in_utility_slot and item_in_utility_slot.name == "minecraft:coal_block" then
  573.             send_chat_message("Utility slot " .. TEMP_SLOT_FOR_STAGING_AND_SUCKING .. " is ready.")
  574.             pending_registration_info = {
  575.                 staging_slot = TEMP_SLOT_FOR_STAGING_AND_SUCKING,
  576.                 target_slot_if_direct = nil, -- Target will be the next available storage slot, not the utility slot.
  577.                 existing_reg_at_staging_slot = nil
  578.             }
  579.         else
  580.             -- If not in utility slot, search storage slots 1-MAX_REGISTERED_DISKS.
  581.             local found_coal_in_storage_slot_num = nil
  582.             for i = 1, MAX_REGISTERED_DISKS do
  583.                 local item_in_scan_slot = turtle.getItemDetail(i)
  584.                 if item_in_scan_slot and item_in_scan_slot.name == "minecraft:coal_block" then
  585.                     found_coal_in_storage_slot_num = i
  586.                     break
  587.                 end
  588.             end
  589.  
  590.             if found_coal_in_storage_slot_num then
  591.                 local existing_reg_at_found_slot, _ = find_disk_by_slot(found_coal_in_storage_slot_num)
  592.                 if existing_reg_at_found_slot then
  593.                      send_chat_message("Storage slot " .. found_coal_in_storage_slot_num .. " is currently registered to disk '" .. existing_reg_at_found_slot.user_given_name .. "'. To overwrite this slot's registration use force.")
  594.                      pending_registration_info = {
  595.                          staging_slot = found_coal_in_storage_slot_num,
  596.                          target_slot_if_direct = found_coal_in_storage_slot_num,
  597.                          existing_reg_at_staging_slot = existing_reg_at_found_slot
  598.                      }
  599.                 else -- Coal found in an available (empty, unregistered) storage slot.
  600.                     send_chat_message("Storage slot " .. found_coal_in_storage_slot_num .. "is ready.")
  601.                     pending_registration_info = {
  602.                         staging_slot = found_coal_in_storage_slot_num,
  603.                         target_slot_if_direct = found_coal_in_storage_slot_num,
  604.                         existing_reg_at_staging_slot = nil
  605.                     }
  606.                 end
  607.             end
  608.         end
  609.     end
  610. end
  611.  
  612. -- Handles the '@newDisk register end <NewDiskName> [force]' command
  613. local function handle_new_disk_register_end(new_disk_name_argument, force_str_argument)
  614.     -- Step 0: Basic Validations
  615.     if not pending_registration_info then
  616.         return
  617.     end
  618.  
  619.     local force_override_active = (type(force_str_argument) == "string" and force_str_argument:lower() == "force")
  620.     local staging_slot_info = pending_registration_info -- Keep a reference
  621.     pending_registration_info = nil -- Crucial: Consume the pending state immediately to prevent re-entry
  622.  
  623.     local item_in_staging_slot = turtle.getItemDetail(staging_slot_info.staging_slot)
  624.     if not item_in_staging_slot then
  625.         error_state("Disk Registration End Error: The staging slot " .. staging_slot_info.staging_slot .. " is now empty. Expected the new disk to be here.")
  626.         return
  627.     end
  628.     if item_in_staging_slot.name == "minecraft:coal_block" then
  629.         error_state("Disk Registration End Error: Staging slot " .. staging_slot_info.staging_slot .. " not empty.")
  630.         return
  631.     end
  632.     local new_disk_nbt_from_item_in_staging = item_in_staging_slot.nbt
  633.  
  634.  
  635.     -- Step 1: Determine the type of registration and the final target storage slot.
  636.     local final_target_storage_slot = nil
  637.     local existing_registration_to_update_idx = nil -- If we are overwriting an existing registration by its name.
  638.     local old_registration_to_remove_idx = nil    -- If we are overwriting a slot that had a *different* named disk.
  639.     local old_disk_name_being_replaced = nil      -- Name of the disk being physically replaced/unregistered.
  640.  
  641.     -- Scenario A: Does the <NewDiskName> conflict with an existing registered disk name?
  642.     local existing_reg_if_name_matches, idx_if_name_matches = find_disk_by_user_name(new_disk_name_argument)
  643.  
  644.     if existing_reg_if_name_matches then
  645.         -- The provided new_disk_name_argument is already in use.
  646.         if force_override_active then
  647.             send_chat_message("The name '" .. new_disk_name_argument .. "' is already registered. 'force' is active: Proceeding to overwrite the existing registration for '" .. new_disk_name_argument .. "' at its current slot " .. existing_reg_if_name_matches.slot_number .. ".", false)
  648.             final_target_storage_slot = existing_reg_if_name_matches.slot_number -- The new disk will go into the old disk's slot.
  649.             existing_registration_to_update_idx = idx_if_name_matches         -- We will update this entry in disk_inventory_data.
  650.             old_disk_name_being_replaced = existing_reg_if_name_matches.user_given_name -- This is the disk being replaced.
  651.         else
  652.             send_chat_message("The disk name '" .. new_disk_name_argument .. "' is already registered for a disk at slot " .. existing_reg_if_name_matches.slot_number .. ". If you intend to replace it, use the 'force' argument. Registration aborted. Returning the disk from staging slot " .. staging_slot_info.staging_slot .. " to the chest.", true)
  653.             turtle.select(staging_slot_info.staging_slot) turtle.drop()
  654.             return
  655.         end
  656.     -- Scenario B: Was the staging_slot (where coal was, now new disk is) already registered to a *different* disk?
  657.     elseif staging_slot_info.existing_reg_at_staging_slot then
  658.         -- staging_slot_info.existing_reg_at_staging_slot contains the data of the disk that *was* registered to staging_slot_info.staging_slot
  659.         if force_override_active then
  660.             -- User wants to overwrite the disk that was in staging_slot_info.staging_slot with the new disk (and new_disk_name_argument).
  661.             send_chat_message("The staging slot " .. staging_slot_info.staging_slot .. " was previously registered to disk '" .. staging_slot_info.existing_reg_at_staging_slot.user_given_name .. "'. 'force' is active: Overwriting this slot with the new disk named '" .. new_disk_name_argument .. "'.", false)
  662.             final_target_storage_slot = staging_slot_info.staging_slot -- The new disk stays/goes here.
  663.             old_disk_name_being_replaced = staging_slot_info.existing_reg_at_staging_slot.user_given_name
  664.             -- We need to find the index of this old registration to remove it.
  665.             local _, old_idx = find_disk_by_user_name(old_disk_name_being_replaced)
  666.             if old_idx then registration_to_remove_due_to_slot_overwrite_idx = old_idx
  667.             else error_state("Logic Error: Could not find index for old disk '"..old_disk_name_being_replaced.."' during slot overwrite.") return end -- Should not happen
  668.         else
  669.             send_chat_message("The staging slot " .. staging_slot_info.staging_slot .. " is already registered to disk '" .. staging_slot_info.existing_reg_at_staging_slot.user_given_name .. "'. To overwrite this slot, use the 'force' argument. Registration aborted. Returning the disk from staging slot " .. staging_slot_info.staging_slot .. " to the chest.", true)
  670.             turtle.select(staging_slot_info.staging_slot) turtle.drop()
  671.             return
  672.         end
  673.     -- Scenario C: This is a completely new disk registration.
  674.     else
  675.         if #disk_inventory_data >= MAX_REGISTERED_DISKS then
  676.             send_chat_message("Cannot register new disk '" .. new_disk_name_argument .. "': Maximum capacity of " .. MAX_REGISTERED_DISKS .. " disks has been reached. Returning the disk from staging slot " .. staging_slot_info.staging_slot .. " to the chest.", true)
  677.             turtle.select(staging_slot_info.staging_slot) turtle.drop()
  678.             return
  679.         end
  680.  
  681.         if staging_slot_info.target_slot_if_direct then
  682.             -- The user placed coal in an empty, unregistered storage slot (1-MAX_REGISTERED_DISKS).
  683.             final_target_storage_slot = staging_slot_info.target_slot_if_direct
  684.         else
  685.             -- The user placed coal in the utility slot (16), or no direct target was set.
  686.             -- Find the next available storage slot (1-MAX_REGISTERED_DISKS).
  687.             final_target_storage_slot = get_next_available_storage_slot()
  688.             if not final_target_storage_slot then
  689.                 -- This should ideally be caught by the #disk_inventory_data check above.
  690.                 error_state("Logic Error: Disk capacity not full, but no available storage slot was found for the new disk '" .. new_disk_name_argument .. "'.")
  691.                 return
  692.             end
  693.         end
  694.     end
  695.  
  696.     -- Step 2: Perform physical disk operations.
  697.     print(helper_turtle_name .. ": Finalizing registration for '" .. new_disk_name_argument .. "'. Staging Slot: " .. staging_slot_info.staging_slot .. ", Final Target Storage Slot: " .. final_target_storage_slot .. (old_disk_name_being_replaced and ", Overwriting registration of: '"..old_disk_name_being_replaced.."'" or ""))
  698.  
  699.     -- If final_target_storage_slot currently holds an old disk (because we are overwriting), clear that physical disk.
  700.     if old_disk_name_being_replaced then
  701.         turtle.select(final_target_storage_slot)
  702.         if turtle.getItemCount(final_target_storage_slot) > 0 then
  703.             -- Ensure the item in the target slot is indeed the one we think we're replacing (optional, good for safety)
  704.             -- local item_details = turtle.getItemDetail(final_target_storage_slot)
  705.             -- if item_details and item_details.name ... (compare with old_disk_name_being_replaced's expected item type if known)
  706.  
  707.             print(helper_turtle_name .. ": Clearing old disk '" .. old_disk_name_being_replaced .. "' from its slot " .. final_target_storage_slot .. " to make way for the new/updated disk.")
  708.             if not turtle.dropUp() then -- Attempt to dispose of it upwards (e.g., into an "obsolete" chest)
  709.                 if not turtle.drop() then -- Otherwise, just drop it in front
  710.                     send_chat_message("WARNING: Failed to automatically clear the old disk from slot " .. final_target_storage_slot .. " during overwrite. Manual cleanup of that slot may be needed before the new disk can be placed if the turtle failed to move it.", true)
  711.                     -- Note: This doesn't stop the registration update, but the physical state might be imperfect.
  712.                 end
  713.             end
  714.         end
  715.     elseif turtle.getItemCount(final_target_storage_slot) > 0 then
  716.          -- This case is for a NEW disk registration where the target slot (determined as free) is somehow not.
  717.          local item_in_final_target_details = turtle.getItemDetail(final_target_storage_slot)
  718.          error_state("Disk Registration End Error (" .. helper_turtle_name .. "): The target storage slot " .. final_target_storage_slot .. " for the new disk '"..new_disk_name_argument.."' is unexpectedly occupied by '"..(item_in_final_target_details and item_in_final_target_details.name or "an unknown item").."'. This slot should have been free.")
  719.          return
  720.     end
  721.  
  722.     -- Move the new disk from the staging_slot to the final_target_storage_slot, if they are different.
  723.     if staging_slot_info.staging_slot ~= final_target_storage_slot then
  724.         turtle.select(tonumber_strict(staging_slot_info.staging_slot, "select staging slot for transfer"))
  725.         if not turtle.transferTo(final_target_storage_slot, item_in_staging_slot.count) then -- item_in_staging_slot was fetched at the start
  726.             error_state("Disk Registration End Error (" .. helper_turtle_name .. "): Failed to move the new disk from staging slot " .. staging_slot_info.staging_slot .. " to its final target storage slot " .. final_target_storage_slot .. ".")
  727.             return
  728.         end
  729.     end
  730.     -- If staging_slot_info.staging_slot IS final_target_storage_slot, the new disk (already verified) is correctly in place.
  731.  
  732.     -- Step 3: Update the disk_inventory_data array.
  733.     local new_disk_registration_data = {
  734.         user_given_name = new_disk_name_argument,
  735.         nbt = new_disk_nbt_from_item_in_staging, -- NBT from the actual disk item in the staging slot
  736.         slot_number = final_target_storage_slot,
  737.         lent_to_operator_id = nil -- New/overwritten disks are not initially lent
  738.     }
  739.  
  740.     if existing_registration_to_update_idx then
  741.         -- This means we are overwriting an existing registration identified by its name.
  742.         disk_inventory_data[existing_registration_to_update_idx] = new_disk_registration_data
  743.         send_chat_message("Successfully updated registration for disk '" .. new_disk_name_argument .. "' in slot " .. final_target_storage_slot .. ".")
  744.     elseif registration_to_remove_due_to_slot_overwrite_idx then
  745.         -- This means we are overwriting a slot that was previously registered to a *different* disk name.
  746.         -- The old registration needs to be removed, and the new one added.
  747.         table.remove(disk_inventory_data, registration_to_remove_due_to_slot_overwrite_idx)
  748.         table.insert(disk_inventory_data, new_disk_registration_data) -- Could also re-sort disk_inventory_data by slot_number if desired.
  749.         send_chat_message("Successfully overwrote slot " .. final_target_storage_slot .. " (which was registered to '" .. old_disk_name_being_replaced .. "') with the new disk '" .. new_disk_name_argument .. "'.")
  750.     else
  751.         -- This is a brand new registration for a new disk name in a new slot.
  752.         table.insert(disk_inventory_data, new_disk_registration_data)
  753.         send_chat_message("Successfully registered new disk '" .. new_disk_name_argument .. "' in slot " .. final_target_storage_slot .. ".")
  754.     end
  755.  
  756.     -- Final step: Save the updated disk inventory data.
  757.     if not save_disk_data() then
  758.         -- save_disk_data would call error_state on failure, but good to have a message.
  759.         send_chat_message("ERROR: Critical failure while saving disk data after registering/updating '" .. new_disk_name_argument .. "'. Check console logs immediately.", true)
  760.     end
  761. end
  762.  
  763. -- Main chat handler (calls the sub-handlers)
  764. local function handle_chat_message(username, message_content)
  765.     print(helper_turtle_name .. ": Chat from " .. username .. ": " .. message_content)
  766.     local lower_message = message_content:lower()
  767.     local parts = {}
  768.     for part in lower_message:gmatch("%S+") do table.insert(parts, part) end
  769.  
  770.     if #parts == 0 then return end -- Ignore empty messages
  771.     local trigger_command = parts[1]
  772.  
  773.     if trigger_command == "@spatial" or trigger_command == "@spat" then
  774.         if #parts < 3 then return end -- Needs at least @spatial <action> <op_name>
  775.         local action = parts[2]
  776.         local operator_name_str = parts[3]
  777.         if action == "load" and #parts >= 4 then
  778.             -- Reconstruct disk name which might have spaces
  779.             local disk_name_parts = {}
  780.             for i = 4, #parts do table.insert(disk_name_parts, parts[i]) end
  781.             local requested_disk_user_name = table.concat(disk_name_parts, " ")
  782.             if requested_disk_user_name ~= "" then
  783.                  process_operator_load_command(operator_name_str, requested_disk_user_name)
  784.             else
  785.                 send_chat_message("Load command is missing the disk name.", true)
  786.             end
  787.         elseif action == "unload" then
  788.             process_operator_unload_command(operator_name_str)
  789.         else
  790.             send_chat_message("Unknown '@spatial' action: " .. action .. ". Use 'load' or 'unload'.", true)
  791.         end
  792.     elseif trigger_command == "@newdisk" and #parts >= 2 then
  793.         local action = parts[2]
  794.         if action == "register" and #parts >= 3 then
  795.             local sub_action = parts[3]
  796.             if sub_action == "start" then
  797.                 local start_arg_slot_num_str = parts[4] or "" -- Optional slot number for 'start'
  798.                 handle_new_disk_register_start(start_arg_slot_num_str)
  799.             elseif sub_action == "end" and #parts >= 4 then
  800.                 -- Disk name can have spaces. The last argument might be "force".
  801.                 local force_keyword_present = false
  802.                 local disk_name_argument_parts = {}
  803.                 if parts[#parts]:lower() == "force" then
  804.                     force_keyword_present = true
  805.                     for i = 4, #parts - 1 do table.insert(disk_name_argument_parts, parts[i]) end
  806.                 else
  807.                     for i = 4, #parts do table.insert(disk_name_argument_parts, parts[i]) end
  808.                 end
  809.                 local disk_name_argument = table.concat(disk_name_argument_parts, " ")
  810.                 local force_str_argument = force_keyword_present and "force" or ""
  811.  
  812.                 if disk_name_argument ~= "" then
  813.                     handle_new_disk_register_end(disk_name_argument, force_str_argument)
  814.                 else
  815.                     send_chat_message("The 'end' command for disk registration requires a disk name.", true)
  816.                 end
  817.             else
  818.                  send_chat_message("Invalid '@newDisk register' command. Usage: 'start [slot_num]' or 'end <DiskName> [force]'", true)
  819.             end
  820.         else
  821.             send_chat_message("Unknown '@newDisk' action: " .. action .. ". Did you mean 'register'?", true)
  822.         end
  823.     else
  824.         -- Not a command for this turtle or an unrecognised command prefix.
  825.         -- print(helper_turtle_name .. ": Ignoring unrecognised message.")
  826.     end
  827. end
  828. --#endregion
  829.  
  830. --#region Timer Event Processing & Main Loop
  831. local function handle_timer(timer_id_val)
  832.     local timer_id = tonumber_strict(timer_id_val, "timer_id in handle_timer")
  833.     if not _G.active_timers then _G.active_timers = {} return end -- Should have been initialized
  834.     local action_details = _G.active_timers[timer_id]
  835.     _G.active_timers[timer_id] = nil -- Consume the timer immediately
  836.  
  837.     if not action_details then
  838.         print(helper_turtle_name .. ": Warning - Received timer ID " .. timer_id .. " which has no associated action or was already processed.")
  839.         return
  840.     end
  841.  
  842.     print(helper_turtle_name .. ": Timer " .. timer_id .. " fired for action: " .. action_details.type)
  843.     local disk_entry_for_action, _ = find_disk_by_user_name(action_details.data.disk_user_name)
  844.  
  845.     if not disk_entry_for_action and (action_details.type == "expect_return_from_operator" or action_details.type == "reclaim_provided_disk") then
  846.         print(helper_turtle_name .. ": Warning - Disk '" .. tostring(action_details.data.disk_user_name) .. "' for timed action type '" .. action_details.type .. "' is no longer registered. Action cancelled.")
  847.         return
  848.     end
  849.  
  850.     if action_details.type == "expect_return_from_operator" then
  851.         if not disk_entry_for_action.lent_to_operator_id or disk_entry_for_action.lent_to_operator_id == "" then
  852.              print(helper_turtle_name .. ": Disk '" .. action_details.data.disk_user_name .. "' is no longer marked as lent. Return action for operator '"..tostring(action_details.data.operator_id).."' cancelled.")
  853.              return
  854.         end
  855.         local retrieved_successfully = attempt_suck_and_verify(disk_entry_for_action, action_details.data.reason)
  856.         if not retrieved_successfully then
  857.             action_details.attempts_left = action_details.attempts_left - 1
  858.             if action_details.attempts_left > 0 then
  859.                 local next_try_delay = action_details.data.suck_interval or 0.1
  860.                 local new_timer_id_for_retry = os.startTimer(tonumber_strict(next_try_delay, "timer retry delay"))
  861.                 _G.active_timers[new_timer_id_for_retry] = action_details -- Re-queue the action with remaining attempts
  862.                 print(helper_turtle_name .. ": Failed to retrieve '"..disk_entry_for_action.user_given_name.."' for '"..action_details.data.reason.."'. Retrying (new timer "..new_timer_id_for_retry..") in "..next_try_delay.."s. Attempts left: "..action_details.attempts_left)
  863.             else
  864.                 send_chat_message("Failed to retrieve disk '" .. disk_entry_for_action.user_given_name .. "' after multiple attempts (" .. action_details.data.reason .. "). The disk remains marked as lent to '"..tostring(disk_entry_for_action.lent_to_operator_id).."'.", true)
  865.                 -- Consider if is_critical_return should trigger error_state if NBT mismatch was the cause,
  866.                 -- but attempt_suck_and_verify handles returning bad NBT items. This is for total failure to retrieve.
  867.             end
  868.         end
  869.     elseif action_details.type == "reclaim_provided_disk" then
  870.         if disk_entry_for_action.lent_to_operator_id == action_details.data.operator_id then
  871.             print(helper_turtle_name .. ": Attempting to reclaim disk '" .. disk_entry_for_action.user_given_name .. "' (as it might not have been picked up by operator '" .. action_details.data.operator_id .. "').")
  872.             if attempt_suck_and_verify(disk_entry_for_action, "reclaim disk not picked up by operator") then
  873.                 send_chat_message("Successfully reclaimed disk '" .. disk_entry_for_action.user_given_name .. "' that was likely not picked up by the operator.")
  874.             else
  875.                 -- Silent failure on console as per earlier request (it was likely picked up by operator)
  876.                 print(helper_turtle_name .. ": Failed to reclaim disk '" .. disk_entry_for_action.user_given_name .. "'. It was likely picked up by the operator or is not in the chest.")
  877.             end
  878.         else
  879.             -- Disk is no longer lent to the operator we expected, or not lent at all.
  880.             print(helper_turtle_name .. ": Reclaim action for disk '" .. disk_entry_for_action.user_given_name .. "' (from operator " .. action_details.data.operator_id .. ") cancelled. Disk's lending status has changed (now lent to: " .. tostring(disk_entry_for_action.lent_to_operator_id) .. ").")
  881.         end
  882.     end
  883. end
  884.  
  885. local function main()
  886.     if not initialize() then
  887.         -- initialize() calls error_state on critical failure.
  888.         print(helper_turtle_name .. ": Initialization failed. Turtle will not operate.")
  889.         return
  890.     end
  891.     while not in_error_state do
  892.         local event_data = {os.pullEvent()} -- Wait for any event
  893.         local event_type = event_data[1]
  894.  
  895.         -- Wrap event handling in pcall to catch runtime errors within handlers and prevent full script crash.
  896.         local success, err_msg_or_obj = pcall(function()
  897.             if event_type == "chat" then
  898.                 handle_chat_message(event_data[2], event_data[3])
  899.             elseif event_type == "timer" then
  900.                 handle_timer(event_data[2]) -- Timer ID is event_data[2]
  901.             elseif event_type == "terminate" then
  902.                 print(helper_turtle_name .. ": Terminate event received. Shutting down.")
  903.                 in_error_state = true -- Set flag to exit the main loop
  904.             end
  905.         end)
  906.  
  907.         if not success then
  908.             local error_log_message = "Runtime error in main loop (" .. event_type .. " event): " .. tostring(err_msg_or_obj)
  909.             print(error_log_message)
  910.             term.setTextColor(colors.red)
  911.             -- Attempt to print a stack trace if the error object contains it (typical for Lua errors)
  912.             if type(err_msg_or_obj) == "string" and err_msg_or_obj:match(":%d+:") then
  913.                 print(err_msg_or_obj)
  914.             else
  915.                 -- For other types of errors or if no clear stack trace, use debug.traceback
  916.                 debug.traceback("Error occurred in " .. event_type .. " event handler", 2) -- '2' to skip traceback of pcall and this func
  917.             end
  918.             term.setTextColor(colors.white)
  919.             send_chat_message("An internal error occurred. Please check the turtle's console. Operations may be unstable.", true)
  920.             -- Depending on severity, could call error_state here to fully halt.
  921.             -- For now, it logs and attempts to continue, which might be risky.
  922.         end
  923.     end
  924.     print(helper_turtle_name .. " main operating loop has exited.")
  925. end
  926.  
  927. -- Start the turtle's main execution
  928. main()
  929.  
  930. -- Fallback message if main() exits unexpectedly without in_error_state being true.
  931. if not in_error_state then
  932.     print(helper_turtle_name .. " script finished unexpectedly without entering a formal error state.")
  933. end
  934. --#endregion
Add Comment
Please, Sign In to add comment