Files
modolith/mods/techage_modpack/techage/ta5_nodes/ta5_digitizer.lua
T

770 lines
23 KiB
Lua

--[[
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"},
},
})