--[[ TechAge ======= Copyright (C) 2019-2025 Joachim Stolberg AGPL v3 See LICENSE.txt for more information TA5 Item Digitizer ]]-- -- for lazy programmers local M = minetest.get_meta local S = techage.S local P2S = function(pos) if pos then return minetest.pos_to_string(pos) end end -- Consumer Related Data local Tube = techage.Tube local Cable = techage.ElectricCable local power = networks.power local MP = minetest.get_modpath(minetest.get_current_modname()) local mConf = dofile(MP .. "/basis/conf_inv.lua") local CYCLE_TIME = 2 local STANDBY_TICKS = 8 local COUNTDOWN_TICKS = 2 local NUM_ITEMS = 50 local STORAGE_SLOTS = 8 local STORAGE_SIZE = 100000 local PWR_NEEDED = 24 local EX_POINTS = techage.ta5_digitizer_expoints local DESC = S("TA5 Digitizer") local LOG_INTERVAL = 150 -- ticks (~5 min at CYCLE_TIME=2) -- items: -- { -- [1] = {name = "default:stone", count = 10}, -- [2] = {name = "default:gold_ingot", count = 5}, -- .... -- [STORAGE_SLOTS] = {name = "default:diamond", count = 1}, -- } -- Returns true if the item can be stored (stackable, no metadata, no wear) local function is_valid_item(stack) if not stack or stack:is_empty() then return false end local ndef = minetest.registered_items[stack:get_name()] if not ndef then return false end if (ndef.stack_max or 1) <= 1 then return false end if stack:get_wear() > 0 then return false end if next(stack:get_meta():to_table().fields) ~= nil then return false end return true end local function store_items(pos, mem, force) if force and mem.items ~= nil then -- nil guard: never overwrite valid meta with nil M(pos):set_string("items", minetest.serialize(mem.items)) end end -- Short summary for item tooltip (e.g. "3/16 slots, 150234 items") local function items_summary_short(items_tbl) if not items_tbl then return "empty" end local slots_used, total = 0, 0 for i = 1, STORAGE_SLOTS do local slot = items_tbl[i] if slot and slot.name ~= "" and slot.count > 0 then slots_used = slots_used + 1 total = total + slot.count end end if slots_used == 0 then return "empty" end return slots_used .. "/" .. STORAGE_SLOTS .. " slots, " .. total .. " items" end -- Full summary for server log (one entry per used slot) local function items_summary_full(items_tbl) if not items_tbl then return "empty" end local parts = {} for i = 1, STORAGE_SLOTS do local slot = items_tbl[i] if slot and slot.name ~= "" and slot.count > 0 then parts[#parts + 1] = slot.name .. " x" .. slot.count end end if #parts == 0 then return "empty" end return table.concat(parts, ", ") end local function get_total_count(items_tbl) if not items_tbl then return 0 end local total = 0 for i = 1, STORAGE_SLOTS do local slot = items_tbl[i] if slot and slot.name ~= "" and slot.count > 0 then total = total + slot.count end end return total end local function restore_items(pos, mem) if mem.items == nil then local meta = M(pos) if meta:contains("items") then mem.items = minetest.deserialize(meta:get_string("items")) or {} else mem.items = {} end for idx = 1, STORAGE_SLOTS do mem.items[idx] = mem.items[idx] or {name = "", count = 0} end end end local function find_storage_slot_with_space(pos, mem, max_num_items, item_name) local function match(i, force) if (mem.items[i].name == item_name and mem.items[i].count < STORAGE_SIZE) or (force and mem.items[i].name == "") then mem.items[i].name = item_name return i end return nil -- full end local function num_items(idx, max_num_items) return math.min(STORAGE_SIZE - mem.items[idx].count, max_num_items) end restore_items(pos, mem) -- Try last used slot if mem.last_idx and match(mem.last_idx) then return mem.last_idx, num_items(mem.last_idx, max_num_items) end -- Try all slots with items for i = 1, STORAGE_SLOTS do if match(i) then mem.last_idx = i return i, num_items(i, max_num_items) end end -- Try all empty slots also for i = 1, STORAGE_SLOTS do if match(i, true) then mem.last_idx = i return i, num_items(i, max_num_items) end end mem.last_idx = nil return nil, 0 -- full end local function find_storage_slot_with_items(pos, mem, item_name) restore_items(pos, mem) for i = STORAGE_SLOTS, 1, -1 do if mem.items[i].name == item_name then if mem.items[i].count > 0 then return i else mem.items[i].name = "" end end end return nil -- empty end local function is_empty(pos, mem) restore_items(pos, mem) for i = 1, STORAGE_SLOTS do if mem.items[i].count > 0 then return false end end return true -- empty end local function delete_empty_slot(pos, mem, idx) if mem.items[idx].name ~= "" and mem.items[idx].count == 0 then mem.items[idx].name = "" end end local function item_image(x, y, item_name, item_count) local box = "box[" .. (x + 1.2) .. "," .. (y + 0.2) .. ";2,0.6;#808080]" local label = "label[" .. (x + 1.2) .. "," .. (y + 0.5) .. ";" .. string.format("%8d", item_count or 0) .. "]" local text = "" local tooltip = "" if item_name ~= "" and item_count > 0 then text = minetest.formspec_escape(ItemStack(item_name):get_description()) tooltip = "tooltip["..x..","..y..";1,1;"..text..";#0C3D32;#FFFFFF]" end return "box[" .. x .. "," .. y .. ";1,1;#808080]" .. "style_type[label;font=mono]" .. "item_image[" .. x .. "," .. y .. ";1,1;" .. item_name .. "]" .. tooltip .. box .. label end local function fs_container(rows, text) local size = 130 return "scrollbaroptions[max=" .. size .. "]" .. "scrollbar[9.6,1.2;0.4,8.2;vertical;wrenchmenu;]" .. "scroll_container[0.2,1;9.4,8;wrenchmenu;vertical;]" .. text .. "scroll_container_end[]" end local function formspec1(self, pos, nvm) local opmode = nvm.opmode or 1 return "formspec_version[8]" .. "size[10.2,9.6]" .. "tabheader[0,0;tab;" .. S("Control,Storage") .. ";1;;true]" .. "box[0.2,0.2;9.8,0.5;#c6e8ff]" .. "label[0.5,0.45;" .. minetest.colorize( "#000000", DESC) .. "]" .. "label[5.7,1.5;" .. S("Configured\nItem") .. "]" .. "list[context;main;4.5,1.2;1,1;]" .. "image[2.5,1;1,1;"..techage.get_power_image(pos, nvm) .. "]" .. "tooltip[2.5,1;1,1;" .. S("Needs @1 ku power", PWR_NEEDED) .. "]" .. "image_button[2.5,2.5;1,1;" .. self:get_state_button_image(nvm) .. ";state_button;]" .. "tooltip[2.5,2.5;1,1;" .. self:get_state_tooltip(nvm) .. "]" .. "dropdown[4.5,3;4,0.6;opmode;" .. S("pull") .. "," .. S("push") .. ";" .. opmode .. ";true]" .. "list[current_player;main;0.2,4.4;8,4;]" .. "listring[context;main]" .. "listring[current_player;main]" end local function formspec2(self, pos, nvm) local mem = techage.get_mem(pos) restore_items(pos, mem) local tbl = {} local idx = 1 for idx = 1, STORAGE_SLOTS do local name, count = mem.items[idx].name, mem.items[idx].count if idx % 2 == 1 then tbl[#tbl + 1] = item_image(0.5, (idx + 0) * 0.6, name, count) else tbl[#tbl + 1] = item_image(5.5, (idx - 1) * 0.6, name, count) end end return "formspec_version[8]" .. "size[10.2,9.6]" .. "tabheader[0,0;tab;" .. S("Control,Storage") .. ";2;;true]" .. "box[0.2,0.2;9.8,0.5;#c6e8ff]" .. "label[0.5,0.45;" .. minetest.colorize( "#000000", DESC) .. "]" .. fs_container(#tbl, table.concat(tbl, "")) end local function formspec(self, pos, nvm) if nvm.fs_tab2 then return formspec2(self, pos, nvm) else return formspec1(self, pos, nvm) end end local function configured_item(pos) local inv = M(pos):get_inventory() local stack = inv:get_stack('main', 1) if stack and stack:get_count() > 0 then return stack:get_name() end return nil end local function config_item(pos, payload) local inv = M(pos):get_inventory() local stack = ItemStack(payload) inv:set_stack("main", 1, stack) end local function stop_node(pos, nvm, state) end local function can_start(pos, nvm, state) if configured_item(pos) then local mem = techage.get_mem(pos) mem.last_idx = nil return true end return S("no configured item") end local State = techage.NodeStates:new({ node_name_passive = "techage:ta5_digitizer_pas", node_name_active = "techage:ta5_digitizer_act", cycle_time = CYCLE_TIME, infotext_name = DESC, standby_ticks = STANDBY_TICKS, formspec_func = formspec, stop_node = stop_node, can_start = can_start, }) local function consume_power(pos, nvm) if techage.needs_power(nvm) then local taken = power.consume_power(pos, Cable, nil, PWR_NEEDED) if techage.is_running(nvm) then if taken < PWR_NEEDED then State:nopower(pos, nvm) return false else return true end elseif taken == PWR_NEEDED then State:start(pos, nvm) return false else return false end end return true end local function on_receive_fields(pos, formname, fields, player) if minetest.is_protected(pos, player:get_player_name()) then return end local meta = M(pos) local nvm = techage.get_nvm(pos) if fields.tab == "1" then nvm.fs_tab2 = false meta:set_string("formspec", formspec(State, pos, nvm)) return elseif fields.tab == "2" then nvm.fs_tab2 = true techage.set_activeformspec(pos, player) meta:set_string("formspec", formspec(State, pos, nvm)) return else if fields.opmode then nvm.opmode = tonumber(fields.opmode) end if EX_POINTS == 0 or techage.get_expoints(player) >= EX_POINTS then State:state_button_event(pos, nvm, fields) end end end local function digitize(pos, nvm, mem) local item_name = configured_item(pos) if item_name == nil then State:fault(pos, nvm, S("No configured item")) return false end local tube_dir = M(pos):get_int("tube_dir") local idx, num_items = find_storage_slot_with_space(pos, mem, NUM_ITEMS, item_name) if idx then local taken = techage.pull_items(pos, tube_dir, num_items, item_name) if taken and taken:get_count() > 0 then if not is_valid_item(taken) then techage.push_items(pos, tube_dir, taken) State:fault(pos, nvm, S("Invalid item type")) return false end local is_new_slot = mem.items[idx].count == 0 mem.items[idx].count = mem.items[idx].count + taken:get_count() if is_new_slot then minetest.log("action", "[techage] TA5 Digitizer #" .. M(pos):get_string("node_number") .. " | new slot: " .. item_name .. " at " .. P2S(pos)) end State:keep_running(pos, nvm, COUNTDOWN_TICKS) return true end delete_empty_slot(pos, mem, idx) -- Pulled nothing, try again later State:idle(pos, nvm) return false end -- No space in storage, stop execution State:blocked(pos, nvm, S("Storage full")) return false end local function reassemble(pos, nvm, mem) local item_name = configured_item(pos) if item_name == nil then State:fault(pos, nvm, S("No configured item")) return false end local tube_dir = M(pos):get_int("tube_dir") local idx = find_storage_slot_with_items(pos, mem, item_name) if idx then local count = math.min(mem.items[idx].count, NUM_ITEMS) local stack = ItemStack({name = item_name, count = count}) local leftover = techage.push_items(pos, tube_dir, stack) local pushed = not leftover and 0 or leftover ~= true and count - leftover:get_count() or count local prev_name = mem.items[idx].name mem.items[idx].count = mem.items[idx].count - pushed delete_empty_slot(pos, mem, idx) if mem.items[idx].name == "" then minetest.log("action", "[techage] TA5 Digitizer #" .. M(pos):get_string("node_number") .. " | slot cleared: " .. prev_name .. " at " .. P2S(pos)) end if pushed > 0 then State:keep_running(pos, nvm, COUNTDOWN_TICKS) return true else -- Can't push items, try again later State:blocked(pos, nvm, "Can't push, no space") end return false end -- No items in storage, stop execution State:stop(pos, nvm) return false end local function on_timer(pos, elapsed) local nvm = techage.get_nvm(pos) if consume_power(pos, nvm) then local mem = techage.get_mem(pos) local changed = false if nvm.opmode == 1 then changed = digitize(pos, nvm, mem) else changed = reassemble(pos, nvm, mem) end store_items(pos, mem, changed) -- Periodic status log (~every 5 min) nvm.log_tick = (nvm.log_tick or 0) + 1 if nvm.log_tick >= LOG_INTERVAL then nvm.log_tick = 0 local full = items_summary_full(mem.items) if full ~= "empty" then minetest.log("action", "[techage] TA5 Digitizer #" .. M(pos):get_string("node_number") .. " | status: " .. full .. " at " .. P2S(pos)) end end if techage.is_activeformspec(pos) then M(pos):set_string("formspec", formspec(State, pos, nvm)) end end return true -- keep running end minetest.register_node("techage:ta5_digitizer_pas", { description = DESC, tiles = { -- up, down, right, left, back, front "techage_filling_ta4.png^techage_frame_ta5_top.png^techage_appl_arrow.png", "techage_filling_ta4.png^techage_frame_ta5.png", "techage_filling_ta4.png^techage_frame_ta5.png^techage_appl_in_out.png", "techage_filling_ta4.png^techage_frame_ta5.png", "techage_filling_ta4.png^techage_appl_digitizer_off.png^techage_frame_ta5.png", "techage_filling_ta4.png^techage_appl_digitizer_off.png^techage_frame_ta5.png", }, after_place_node = function(pos, placer, itemstack, pointed_thing) -- Don't allow placing if itemstack has metadata if techage.cordless_screwdriver_only(pos, placer, itemstack) then return true end local meta = M(pos) local nvm = techage.get_nvm(pos) local node = minetest.get_node(pos) local tube_dir = techage.side_to_outdir("R", node.param2) local number = techage.add_node(pos, "techage:ta5_digitizer_pas") State:node_init(pos, nvm, number) meta:set_int("tube_dir", tube_dir) meta:set_string("owner", placer:get_player_name()) Tube:after_place_node(pos, {tube_dir}) Cable:after_place_node(pos) local inv = meta:get_inventory() inv:set_size('main', 1) end, ta_rotate_node = function(pos, node, new_param2) local meta = M(pos) Cable:after_dig_node(pos) Tube:after_dig_node(pos) minetest.swap_node(pos, {name = node.name, param2 = new_param2}) local tube_dir = techage.side_to_outdir("R", new_param2) meta:set_int("tube_dir", tube_dir) Tube:after_place_node(pos, {tube_dir}) Cable:after_place_node(pos) end, on_timer = on_timer, on_receive_fields = on_receive_fields, ta_preserve_nodedata = function(pos, node, digger) -- Load items from meta into mem first (handles cold mem after server restart), -- then flush to meta so pack_meta captures the current state reliably. local mem = techage.get_mem(pos) restore_items(pos, mem) store_items(pos, mem, true) local number = M(pos):get_string("node_number") local player_name = digger and digger:get_player_name() or "unknown" local short = items_summary_short(mem.items) local full = items_summary_full(mem.items) minetest.log("action", "[techage] TA5 Digitizer #" .. number .. " packed by " .. player_name .. " at " .. P2S(pos) .. " | " .. full) return techage.preserve_nodedata(pos), short end, ta_restore_nodedata = function(pos, s, placer) techage.restore_nodedata(pos, s) -- Re-register the existing node number in the backend (required after -- placement via cordless screwdriver, where add_node is never called) techage.repair_number(pos) -- Log restored contents directly from freshly-restored meta local items_tbl = minetest.deserialize(M(pos):get_string("items")) local number = M(pos):get_string("node_number") local player_name = placer and placer:get_player_name() or "unknown" local full = items_summary_full(items_tbl) minetest.log("action", "[techage] TA5 Digitizer #" .. number .. " unpacked by " .. player_name .. " at " .. P2S(pos) .. " | " .. full) local node = minetest.get_node(pos) local tube_dir = techage.side_to_outdir("R", node.param2) local meta = M(pos) meta:set_int("tube_dir", tube_dir) Tube:after_place_node(pos, {tube_dir}) Cable:after_place_node(pos) end, allow_metadata_inventory_put = function(pos, listname, index, stack, player) if minetest.is_protected(pos, player:get_player_name()) then return 0 end if not is_valid_item(stack) then minetest.chat_send_player(player:get_player_name(), S("Item not storable (not stackable, has metadata, or is worn)")) return 0 end return mConf.allow_conf_inv_put(pos, listname, index, stack, player) end, allow_metadata_inventory_take = function(pos, listname, index, stack, player) if minetest.is_protected(pos, player:get_player_name()) then return 0 end return mConf.allow_conf_inv_take(pos, listname, index, stack, player) end, ta_can_remove = function(pos, digger) if minetest.is_protected(pos, digger:get_player_name()) then return false end local nvm = techage.get_nvm(pos) if (nvm.techage_state or techage.STOPPED) ~= techage.STOPPED then minetest.chat_send_player(digger:get_player_name(), S("[Digitizer] Stop the Digitizer first!")) return false end return true -- only removable when stopped; data preserved via ta_preserve_nodedata end, can_dig = function(pos, player) if minetest.is_protected(pos, player:get_player_name()) then return false end local mem = techage.get_mem(pos) if is_empty(pos, mem) then return true else minetest.chat_send_player(player:get_player_name(), S("[Digitizer] Storage not empty! Empty the storage first.")) return false end end, after_dig_node = function(pos, oldnode, oldmetadata, digger) techage.remove_node(pos, oldnode, oldmetadata) Tube:after_dig_node(pos) Cable:after_dig_node(pos) techage.del_mem(pos) end, paramtype2 = "facedir", -- important! on_rotate = screwdriver.disallow, -- important! is_ground_content = false, groups = {crumbly = 3, cracky = 3, snappy = 3}, sounds = default.node_sound_glass_defaults(), }) minetest.register_node("techage:ta5_digitizer_act", { description = DESC, tiles = { -- up, down, right, left, back, front "techage_filling_ta4.png^techage_frame_ta5_top.png^techage_appl_arrow.png", "techage_filling_ta4.png^techage_frame_ta5.png", "techage_filling_ta4.png^techage_frame_ta5.png^techage_appl_in_out.png", "techage_filling_ta4.png^techage_frame_ta5.png", { name = "techage_appl_digitizer_on4.png^[transformR180]^techage_frame4_ta5.png", backface_culling = false, animation = { type = "vertical_frames", aspect_w = 32, aspect_h = 32, length = 1.2, }, }, { name = "techage_appl_digitizer_on4.png^techage_frame4_ta5.png", backface_culling = false, animation = { type = "vertical_frames", aspect_w = 32, aspect_h = 32, length = 1.2, }, }, }, on_timer = on_timer, on_receive_fields = on_receive_fields, on_rightclick = function(pos, node, clicker) local nvm = techage.get_nvm(pos) techage.set_activeformspec(pos, clicker) M(pos):set_string("formspec", formspec(State, pos, nvm)) end, allow_metadata_inventory_put = function(pos, listname, index, stack, player) return 0 end, allow_metadata_inventory_take = function(pos, listname, index, stack, player) return 0 end, ta_can_remove = function(pos, digger) if minetest.is_protected(pos, digger:get_player_name()) then return false end minetest.chat_send_player(digger:get_player_name(), S("[Digitizer] Stop the Digitizer first!")) return false end, paramtype2 = "facedir", -- important! on_rotate = screwdriver.disallow, -- important! is_ground_content = false, drop = "", diggable = false, groups = {crumbly = 3, cracky = 3, snappy = 3, not_in_creative_inventory = 1}, sounds = default.node_sound_glass_defaults(), }) techage.register_node({"techage:ta5_digitizer_pas", "techage:ta5_digitizer_act"}, { on_recv_message = function(pos, src, topic, payload) if topic == "pull" then local nvm = techage.get_nvm(pos) if not techage.is_running(nvm) and configured_item(pos) then nvm.opmode = 1 State:start(pos, nvm) return true end return false elseif topic == "push" then local nvm = techage.get_nvm(pos) if not techage.is_running(nvm) and configured_item(pos) then nvm.opmode = 2 State:start(pos, nvm) return true end return false elseif topic == "config" then local nvm = techage.get_nvm(pos) if techage.is_running(nvm) then State:stop(pos, nvm) end if payload and type(payload) == "string" then config_item(pos, payload) return true end return false elseif topic == "stop" then local nvm = techage.get_nvm(pos) State:stop(pos, nvm) return true elseif topic == "count" then local nvm = techage.get_nvm(pos) restore_items(pos, nvm) return get_total_count(nvm.items) elseif topic == "itemstring" then return configured_item(pos) or "" elseif topic == "mode" then local nvm = techage.get_nvm(pos) if payload then nvm.opmode = tonumber(payload) == 2 and 2 or 1 return true end return nvm.opmode or 1 else return State:on_receive_message(pos, topic, payload) end end, on_beduino_receive_cmnd = function(pos, src, topic, payload) if topic == 65 then -- Set config item (itemstring) local nvm = techage.get_nvm(pos) if techage.is_running(nvm) then State:stop(pos, nvm) end config_item(pos, payload) return 0 elseif topic == 67 then -- Set mode (1=pull, 2=push), string payload local nvm = techage.get_nvm(pos) nvm.opmode = (tonumber(payload) == 2) and 2 or 1 return 0 else return State:on_beduino_receive_cmnd(pos, topic, payload) end end, on_beduino_request_data = function(pos, src, topic, payload) if topic == 154 then -- Get total stored item count local nvm = techage.get_nvm(pos) restore_items(pos, nvm) return 0, {get_total_count(nvm.items)} elseif topic == 155 then -- Get configured item name return 0, configured_item(pos) or "" else return State:on_beduino_request_data(pos, topic, payload) end end, on_node_load = function(pos, node) local nvm = techage.get_nvm(pos) if techage.is_operational(nvm) then State:on_node_load(pos) end end, }) power.register_nodes({"techage:ta5_digitizer_pas", "techage:ta5_digitizer_act"}, Cable, "con", {"B", "L", "F", "D", "U"}) Tube:set_valid_sides({"techage:ta5_digitizer_pas", "techage:ta5_digitizer_act"}, {"R"}) techage.recipes.add("ta4_electronic_fab", { output = "techage:ta5_controlunit 1", waste = "basic_materials:empty_spool 1", input = { "techage:aluminum 1", "basic_materials:gold_wire 1", "techage:ta5_aichip2 1", "techage:ta4_wlanchip 2", }, ex_points = EX_POINTS, }) minetest.register_craftitem("techage:ta5_controlunit", { description = S("TA5 Control Unit"), inventory_image = "techage_controlunit.png", }) minetest.register_craftitem("techage:ta5_ssd", { description = S("TA5 SSD"), inventory_image = "techage_ta5_ssd.png", }) techage.recipes.add("ta4_electronic_fab", { output = "techage:ta5_ssd 1", input = { "techage:ta4_ramchip 16", "techage:ta4_silicon_wafer 1", "basic_materials:plastic_sheet 1", "basic_materials:steel_strip 1", }, }) minetest.register_craft({ output = "techage:ta5_digitizer_pas", recipe = { {"techage:aluminum", "dye:red", "techage:ta4_carbon_fiber"}, {"techage:electric_cableS", "techage:ta4_pusher_pas", "techage:ta4_tubeS"}, {"techage:ta5_ssd", "basic_materials:gear_steel", "techage:ta5_controlunit"}, }, })