3208 lines
126 KiB
Lua
3208 lines
126 KiB
Lua
-- Inventory Manager: Touch UI on monitor
|
||
-- Main computer (networked). Computer 1 sits next to dropper_0 and auto-dispenses.
|
||
|
||
local DROPPER_NAME = "minecraft:dropper_9"
|
||
local BARREL_NAME = "minecraft:barrel_0"
|
||
local POLL_INTERVAL = 2 -- seconds between barrel checks
|
||
local MONITOR_SIDE = "left"
|
||
local SCAN_INTERVAL = 120 -- seconds between full background scans
|
||
local SMELT_INTERVAL = 3 -- seconds between furnace checks
|
||
local SMELT_RESERVE = 64 -- keep at least 1 stack of each raw material
|
||
local DEFRAG_INTERVAL = 60 -- seconds between defrag passes
|
||
local COMPOST_INTERVAL = 3 -- seconds between composter checks
|
||
local ALERT_INTERVAL = 15 -- seconds between alert re-checks
|
||
local CACHE_FILE = ".inventory_cache" -- persistent cache file
|
||
local SMELTER_MONITOR_SIDE = "top"
|
||
local DISABLED_RECIPES_FILE = ".disabled_recipes"
|
||
|
||
-- Network sync (for client displays)
|
||
local BROADCAST_CHANNEL = 4200
|
||
local ORDER_CHANNEL = 4201
|
||
local BROADCAST_INTERVAL = 1 -- seconds between state broadcasts
|
||
|
||
-- Crafting turtle
|
||
local CRAFT_CHANNEL = 4203
|
||
local CRAFT_REPLY_CHANNEL = 4204
|
||
|
||
-------------------------------------------------
|
||
-- Furnace types to manage
|
||
-------------------------------------------------
|
||
|
||
local FURNACE_TYPES = {
|
||
"minecraft:furnace",
|
||
"minecraft:smoker",
|
||
"minecraft:blast_furnace",
|
||
}
|
||
|
||
-- Furnace slots: 1 = input, 2 = fuel, 3 = output (standard Minecraft)
|
||
local SLOT_INPUT = 1
|
||
local SLOT_FUEL = 2
|
||
local SLOT_OUTPUT = 3
|
||
|
||
-------------------------------------------------
|
||
-- Smeltable items: input -> output
|
||
-- Items in chests matching a key here get auto-smelted.
|
||
-- Add/remove entries to control what gets cooked.
|
||
-------------------------------------------------
|
||
|
||
local SMELTABLE = {
|
||
-- Ores (furnace + blast furnace only)
|
||
["minecraft:raw_iron"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:raw_gold"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:raw_copper"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:gold_ore"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:copper_ore"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:deepslate_iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:deepslate_gold_ore"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:deepslate_copper_ore"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:ancient_debris"] = { result = "minecraft:netherite_scrap",furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
-- Sand / stone (furnace + blast furnace)
|
||
["minecraft:sand"] = { result = "minecraft:glass", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:red_sand"] = { result = "minecraft:glass", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:cobblestone"] = { result = "minecraft:stone", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:stone"] = { result = "minecraft:smooth_stone", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:clay_ball"] = { result = "minecraft:brick", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:netherrack"] = { result = "minecraft:nether_brick", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["minecraft:sandstone"] = { result = "minecraft:smooth_sandstone", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
-- Food (furnace + smoker only)
|
||
["minecraft:beef"] = { result = "minecraft:cooked_beef", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["minecraft:porkchop"] = { result = "minecraft:cooked_porkchop", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["minecraft:chicken"] = { result = "minecraft:cooked_chicken", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["minecraft:mutton"] = { result = "minecraft:cooked_mutton", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["minecraft:rabbit"] = { result = "minecraft:cooked_rabbit", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["minecraft:cod"] = { result = "minecraft:cooked_cod", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["minecraft:salmon"] = { result = "minecraft:cooked_salmon", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["minecraft:potato"] = { result = "minecraft:baked_potato", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["minecraft:kelp"] = { result = "minecraft:dried_kelp", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
-- Misc (furnace only)
|
||
["minecraft:wet_sponge"] = { result = "minecraft:sponge", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:cactus"] = { result = "minecraft:green_dye", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:sea_pickle"] = { result = "minecraft:lime_dye", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:oak_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:spruce_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:birch_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:jungle_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:acacia_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:dark_oak_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:mangrove_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||
["minecraft:cherry_log"] = { result = "minecraft:charcoal", furnaces = {"minecraft:furnace"} },
|
||
|
||
-------------------------------------------------
|
||
-- Mythic Metals — raw ores (furnace + blast furnace)
|
||
-------------------------------------------------
|
||
["mythicmetals:raw_adamantite"] = { result = "mythicmetals:adamantite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_aquarium"] = { result = "mythicmetals:aquarium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_banglum"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_carmot"] = { result = "mythicmetals:carmot_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_kyber"] = { result = "mythicmetals:kyber_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_manganese"] = { result = "mythicmetals:manganese_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_midas_gold"] = { result = "mythicmetals:midas_gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_mythril"] = { result = "mythicmetals:mythril_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_orichalcum"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_palladium"] = { result = "mythicmetals:palladium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_prometheum"] = { result = "mythicmetals:prometheum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_quadrillum"] = { result = "mythicmetals:quadrillum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_runite"] = { result = "mythicmetals:runite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_star_platinum"] = { result = "mythicmetals:star_platinum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_stormyx"] = { result = "mythicmetals:stormyx_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_tin"] = { result = "mythicmetals:tin_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:raw_silver"] = { result = "mythicmetals:silver_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
|
||
-------------------------------------------------
|
||
-- Mythic Metals — ore blocks (furnace + blast furnace)
|
||
-------------------------------------------------
|
||
["mythicmetals:adamantite_ore"] = { result = "mythicmetals:adamantite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_adamantite_ore"] = { result = "mythicmetals:adamantite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:aquarium_ore"] = { result = "mythicmetals:aquarium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:banglum_ore"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_banglum_ore"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:nether_banglum_ore"] = { result = "mythicmetals:banglum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:carmot_ore"] = { result = "mythicmetals:carmot_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_carmot_ore"] = { result = "mythicmetals:carmot_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:kyber_ore"] = { result = "mythicmetals:kyber_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_kyber_ore"] = { result = "mythicmetals:kyber_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:manganese_ore"] = { result = "mythicmetals:manganese_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_manganese_ore"] = { result = "mythicmetals:manganese_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:midas_gold_ore"] = { result = "mythicmetals:midas_gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:mythril_ore"] = { result = "mythicmetals:mythril_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_mythril_ore"] = { result = "mythicmetals:mythril_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:orichalcum_ore"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_orichalcum_ore"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:end_stone_orichalcum_ore"] = { result = "mythicmetals:orichalcum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:palladium_ore"] = { result = "mythicmetals:palladium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:prometheum_ore"] = { result = "mythicmetals:prometheum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_prometheum_ore"] = { result = "mythicmetals:prometheum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:quadrillum_ore"] = { result = "mythicmetals:quadrillum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_quadrillum_ore"] = { result = "mythicmetals:quadrillum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:runite_ore"] = { result = "mythicmetals:runite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_runite_ore"] = { result = "mythicmetals:runite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:star_platinum_ore"] = { result = "mythicmetals:star_platinum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:end_stone_star_platinum_ore"] = { result = "mythicmetals:star_platinum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:stormyx_ore"] = { result = "mythicmetals:stormyx_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:end_stone_stormyx_ore"] = { result = "mythicmetals:stormyx_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:tin_ore"] = { result = "mythicmetals:tin_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_tin_ore"] = { result = "mythicmetals:tin_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:silver_ore"] = { result = "mythicmetals:silver_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["mythicmetals:deepslate_silver_ore"] = { result = "mythicmetals:silver_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
|
||
-------------------------------------------------
|
||
-- Ad Astra — raw ores + planetary ores (furnace + blast furnace)
|
||
-------------------------------------------------
|
||
["ad_astra:raw_desh"] = { result = "ad_astra:desh_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:raw_ostrum"] = { result = "ad_astra:ostrum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:raw_calorite"] = { result = "ad_astra:calorite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:moon_desh_ore"] = { result = "ad_astra:desh_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:deepslate_desh_ore"] = { result = "ad_astra:desh_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:mars_ostrum_ore"] = { result = "ad_astra:ostrum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:deepslate_ostrum_ore"] = { result = "ad_astra:ostrum_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:venus_calorite_ore"] = { result = "ad_astra:calorite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:deepslate_calorite_ore"] = { result = "ad_astra:calorite_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:moon_iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:mars_iron_ore"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:venus_gold_ore"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:venus_diamond_ore"] = { result = "minecraft:diamond", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:mars_diamond_ore"] = { result = "minecraft:diamond", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["ad_astra:moon_ice_shard_ore"] = { result = "ad_astra:ice_shard", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
|
||
-------------------------------------------------
|
||
-- Create — zinc + crushed ores (furnace + blast furnace)
|
||
-------------------------------------------------
|
||
["create:raw_zinc"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["create:zinc_ore"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["create:deepslate_zinc_ore"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["create:crushed_raw_iron"] = { result = "minecraft:iron_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["create:crushed_raw_gold"] = { result = "minecraft:gold_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["create:crushed_raw_copper"] = { result = "minecraft:copper_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["create:crushed_raw_zinc"] = { result = "create:zinc_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
|
||
-------------------------------------------------
|
||
-- BetterEnd — thallasium (furnace + blast furnace)
|
||
-------------------------------------------------
|
||
["betterend:thallasium_raw"] = { result = "betterend:thallasium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
["betterend:thallasium_ore"] = { result = "betterend:thallasium_ingot", furnaces = {"minecraft:furnace", "minecraft:blast_furnace"} },
|
||
|
||
-------------------------------------------------
|
||
-- Farmer's Delight — food (furnace + smoker)
|
||
-------------------------------------------------
|
||
["farmersdelight:chicken_cuts"] = { result = "farmersdelight:cooked_chicken_cuts", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["farmersdelight:bacon"] = { result = "farmersdelight:cooked_bacon", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["farmersdelight:cod_slice"] = { result = "farmersdelight:cooked_cod_slice", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["farmersdelight:salmon_slice"] = { result = "farmersdelight:cooked_salmon_slice", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["farmersdelight:mutton_chops"] = { result = "farmersdelight:cooked_mutton_chops", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["farmersdelight:ham"] = { result = "farmersdelight:smoked_ham", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
["farmersdelight:minced_beef"] = { result = "farmersdelight:beef_patty", furnaces = {"minecraft:furnace", "minecraft:smoker"} },
|
||
}
|
||
|
||
-- Fuel items, ordered by preference (best first)
|
||
-- burn_time = how many items one fuel smelts
|
||
local FUEL_LIST = {
|
||
{ name = "minecraft:coal", burn_time = 8 },
|
||
{ name = "minecraft:charcoal", burn_time = 8 },
|
||
{ name = "minecraft:coal_block", burn_time = 80 },
|
||
{ name = "minecraft:blaze_rod", burn_time = 12 },
|
||
{ name = "minecraft:dried_kelp_block", burn_time = 20 },
|
||
{ name = "minecraft:lava_bucket", burn_time = 100 },
|
||
{ name = "minecraft:oak_planks", burn_time = 1.5 },
|
||
{ name = "minecraft:spruce_planks",burn_time = 1.5 },
|
||
{ name = "minecraft:birch_planks", burn_time = 1.5 },
|
||
{ name = "minecraft:jungle_planks",burn_time = 1.5 },
|
||
{ name = "minecraft:acacia_planks",burn_time = 1.5 },
|
||
{ name = "minecraft:dark_oak_planks",burn_time = 1.5 },
|
||
{ name = "minecraft:mangrove_planks",burn_time = 1.5 },
|
||
{ name = "minecraft:cherry_planks",burn_time = 1.5 },
|
||
{ name = "minecraft:stick", burn_time = 0.5 },
|
||
}
|
||
|
||
-- Build a set for quick lookup
|
||
local FUEL_SET = {}
|
||
for _, f in ipairs(FUEL_LIST) do FUEL_SET[f.name] = true end
|
||
|
||
-------------------------------------------------
|
||
-- Compostable items (pushed into dropper_10,
|
||
-- which distributes to composters via hoppers).
|
||
-- Bone meal returns through hopper_0.
|
||
-------------------------------------------------
|
||
|
||
local COMPOSTABLE = {
|
||
-- Seeds & crops
|
||
"minecraft:wheat_seeds",
|
||
"minecraft:beetroot_seeds",
|
||
"minecraft:pumpkin_seeds",
|
||
"minecraft:melon_seeds",
|
||
"minecraft:torchflower_seeds",
|
||
"minecraft:pitcher_pod",
|
||
"minecraft:wheat",
|
||
"minecraft:beetroot",
|
||
"minecraft:carrot",
|
||
"minecraft:melon_slice",
|
||
"minecraft:pumpkin",
|
||
"minecraft:carved_pumpkin",
|
||
"minecraft:sweet_berries",
|
||
"minecraft:glow_berries",
|
||
-- Plant blocks
|
||
"minecraft:tall_grass",
|
||
"minecraft:short_grass",
|
||
"minecraft:fern",
|
||
"minecraft:large_fern",
|
||
"minecraft:dead_bush",
|
||
"minecraft:vine",
|
||
"minecraft:hanging_roots",
|
||
"minecraft:small_dripleaf",
|
||
"minecraft:big_dripleaf",
|
||
"minecraft:moss_block",
|
||
"minecraft:moss_carpet",
|
||
"minecraft:azalea",
|
||
"minecraft:flowering_azalea",
|
||
"minecraft:spore_blossom",
|
||
"minecraft:seagrass",
|
||
"minecraft:sea_pickle",
|
||
"minecraft:lily_pad",
|
||
"minecraft:sugar_cane",
|
||
"minecraft:kelp",
|
||
"minecraft:dried_kelp",
|
||
"minecraft:cactus",
|
||
"minecraft:bamboo",
|
||
"minecraft:nether_wart",
|
||
"minecraft:crimson_fungus",
|
||
"minecraft:warped_fungus",
|
||
"minecraft:crimson_roots",
|
||
"minecraft:warped_roots",
|
||
"minecraft:shroomlight",
|
||
"minecraft:weeping_vines",
|
||
"minecraft:twisting_vines",
|
||
-- Leaves
|
||
"minecraft:oak_leaves",
|
||
"minecraft:spruce_leaves",
|
||
"minecraft:birch_leaves",
|
||
"minecraft:jungle_leaves",
|
||
"minecraft:acacia_leaves",
|
||
"minecraft:dark_oak_leaves",
|
||
"minecraft:mangrove_leaves",
|
||
"minecraft:cherry_leaves",
|
||
"minecraft:azalea_leaves",
|
||
"minecraft:flowering_azalea_leaves",
|
||
-- Flowers
|
||
"minecraft:dandelion",
|
||
"minecraft:poppy",
|
||
"minecraft:blue_orchid",
|
||
"minecraft:allium",
|
||
"minecraft:azure_bluet",
|
||
"minecraft:red_tulip",
|
||
"minecraft:orange_tulip",
|
||
"minecraft:white_tulip",
|
||
"minecraft:pink_tulip",
|
||
"minecraft:oxeye_daisy",
|
||
"minecraft:cornflower",
|
||
"minecraft:lily_of_the_valley",
|
||
"minecraft:sunflower",
|
||
"minecraft:lilac",
|
||
"minecraft:rose_bush",
|
||
"minecraft:peony",
|
||
"minecraft:wither_rose",
|
||
"minecraft:torchflower",
|
||
"minecraft:pitcher_plant",
|
||
-- Saplings
|
||
"minecraft:oak_sapling",
|
||
"minecraft:spruce_sapling",
|
||
"minecraft:birch_sapling",
|
||
"minecraft:jungle_sapling",
|
||
"minecraft:acacia_sapling",
|
||
"minecraft:dark_oak_sapling",
|
||
"minecraft:mangrove_propagule",
|
||
"minecraft:cherry_sapling",
|
||
-- Food waste
|
||
"minecraft:rotten_flesh",
|
||
"minecraft:spider_eye",
|
||
"minecraft:poisonous_potato",
|
||
"minecraft:fermented_spider_eye",
|
||
"minecraft:apple",
|
||
"minecraft:bread",
|
||
"minecraft:cookie",
|
||
"minecraft:cake",
|
||
"minecraft:pumpkin_pie",
|
||
-- Farmer's Delight compostables
|
||
"farmersdelight:tree_bark",
|
||
"farmersdelight:straw",
|
||
"farmersdelight:canvas",
|
||
"farmersdelight:rice",
|
||
"farmersdelight:rice_panicle",
|
||
"farmersdelight:onion",
|
||
"farmersdelight:tomato",
|
||
"farmersdelight:cabbage",
|
||
"farmersdelight:cabbage_leaf",
|
||
}
|
||
|
||
-- Build set for quick lookup
|
||
local COMPOSTABLE_SET = {}
|
||
for _, name in ipairs(COMPOSTABLE) do COMPOSTABLE_SET[name] = true end
|
||
|
||
-- Reserve: keep at least this many of each compostable before composting the rest
|
||
local COMPOST_RESERVE = 16
|
||
local COMPOST_DROPPER = "minecraft:dropper_10"
|
||
local COMPOST_HOPPER = "minecraft:hopper_0"
|
||
|
||
-------------------------------------------------
|
||
-- Crafting recipes (for networked crafting turtle)
|
||
-- grid: 9 entries mapping to turtle slots 1-3, 5-7, 9-11
|
||
-------------------------------------------------
|
||
|
||
local GRID_TO_SLOT = {1, 2, 3, 5, 6, 7, 9, 10, 11}
|
||
|
||
local CRAFTABLE = {
|
||
-- Basic materials
|
||
{
|
||
output = "minecraft:oak_planks",
|
||
count = 4,
|
||
grid = {
|
||
"minecraft:oak_log", nil, nil,
|
||
nil, nil, nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:spruce_planks",
|
||
count = 4,
|
||
grid = {
|
||
"minecraft:spruce_log", nil, nil,
|
||
nil, nil, nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:birch_planks",
|
||
count = 4,
|
||
grid = {
|
||
"minecraft:birch_log", nil, nil,
|
||
nil, nil, nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:stick",
|
||
count = 4,
|
||
grid = {
|
||
"minecraft:oak_planks", nil, nil,
|
||
"minecraft:oak_planks", nil, nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:oak_slab",
|
||
count = 6,
|
||
grid = {
|
||
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
|
||
nil, nil, nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:torch",
|
||
count = 4,
|
||
grid = {
|
||
"minecraft:coal", nil, nil,
|
||
"minecraft:stick", nil, nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
-- Crafting & storage
|
||
{
|
||
output = "minecraft:crafting_table",
|
||
count = 1,
|
||
grid = {
|
||
"minecraft:oak_planks", "minecraft:oak_planks", nil,
|
||
"minecraft:oak_planks", "minecraft:oak_planks", nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:chest",
|
||
count = 1,
|
||
grid = {
|
||
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
|
||
"minecraft:oak_planks", nil, "minecraft:oak_planks",
|
||
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:barrel",
|
||
count = 1,
|
||
grid = {
|
||
"minecraft:oak_planks", "minecraft:oak_slab", "minecraft:oak_planks",
|
||
"minecraft:oak_planks", nil, "minecraft:oak_planks",
|
||
"minecraft:oak_planks", "minecraft:oak_slab", "minecraft:oak_planks",
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:hopper",
|
||
count = 1,
|
||
grid = {
|
||
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
|
||
"minecraft:iron_ingot", "minecraft:chest", "minecraft:iron_ingot",
|
||
nil, "minecraft:iron_ingot", nil,
|
||
},
|
||
},
|
||
-- Building
|
||
{
|
||
output = "minecraft:furnace",
|
||
count = 1,
|
||
grid = {
|
||
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
|
||
"minecraft:cobblestone", nil, "minecraft:cobblestone",
|
||
"minecraft:cobblestone", "minecraft:cobblestone", "minecraft:cobblestone",
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:ladder",
|
||
count = 3,
|
||
grid = {
|
||
"minecraft:stick", nil, "minecraft:stick",
|
||
"minecraft:stick", "minecraft:stick", "minecraft:stick",
|
||
"minecraft:stick", nil, "minecraft:stick",
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:glass_pane",
|
||
count = 16,
|
||
grid = {
|
||
"minecraft:glass", "minecraft:glass", "minecraft:glass",
|
||
"minecraft:glass", "minecraft:glass", "minecraft:glass",
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:iron_bars",
|
||
count = 16,
|
||
grid = {
|
||
"minecraft:iron_ingot", "minecraft:iron_ingot", "minecraft:iron_ingot",
|
||
"minecraft:iron_ingot", "minecraft:iron_ingot", "minecraft:iron_ingot",
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
-- Tools & combat
|
||
{
|
||
output = "minecraft:bucket",
|
||
count = 1,
|
||
grid = {
|
||
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
|
||
nil, "minecraft:iron_ingot", nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:arrow",
|
||
count = 4,
|
||
grid = {
|
||
"minecraft:flint", nil, nil,
|
||
"minecraft:stick", nil, nil,
|
||
"minecraft:feather", nil, nil,
|
||
},
|
||
},
|
||
-- Redstone
|
||
{
|
||
output = "minecraft:piston",
|
||
count = 1,
|
||
grid = {
|
||
"minecraft:oak_planks", "minecraft:oak_planks", "minecraft:oak_planks",
|
||
"minecraft:cobblestone", "minecraft:iron_ingot", "minecraft:cobblestone",
|
||
"minecraft:cobblestone", "minecraft:redstone", "minecraft:cobblestone",
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:rail",
|
||
count = 16,
|
||
grid = {
|
||
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
|
||
"minecraft:iron_ingot", "minecraft:stick", "minecraft:iron_ingot",
|
||
"minecraft:iron_ingot", nil, "minecraft:iron_ingot",
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:powered_rail",
|
||
count = 6,
|
||
grid = {
|
||
"minecraft:gold_ingot", nil, "minecraft:gold_ingot",
|
||
"minecraft:gold_ingot", "minecraft:stick", "minecraft:gold_ingot",
|
||
"minecraft:gold_ingot", "minecraft:redstone", "minecraft:gold_ingot",
|
||
},
|
||
},
|
||
-- Food & misc
|
||
{
|
||
output = "minecraft:bread",
|
||
count = 1,
|
||
grid = {
|
||
"minecraft:wheat", "minecraft:wheat", "minecraft:wheat",
|
||
nil, nil, nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:paper",
|
||
count = 3,
|
||
grid = {
|
||
"minecraft:sugar_cane", "minecraft:sugar_cane", "minecraft:sugar_cane",
|
||
nil, nil, nil,
|
||
nil, nil, nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:compass",
|
||
count = 1,
|
||
grid = {
|
||
nil, "minecraft:iron_ingot", nil,
|
||
"minecraft:iron_ingot", "minecraft:redstone", "minecraft:iron_ingot",
|
||
nil, "minecraft:iron_ingot", nil,
|
||
},
|
||
},
|
||
{
|
||
output = "minecraft:clock",
|
||
count = 1,
|
||
grid = {
|
||
nil, "minecraft:gold_ingot", nil,
|
||
"minecraft:gold_ingot", "minecraft:redstone", "minecraft:gold_ingot",
|
||
nil, "minecraft:gold_ingot", nil,
|
||
},
|
||
},
|
||
}
|
||
|
||
-------------------------------------------------
|
||
-- Low-stock alerts
|
||
-- When a tracked item drops below 'min', an alert
|
||
-- is shown on the inventory monitor.
|
||
-------------------------------------------------
|
||
|
||
local LOW_STOCK_ALERTS = {
|
||
{ name = "minecraft:coal", min = 64, label = "Coal" },
|
||
{ name = "minecraft:charcoal", min = 64, label = "Charcoal" },
|
||
{ name = "minecraft:torch", min = 64, label = "Torches" },
|
||
{ name = "minecraft:arrow", min = 64, label = "Arrows" },
|
||
{ name = "minecraft:cooked_beef", min = 32, label = "Steak" },
|
||
{ name = "minecraft:cooked_porkchop",min = 32, label = "Porkchops" },
|
||
{ name = "minecraft:bread", min = 32, label = "Bread" },
|
||
{ name = "minecraft:iron_ingot", min = 64, label = "Iron" },
|
||
{ name = "minecraft:gold_ingot", min = 32, label = "Gold" },
|
||
{ name = "minecraft:diamond", min = 16, label = "Diamond" },
|
||
{ name = "minecraft:bone_meal", min = 32, label = "Bone Meal" },
|
||
{ name = "minecraft:oak_planks", min = 64, label = "Planks" },
|
||
{ name = "minecraft:cobblestone", min = 128, label = "Cobblestone" },
|
||
}
|
||
|
||
-- Active alerts (populated by checkAlerts)
|
||
local activeAlerts = {}
|
||
|
||
-------------------------------------------------
|
||
-- Cached data (updated by background scanner)
|
||
-------------------------------------------------
|
||
|
||
local cache = {
|
||
catalogue = {}, -- itemName -> { {chest=name, total=N}, ... }
|
||
itemList = {}, -- sorted list of { name, total }
|
||
grandTotal = 0,
|
||
chestCount = 0,
|
||
totalSlots = 0,
|
||
usedSlots = 0,
|
||
freeSlots = 0,
|
||
usedRatio = 0,
|
||
dropperOk = false,
|
||
barrelOk = false,
|
||
furnaceCount = 0,
|
||
furnaceStatus = {}, -- per-furnace { name, type, input, fuel, output, active }
|
||
}
|
||
|
||
-------------------------------------------------
|
||
-- Activity state (shown on monitor)
|
||
-------------------------------------------------
|
||
|
||
local activity = {
|
||
sorting = false, -- barrel sort in progress
|
||
dispensing = false, -- order in progress
|
||
scanning = false, -- background scan in progress
|
||
smelting = false, -- auto-smelt in progress
|
||
defragging = false, -- defrag in progress
|
||
composting = false, -- auto-compost in progress
|
||
crafting = false, -- crafting in progress
|
||
}
|
||
|
||
-------------------------------------------------
|
||
-- Instant cache adjustment (no scan needed)
|
||
-------------------------------------------------
|
||
|
||
--- Adjust cached counts for a single item move.
|
||
-- delta > 0 means items arrived in chestName;
|
||
-- delta < 0 means items left chestName.
|
||
local function adjustCache(itemName, chestName, delta)
|
||
if delta == 0 then return end
|
||
|
||
-- 1) catalogue: per-chest totals
|
||
local cat = cache.catalogue
|
||
if delta > 0 then
|
||
-- Items added to a chest
|
||
if not cat[itemName] then cat[itemName] = {} end
|
||
local found = false
|
||
for _, entry in ipairs(cat[itemName]) do
|
||
if entry.chest == chestName then
|
||
entry.total = entry.total + delta
|
||
found = true
|
||
break
|
||
end
|
||
end
|
||
if not found then
|
||
table.insert(cat[itemName], { chest = chestName, total = delta })
|
||
end
|
||
else
|
||
-- Items removed from a chest
|
||
if cat[itemName] then
|
||
for idx, entry in ipairs(cat[itemName]) do
|
||
if entry.chest == chestName then
|
||
entry.total = entry.total + delta -- delta is negative
|
||
if entry.total <= 0 then
|
||
table.remove(cat[itemName], idx)
|
||
end
|
||
break
|
||
end
|
||
end
|
||
-- Remove item from catalogue entirely if no sources left
|
||
if #cat[itemName] == 0 then
|
||
cat[itemName] = nil
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 2) Rebuild itemList from catalogue (lightweight — just totals)
|
||
local itemList = {}
|
||
local grandTotal = 0
|
||
for name, sources in pairs(cat) do
|
||
local total = 0
|
||
for _, s in ipairs(sources) do total = total + s.total end
|
||
grandTotal = grandTotal + total
|
||
table.insert(itemList, { name = name, total = total })
|
||
end
|
||
table.sort(itemList, function(a, b) return a.total > b.total end)
|
||
|
||
cache.itemList = itemList
|
||
cache.grandTotal = grandTotal
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Inventory helpers
|
||
-------------------------------------------------
|
||
|
||
local function getChests()
|
||
local chests = {}
|
||
for _, name in ipairs(peripheral.getNames()) do
|
||
if peripheral.getType(name) == "minecraft:chest" then
|
||
table.insert(chests, name)
|
||
end
|
||
end
|
||
return chests
|
||
end
|
||
|
||
local function getFurnaces()
|
||
local furnaces = {}
|
||
for _, ftype in ipairs(FURNACE_TYPES) do
|
||
for _, name in ipairs(peripheral.getNames()) do
|
||
if peripheral.getType(name) == ftype then
|
||
table.insert(furnaces, name)
|
||
end
|
||
end
|
||
end
|
||
return furnaces
|
||
end
|
||
|
||
local function refreshFurnaceStatus()
|
||
local furnaces = getFurnaces()
|
||
local status = {}
|
||
for _, fname in ipairs(furnaces) do
|
||
local furnace = peripheral.wrap(fname)
|
||
if furnace then
|
||
local contents = furnace.list()
|
||
local entry = {
|
||
name = fname,
|
||
type = peripheral.getType(fname),
|
||
input = contents[SLOT_INPUT] or nil,
|
||
fuel = contents[SLOT_FUEL] or nil,
|
||
output = contents[SLOT_OUTPUT] or nil,
|
||
active = (contents[SLOT_INPUT] ~= nil and contents[SLOT_FUEL] ~= nil),
|
||
}
|
||
table.insert(status, entry)
|
||
end
|
||
end
|
||
cache.furnaceStatus = status
|
||
end
|
||
|
||
local function scanInventory(deviceName)
|
||
local inv = peripheral.wrap(deviceName)
|
||
if not inv then return {} end
|
||
local result = {}
|
||
for slot, item in pairs(inv.list()) do
|
||
if not result[item.name] then
|
||
result[item.name] = { total = 0, slots = {} }
|
||
end
|
||
result[item.name].total = result[item.name].total + item.count
|
||
result[item.name].slots[slot] = { name = item.name, count = item.count }
|
||
end
|
||
return result
|
||
end
|
||
|
||
-- Full scan: updates the global cache
|
||
-- onProgress(current, total, chestName) is called per chest if provided
|
||
local function refreshCache(onProgress)
|
||
activity.scanning = true
|
||
|
||
local chests = getChests()
|
||
local catalogue = {}
|
||
local totalSlots = 0
|
||
local usedSlots = 0
|
||
|
||
for ci, chest in ipairs(chests) do
|
||
if onProgress then onProgress(ci, #chests, chest) end
|
||
local inv = peripheral.wrap(chest)
|
||
if inv then
|
||
totalSlots = totalSlots + inv.size()
|
||
local contents = inv.list()
|
||
for slot, item in pairs(contents) do
|
||
usedSlots = usedSlots + 1
|
||
if not catalogue[item.name] then
|
||
catalogue[item.name] = {}
|
||
end
|
||
-- Accumulate per-chest totals
|
||
local found = false
|
||
for _, entry in ipairs(catalogue[item.name]) do
|
||
if entry.chest == chest then
|
||
entry.total = entry.total + item.count
|
||
found = true
|
||
break
|
||
end
|
||
end
|
||
if not found then
|
||
table.insert(catalogue[item.name], { chest = chest, total = item.count })
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Build sorted item list
|
||
local itemList = {}
|
||
local grandTotal = 0
|
||
for itemName, sources in pairs(catalogue) do
|
||
local total = 0
|
||
for _, s in ipairs(sources) do total = total + s.total end
|
||
grandTotal = grandTotal + total
|
||
table.insert(itemList, { name = itemName, total = total })
|
||
end
|
||
table.sort(itemList, function(a, b) return a.total > b.total end)
|
||
|
||
-- Update cache atomically
|
||
cache.catalogue = catalogue
|
||
cache.itemList = itemList
|
||
cache.grandTotal = grandTotal
|
||
cache.chestCount = #chests
|
||
cache.totalSlots = totalSlots
|
||
cache.usedSlots = usedSlots
|
||
cache.freeSlots = totalSlots - usedSlots
|
||
cache.usedRatio = totalSlots > 0 and (usedSlots / totalSlots) or 0
|
||
cache.dropperOk = peripheral.wrap(DROPPER_NAME) ~= nil
|
||
cache.barrelOk = peripheral.wrap(BARREL_NAME) ~= nil
|
||
|
||
-- Count furnaces
|
||
local furnaceCount = 0
|
||
for _, ftype in ipairs(FURNACE_TYPES) do
|
||
for _, name in ipairs(peripheral.getNames()) do
|
||
if peripheral.getType(name) == ftype then
|
||
furnaceCount = furnaceCount + 1
|
||
end
|
||
end
|
||
end
|
||
cache.furnaceCount = furnaceCount
|
||
|
||
-- Scan furnace contents for smelter dashboard
|
||
refreshFurnaceStatus()
|
||
|
||
activity.scanning = false
|
||
|
||
-- Persist cache to disk
|
||
pcall(function()
|
||
local data = {
|
||
catalogue = cache.catalogue,
|
||
itemList = cache.itemList,
|
||
grandTotal = cache.grandTotal,
|
||
chestCount = cache.chestCount,
|
||
totalSlots = cache.totalSlots,
|
||
usedSlots = cache.usedSlots,
|
||
freeSlots = cache.freeSlots,
|
||
usedRatio = cache.usedRatio,
|
||
dropperOk = cache.dropperOk,
|
||
barrelOk = cache.barrelOk,
|
||
furnaceCount = cache.furnaceCount,
|
||
furnaceStatus = cache.furnaceStatus,
|
||
savedAt = os.epoch("utc"),
|
||
}
|
||
local f = fs.open(CACHE_FILE, "w")
|
||
f.write(textutils.serialise(data))
|
||
f.close()
|
||
end)
|
||
end
|
||
|
||
-- Load cache from disk (returns true if loaded)
|
||
local function loadCacheFromDisk()
|
||
if not fs.exists(CACHE_FILE) then return false end
|
||
local ok, err = pcall(function()
|
||
local f = fs.open(CACHE_FILE, "r")
|
||
local raw = f.readAll()
|
||
f.close()
|
||
local data = textutils.unserialise(raw)
|
||
if data and data.catalogue and data.itemList then
|
||
cache.catalogue = data.catalogue
|
||
cache.itemList = data.itemList
|
||
cache.grandTotal = data.grandTotal or 0
|
||
cache.chestCount = data.chestCount or 0
|
||
cache.totalSlots = data.totalSlots or 0
|
||
cache.usedSlots = data.usedSlots or 0
|
||
cache.freeSlots = data.freeSlots or 0
|
||
cache.usedRatio = data.usedRatio or 0
|
||
cache.dropperOk = data.dropperOk or false
|
||
cache.barrelOk = data.barrelOk or false
|
||
cache.furnaceCount = data.furnaceCount or 0
|
||
cache.furnaceStatus = data.furnaceStatus or {}
|
||
else
|
||
error("invalid cache data")
|
||
end
|
||
end)
|
||
if not ok then
|
||
print("[WARN] Could not load cache: " .. tostring(err))
|
||
return false
|
||
end
|
||
return true
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Monitor setup
|
||
-------------------------------------------------
|
||
|
||
local mon = nil
|
||
local monName = nil
|
||
local smelterMon = nil
|
||
local smelterMonName = nil
|
||
local networkModem = nil
|
||
local networkModemName = nil
|
||
local craftTurtleName = nil
|
||
|
||
local function setupMonitor()
|
||
mon = peripheral.wrap(MONITOR_SIDE)
|
||
if mon and mon.setTextScale then
|
||
monName = MONITOR_SIDE
|
||
else
|
||
mon = nil
|
||
end
|
||
if not mon then
|
||
-- Search for a monitor on the network (skip smelter side)
|
||
for _, name in ipairs(peripheral.getNames()) do
|
||
if peripheral.getType(name) == "monitor" and name ~= SMELTER_MONITOR_SIDE then
|
||
mon = peripheral.wrap(name)
|
||
monName = name
|
||
break
|
||
end
|
||
end
|
||
end
|
||
if not mon then return false end
|
||
mon.setTextScale(0.5)
|
||
mon.clear()
|
||
return true
|
||
end
|
||
|
||
local function setupSmelterMonitor()
|
||
smelterMon = peripheral.wrap(SMELTER_MONITOR_SIDE)
|
||
if smelterMon and smelterMon.setTextScale then
|
||
smelterMonName = SMELTER_MONITOR_SIDE
|
||
else
|
||
smelterMon = nil
|
||
end
|
||
if not smelterMon then
|
||
-- Search for a second monitor on the network
|
||
for _, name in ipairs(peripheral.getNames()) do
|
||
if peripheral.getType(name) == "monitor" and name ~= monName then
|
||
smelterMon = peripheral.wrap(name)
|
||
smelterMonName = name
|
||
break
|
||
end
|
||
end
|
||
end
|
||
if not smelterMon then return false end
|
||
smelterMon.setTextScale(0.5)
|
||
smelterMon.clear()
|
||
return true
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- UI State
|
||
-------------------------------------------------
|
||
|
||
local selectedAmount = 1
|
||
local amountOptions = {1, 4, 8, 16, 32, 64}
|
||
local statusMessage = ""
|
||
local statusColor = colors.white
|
||
local statusTimer = 0
|
||
|
||
local touchZones = {}
|
||
local pendingZones = {}
|
||
local needsRedraw = true
|
||
|
||
local currentPage = 1
|
||
local totalPages = 1
|
||
local searchQuery = ""
|
||
local showKeyboard = false
|
||
|
||
-- Keyboard layout
|
||
local kbRows = {
|
||
{"Q","W","E","R","T","Y","U","I","O","P"},
|
||
{"A","S","D","F","G","H","J","K","L"},
|
||
{"Z","X","C","V","B","N","M"},
|
||
}
|
||
|
||
-------------------------------------------------
|
||
-- Smelter dashboard state
|
||
-------------------------------------------------
|
||
|
||
local smelterView = "status" -- "status" or "recipes"
|
||
local smelterPage = 1
|
||
local smelterTotalPages = 1
|
||
local smelterTouchZones = {}
|
||
local smelterPendingZones = {}
|
||
local smelterNeedsRedraw = true
|
||
local smeltingPaused = false
|
||
local disabledRecipes = {} -- { ["minecraft:raw_iron"] = true }
|
||
|
||
local function loadDisabledRecipes()
|
||
if not fs.exists(DISABLED_RECIPES_FILE) then return end
|
||
pcall(function()
|
||
local f = fs.open(DISABLED_RECIPES_FILE, "r")
|
||
local raw = f.readAll()
|
||
f.close()
|
||
local data = textutils.unserialise(raw)
|
||
if type(data) == "table" then
|
||
if data.disabled then disabledRecipes = data.disabled end
|
||
if data.paused ~= nil then smeltingPaused = data.paused end
|
||
end
|
||
end)
|
||
end
|
||
|
||
local function saveDisabledRecipes()
|
||
pcall(function()
|
||
local f = fs.open(DISABLED_RECIPES_FILE, "w")
|
||
f.write(textutils.serialise({ disabled = disabledRecipes, paused = smeltingPaused }))
|
||
f.close()
|
||
end)
|
||
end
|
||
|
||
-- Get items filtered by search query
|
||
local function getFilteredItems()
|
||
local filtered = {}
|
||
for _, item in ipairs(cache.itemList) do
|
||
if searchQuery == "" then
|
||
table.insert(filtered, item)
|
||
else
|
||
local lower = item.name:lower():gsub("minecraft:", ""):gsub("_", " ")
|
||
if lower:find(searchQuery:lower(), 1, true) then
|
||
table.insert(filtered, item)
|
||
end
|
||
end
|
||
end
|
||
return filtered
|
||
end
|
||
|
||
local function addZone(x1, y1, x2, y2, action, data)
|
||
table.insert(pendingZones, {
|
||
x1 = x1, y1 = y1, x2 = x2, y2 = y2,
|
||
action = action, data = data
|
||
})
|
||
end
|
||
|
||
local function hitTest(x, y)
|
||
for _, zone in ipairs(touchZones) do
|
||
if x >= zone.x1 and x <= zone.x2 and y >= zone.y1 and y <= zone.y2 then
|
||
return zone.action, zone.data
|
||
end
|
||
end
|
||
return nil, nil
|
||
end
|
||
|
||
local function addSmelterZone(x1, y1, x2, y2, action, data)
|
||
table.insert(smelterPendingZones, {
|
||
x1 = x1, y1 = y1, x2 = x2, y2 = y2,
|
||
action = action, data = data
|
||
})
|
||
end
|
||
|
||
local function smelterHitTest(x, y)
|
||
for _, zone in ipairs(smelterTouchZones) do
|
||
if x >= zone.x1 and x <= zone.x2 and y >= zone.y1 and y <= zone.y2 then
|
||
return zone.action, zone.data
|
||
end
|
||
end
|
||
return nil, nil
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Drawing helpers (write to draw target)
|
||
-------------------------------------------------
|
||
|
||
local draw = nil
|
||
|
||
local function monWrite(x, y, text, fg, bg)
|
||
draw.setCursorPos(x, y)
|
||
if fg then draw.setTextColor(fg) end
|
||
if bg then draw.setBackgroundColor(bg) end
|
||
draw.write(text)
|
||
end
|
||
|
||
local function monFill(y, color)
|
||
local w, _ = draw.getSize()
|
||
draw.setCursorPos(1, y)
|
||
draw.setBackgroundColor(color)
|
||
draw.write(string.rep(" ", w))
|
||
end
|
||
|
||
local function monCenter(y, text, fg, bg)
|
||
local w, _ = draw.getSize()
|
||
local x = math.floor((w - #text) / 2) + 1
|
||
monWrite(x, y, text, fg, bg)
|
||
end
|
||
|
||
local function monBar(x, y, width, ratio, barColor, bgColor)
|
||
local filled = math.floor(ratio * width)
|
||
draw.setCursorPos(x, y)
|
||
draw.setBackgroundColor(barColor)
|
||
draw.write(string.rep(" ", filled))
|
||
draw.setBackgroundColor(bgColor)
|
||
draw.write(string.rep(" ", width - filled))
|
||
end
|
||
|
||
local function drawButton(x, y, text, fg, bg, padLeft, padRight)
|
||
padLeft = padLeft or 1
|
||
padRight = padRight or 1
|
||
local full = string.rep(" ", padLeft) .. text .. string.rep(" ", padRight)
|
||
monWrite(x, y, full, fg, bg)
|
||
return x, y, x + #full - 1, y
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Dashboard (reads ONLY from cache — no peripheral calls — instant)
|
||
-------------------------------------------------
|
||
|
||
local function drawDashboard()
|
||
if not mon then return end
|
||
|
||
local w, h = mon.getSize()
|
||
pendingZones = {}
|
||
|
||
-- Offscreen buffer
|
||
draw = window.create(mon, 1, 1, w, h, false)
|
||
draw.setBackgroundColor(colors.black)
|
||
draw.clear()
|
||
|
||
-- ===== Title bar =====
|
||
monFill(1, colors.blue)
|
||
monCenter(1, " ** INVENTORY MANAGER ** ", colors.white, colors.blue)
|
||
|
||
-- ===== Status bar =====
|
||
monFill(2, colors.gray)
|
||
local statusParts = {}
|
||
table.insert(statusParts, string.format(" Chests: %d", cache.chestCount))
|
||
table.insert(statusParts, cache.dropperOk and "Dropper: OK" or "Dropper: --")
|
||
table.insert(statusParts, cache.barrelOk and "Barrel: OK" or "Barrel: --")
|
||
if cache.furnaceCount and cache.furnaceCount > 0 then
|
||
table.insert(statusParts, string.format("Furnaces: %d", cache.furnaceCount))
|
||
end
|
||
|
||
-- Activity indicators
|
||
local actParts = {}
|
||
if activity.sorting then table.insert(actParts, "SORTING") end
|
||
if activity.dispensing then table.insert(actParts, "DISPENSING") end
|
||
if activity.smelting then table.insert(actParts, "SMELTING") end
|
||
if activity.scanning then table.insert(actParts, "SCANNING") end
|
||
if activity.defragging then table.insert(actParts, "DEFRAG") end
|
||
if activity.composting then table.insert(actParts, "COMPOST") end
|
||
|
||
monWrite(2, 2, table.concat(statusParts, " | "), colors.white, colors.gray)
|
||
|
||
if #actParts > 0 then
|
||
local actStr = " " .. table.concat(actParts, " | ") .. " "
|
||
monWrite(w - #actStr, 2, actStr, colors.white, colors.orange)
|
||
end
|
||
|
||
-- ===== Divider =====
|
||
monFill(3, colors.lightBlue)
|
||
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.cyan, colors.lightBlue)
|
||
|
||
-- ===== Storage capacity =====
|
||
monFill(4, colors.black)
|
||
local capLabel = string.format(" Storage: %d/%d slots (%d free)",
|
||
cache.usedSlots, cache.totalSlots, cache.freeSlots)
|
||
monWrite(2, 4, capLabel, colors.lightGray, colors.black)
|
||
|
||
local barStart = #capLabel + 4
|
||
local barWidth = w - barStart - 2
|
||
if barWidth > 4 then
|
||
local barColor = colors.lime
|
||
if cache.usedRatio > 0.9 then barColor = colors.red
|
||
elseif cache.usedRatio > 0.7 then barColor = colors.orange
|
||
elseif cache.usedRatio > 0.5 then barColor = colors.yellow
|
||
end
|
||
monBar(barStart, 4, barWidth, cache.usedRatio, barColor, colors.gray)
|
||
local pctStr = string.format(" %d%% ", math.floor(cache.usedRatio * 100))
|
||
local pctX = barStart + math.floor(barWidth / 2) - math.floor(#pctStr / 2)
|
||
monWrite(pctX, 4, pctStr, colors.white, barColor)
|
||
end
|
||
|
||
-- ===== Amount selector (row 5) =====
|
||
monFill(5, colors.black)
|
||
monWrite(2, 5, "Qty:", colors.lightGray, colors.black)
|
||
local btnX = 7
|
||
for _, amt in ipairs(amountOptions) do
|
||
local label = tostring(amt)
|
||
local bg = (amt == selectedAmount) and colors.cyan or colors.gray
|
||
local fg = (amt == selectedAmount) and colors.white or colors.lightGray
|
||
local x1, y1, x2, y2 = drawButton(btnX, 5, label, fg, bg)
|
||
addZone(x1, y1, x2, y2, "amount", amt)
|
||
btnX = x2 + 2
|
||
end
|
||
|
||
-- Refresh button
|
||
local refreshBg = activity.scanning and colors.yellow or colors.green
|
||
local refreshFg = activity.scanning and colors.black or colors.white
|
||
local refreshTxt = activity.scanning and "Scanning" or "Refresh"
|
||
local scanX = w - #refreshTxt - 3
|
||
local sx1, sy1, sx2, sy2 = drawButton(scanX, 5, refreshTxt, refreshFg, refreshBg, 1, 1)
|
||
addZone(sx1, sy1, sx2, sy2, "scan", nil)
|
||
|
||
-- ===== Search bar + Pagination (row 6) =====
|
||
monFill(6, colors.black)
|
||
|
||
-- Keyboard toggle button
|
||
local kbLabel = showKeyboard and " X " or " ? "
|
||
local kbBg = showKeyboard and colors.red or colors.purple
|
||
monWrite(2, 6, kbLabel, colors.white, kbBg)
|
||
addZone(2, 6, 4, 6, "kb_toggle", nil)
|
||
|
||
-- Search query display
|
||
local queryDisplay = searchQuery
|
||
if showKeyboard then
|
||
queryDisplay = queryDisplay .. "|"
|
||
elseif queryDisplay == "" then
|
||
queryDisplay = "search..."
|
||
end
|
||
local fieldW = math.floor(w * 0.4)
|
||
if fieldW < 10 then fieldW = 10 end
|
||
local displayText = queryDisplay:sub(1, fieldW)
|
||
displayText = displayText .. string.rep("_", math.max(0, fieldW - #displayText))
|
||
monWrite(6, 6, displayText,
|
||
(searchQuery == "" and not showKeyboard) and colors.gray or colors.white,
|
||
colors.black)
|
||
addZone(6, 6, 5 + fieldW, 6, "kb_toggle", nil)
|
||
|
||
-- Filter items
|
||
local filteredItems = getFilteredItems()
|
||
|
||
-- Pagination
|
||
local maxRows = h - 10
|
||
if maxRows < 1 then maxRows = 1 end
|
||
totalPages = math.max(1, math.ceil(#filteredItems / maxRows))
|
||
if currentPage > totalPages then currentPage = totalPages end
|
||
if currentPage < 1 then currentPage = 1 end
|
||
|
||
-- Page controls (right side of row 6)
|
||
local pageStr = string.format("Pg %d/%d", currentPage, totalPages)
|
||
local navW = 3 + 1 + #pageStr + 1 + 3
|
||
local navX = w - navW
|
||
|
||
if currentPage > 1 then
|
||
monWrite(navX, 6, " < ", colors.white, colors.gray)
|
||
addZone(navX, 6, navX + 2, 6, "page_prev", nil)
|
||
else
|
||
monWrite(navX, 6, " < ", colors.lightGray, colors.black)
|
||
end
|
||
|
||
monWrite(navX + 4, 6, pageStr, colors.lightGray, colors.black)
|
||
|
||
local nextX = navX + 4 + #pageStr + 1
|
||
if currentPage < totalPages then
|
||
monWrite(nextX, 6, " > ", colors.white, colors.gray)
|
||
addZone(nextX, 6, nextX + 2, 6, "page_next", nil)
|
||
else
|
||
monWrite(nextX, 6, " > ", colors.lightGray, colors.black)
|
||
end
|
||
|
||
-- ===== Column headers (row 7) =====
|
||
local row = 7
|
||
monFill(row, colors.gray)
|
||
monWrite(2, row, "#", colors.lightGray, colors.gray)
|
||
monWrite(5, row, "Item", colors.lightGray, colors.gray)
|
||
monWrite(w - 22, row, "Qty", colors.lightGray, colors.gray)
|
||
monWrite(w - 14, row, "Stock", colors.lightGray, colors.gray)
|
||
monWrite(w - 1, row, ">", colors.lightGray, colors.gray)
|
||
row = row + 1
|
||
|
||
-- ===== Item rows (paginated + filtered) =====
|
||
local maxCount = 0
|
||
for _, item in ipairs(filteredItems) do
|
||
if item.total > maxCount then maxCount = item.total end
|
||
end
|
||
if maxCount == 0 then maxCount = 1 end
|
||
|
||
local startIdx = (currentPage - 1) * maxRows + 1
|
||
local endIdx = math.min(startIdx + maxRows - 1, #filteredItems)
|
||
|
||
if #filteredItems == 0 then
|
||
monFill(8, colors.black)
|
||
monFill(9, colors.black)
|
||
if searchQuery ~= "" then
|
||
monCenter(9, "No items match \"" .. searchQuery .. "\"", colors.gray, colors.black)
|
||
else
|
||
monCenter(9, "No items in storage", colors.gray, colors.black)
|
||
end
|
||
row = 10
|
||
else
|
||
for i = startIdx, endIdx do
|
||
local item = filteredItems[i]
|
||
local y = row
|
||
local short = item.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||
short = short:sub(1,1):upper() .. short:sub(2)
|
||
|
||
local maxNameLen = w - 30
|
||
if #short > maxNameLen then
|
||
short = short:sub(1, maxNameLen - 2) .. ".."
|
||
end
|
||
|
||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||
monFill(y, rowBg)
|
||
|
||
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
|
||
monWrite(5, y, short, colors.white, rowBg)
|
||
monWrite(w - 22, y, tostring(item.total), colors.yellow, rowBg)
|
||
|
||
local ratio = item.total / maxCount
|
||
local barColor = colors.lime
|
||
if ratio < 0.25 then barColor = colors.red
|
||
elseif ratio < 0.5 then barColor = colors.orange
|
||
end
|
||
monBar(w - 14, y, 12, ratio, barColor, rowBg == colors.gray and colors.lightGray or colors.gray)
|
||
|
||
monWrite(w - 1, y, ">", colors.orange, rowBg)
|
||
addZone(1, y, w, y, "order", item.name)
|
||
|
||
row = row + 1
|
||
end
|
||
end
|
||
|
||
-- Fill remaining empty item rows
|
||
local lastItemRow = h - 3
|
||
while row <= lastItemRow do
|
||
monFill(row, colors.black)
|
||
row = row + 1
|
||
end
|
||
|
||
if showKeyboard then
|
||
-- ===== Keyboard overlay (bottom 3 rows: h-2, h-1, h) =====
|
||
local keyW = 3
|
||
local keyGap = 1
|
||
|
||
local kbDefs = {
|
||
{ keys = kbRows[1], specials = {{ label = " Bksp ", action = "kb_bksp", bg = colors.red }} },
|
||
{ keys = kbRows[2], specials = {{ label = " Done ", action = "kb_done", bg = colors.green }} },
|
||
{ keys = kbRows[3], specials = {
|
||
{ label = " Space ", action = "kb_space", bg = colors.lightGray },
|
||
{ label = " Clr ", action = "kb_clear", bg = colors.orange },
|
||
}},
|
||
}
|
||
|
||
for rowIdx, def in ipairs(kbDefs) do
|
||
local y = h - 3 + rowIdx
|
||
monFill(y, colors.black)
|
||
|
||
-- Calculate total row width for centering
|
||
local keysW = #def.keys * keyW + math.max(0, #def.keys - 1) * keyGap
|
||
local specialsW = 0
|
||
for _, sp in ipairs(def.specials) do
|
||
specialsW = specialsW + keyGap + #sp.label
|
||
end
|
||
local rowW = keysW + specialsW
|
||
local x = math.floor((w - rowW) / 2) + 1
|
||
|
||
-- Draw letter keys
|
||
for ki, key in ipairs(def.keys) do
|
||
monWrite(x, y, " " .. key .. " ", colors.white, colors.gray)
|
||
addZone(x, y, x + keyW - 1, y, "kb_key", key:lower())
|
||
x = x + keyW
|
||
if ki < #def.keys then x = x + keyGap end
|
||
end
|
||
|
||
-- Draw special keys
|
||
for _, sp in ipairs(def.specials) do
|
||
x = x + keyGap
|
||
monWrite(x, y, sp.label, colors.white, sp.bg)
|
||
addZone(x, y, x + #sp.label - 1, y, sp.action, nil)
|
||
x = x + #sp.label
|
||
end
|
||
end
|
||
else
|
||
-- ===== Status message (h-2) =====
|
||
monFill(h - 2, colors.black)
|
||
if #activeAlerts > 0 then
|
||
-- Show low-stock alerts scrolling through them
|
||
local alertIdx = math.floor(os.epoch("utc") / 2000) % #activeAlerts + 1
|
||
local a = activeAlerts[alertIdx]
|
||
local alertMsg = string.format(" LOW STOCK: %s (%d/%d) ", a.label, a.current, a.min)
|
||
monCenter(h - 2, alertMsg, colors.white, colors.red)
|
||
elseif statusTimer > 0 and #statusMessage > 0 then
|
||
monCenter(h - 2, statusMessage, statusColor, colors.black)
|
||
end
|
||
|
||
-- ===== Footer (h-1) =====
|
||
monFill(h - 1, colors.gray)
|
||
local footerLeft = string.format(" Total: %d items | %d types ",
|
||
cache.grandTotal, #cache.itemList)
|
||
monWrite(2, h - 1, footerLeft, colors.white, colors.gray)
|
||
|
||
if searchQuery ~= "" then
|
||
local filterNote = string.format("| Showing %d ", #filteredItems)
|
||
monWrite(2 + #footerLeft + 1, h - 1, filterNote, colors.yellow, colors.gray)
|
||
end
|
||
|
||
local timeStr = textutils.formatTime(os.time(), true)
|
||
monWrite(w - #timeStr - 1, h - 1, timeStr, colors.lightGray, colors.gray)
|
||
|
||
-- ===== Bottom accent (h) =====
|
||
monFill(h, colors.blue)
|
||
local bottomMsg = " Tap item to order "
|
||
if activity.dispensing then
|
||
bottomMsg = " DISPENSING... "
|
||
elseif activity.smelting then
|
||
bottomMsg = " SMELTING... "
|
||
elseif activity.sorting then
|
||
bottomMsg = " SORTING BARREL... "
|
||
elseif activity.defragging then
|
||
bottomMsg = " DEFRAGMENTING... "
|
||
elseif activity.composting then
|
||
bottomMsg = " COMPOSTING... "
|
||
end
|
||
monCenter(h, bottomMsg, colors.lightBlue, colors.blue)
|
||
end
|
||
|
||
-- Flush to monitor
|
||
draw.setVisible(true)
|
||
|
||
-- Swap zones
|
||
touchZones = pendingZones
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Crafting helpers
|
||
-------------------------------------------------
|
||
|
||
--- Get ingredient counts from a recipe's grid
|
||
local function getRecipeIngredients(recipe)
|
||
local ingredients = {}
|
||
for _, item in ipairs(recipe.grid) do
|
||
if item then
|
||
ingredients[item] = (ingredients[item] or 0) + 1
|
||
end
|
||
end
|
||
return ingredients
|
||
end
|
||
|
||
--- Check if a recipe can be crafted with current stock
|
||
local function canCraftRecipe(recipe)
|
||
local ingredients = getRecipeIngredients(recipe)
|
||
for itemName, needed in pairs(ingredients) do
|
||
local have = 0
|
||
if cache.catalogue[itemName] then
|
||
for _, src in ipairs(cache.catalogue[itemName]) do
|
||
have = have + src.total
|
||
end
|
||
end
|
||
if have < needed then return false end
|
||
end
|
||
return true
|
||
end
|
||
|
||
--- How many times a recipe can be crafted
|
||
local function maxCraftBatches(recipe)
|
||
local ingredients = getRecipeIngredients(recipe)
|
||
local minBatches = math.huge
|
||
for itemName, needed in pairs(ingredients) do
|
||
local have = 0
|
||
if cache.catalogue[itemName] then
|
||
for _, src in ipairs(cache.catalogue[itemName]) do
|
||
have = have + src.total
|
||
end
|
||
end
|
||
local batches = math.floor(have / needed)
|
||
if batches < minBatches then minBatches = batches end
|
||
end
|
||
if minBatches == math.huge then return 0 end
|
||
return minBatches
|
||
end
|
||
|
||
--- Get list of missing ingredients for a recipe
|
||
local function getMissingIngredients(recipe)
|
||
local ingredients = getRecipeIngredients(recipe)
|
||
local missing = {}
|
||
for itemName, needed in pairs(ingredients) do
|
||
local have = 0
|
||
if cache.catalogue[itemName] then
|
||
for _, src in ipairs(cache.catalogue[itemName]) do
|
||
have = have + src.total
|
||
end
|
||
end
|
||
if have < needed then
|
||
table.insert(missing, {
|
||
name = itemName,
|
||
have = have,
|
||
need = needed,
|
||
})
|
||
end
|
||
end
|
||
return missing
|
||
end
|
||
|
||
--- Execute a craft via the networked turtle
|
||
-- Uses chest-side pullItems/pushItems to move items to/from the turtle,
|
||
-- since remote turtles may not expose the inventory API (list/pushItems).
|
||
local function craftItem(recipeIdx)
|
||
local recipe = CRAFTABLE[recipeIdx]
|
||
if not recipe then return false, "Invalid recipe" end
|
||
if not craftTurtleName then return false, "No turtle" end
|
||
if not networkModem then return false, "No modem" end
|
||
|
||
-- Verify the turtle is still on the network
|
||
if not peripheral.isPresent(craftTurtleName) then
|
||
return false, "Turtle offline"
|
||
end
|
||
|
||
activity.crafting = true
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
|
||
local chests = getChests()
|
||
|
||
-- Helper: pull all 16 turtle slots into chests (chest-side)
|
||
local function clearTurtle(knownItems)
|
||
for slot = 1, 16 do
|
||
for _, ch in ipairs(chests) do
|
||
local chest = peripheral.wrap(ch)
|
||
if chest then
|
||
local n = chest.pullItems(craftTurtleName, slot)
|
||
if n and n > 0 then
|
||
if knownItems and knownItems[slot] then
|
||
adjustCache(knownItems[slot], ch, n)
|
||
end
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 1. Clear turtle inventory (safety – turtle should be empty)
|
||
clearTurtle(nil)
|
||
|
||
-- 2. Push ingredients into correct grid slots, track what we placed
|
||
local placedItems = {} -- turtleSlot -> itemName
|
||
for gridPos = 1, 9 do
|
||
local itemName = recipe.grid[gridPos]
|
||
if itemName then
|
||
local turtleSlot = GRID_TO_SLOT[gridPos]
|
||
local placed = false
|
||
if cache.catalogue[itemName] then
|
||
for _, source in ipairs(cache.catalogue[itemName]) do
|
||
local chest = peripheral.wrap(source.chest)
|
||
if chest then
|
||
for slot, slotItem in pairs(chest.list()) do
|
||
if slotItem.name == itemName then
|
||
local n = chest.pushItems(craftTurtleName, slot, 1, turtleSlot)
|
||
if n and n > 0 then
|
||
adjustCache(itemName, source.chest, -n)
|
||
placedItems[turtleSlot] = itemName
|
||
placed = true
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
if placed then break end
|
||
end
|
||
end
|
||
if not placed then
|
||
-- Cleanup: pull placed items back using tracked names
|
||
print("[CRAFT] Missing ingredient, aborting")
|
||
clearTurtle(placedItems)
|
||
activity.crafting = false
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
return false, "Missing ingredient"
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 3. Signal turtle to craft
|
||
networkModem.transmit(CRAFT_CHANNEL, CRAFT_REPLY_CHANNEL, { type = "craft", count = 1 })
|
||
print(string.format("[CRAFT] Sent craft request: %s", recipe.output))
|
||
|
||
-- 4. Wait for reply with timeout
|
||
local timer = os.startTimer(5)
|
||
local success = false
|
||
local errMsg = "Turtle timeout"
|
||
while true do
|
||
local event = {os.pullEvent()}
|
||
if event[1] == "modem_message" and event[3] == CRAFT_REPLY_CHANNEL
|
||
and type(event[5]) == "table" and event[5].type == "craft_result" then
|
||
os.cancelTimer(timer)
|
||
success = event[5].success
|
||
errMsg = event[5].error
|
||
break
|
||
elseif event[1] == "timer" and event[2] == timer then
|
||
break
|
||
end
|
||
end
|
||
|
||
-- 5. Pull all items from turtle back to chests
|
||
-- On success: slots contain crafted output (recipe.output)
|
||
-- On failure/timeout: slots still have placed ingredients
|
||
local resultItems = success and {} or placedItems
|
||
if success then
|
||
-- After a successful craft, only the output item remains
|
||
for slot = 1, 16 do resultItems[slot] = recipe.output end
|
||
end
|
||
|
||
for slot = 1, 16 do
|
||
for _, ch in ipairs(chests) do
|
||
local chest = peripheral.wrap(ch)
|
||
if chest then
|
||
local n = chest.pullItems(craftTurtleName, slot)
|
||
if n and n > 0 then
|
||
local itemName = resultItems[slot] or recipe.output
|
||
adjustCache(itemName, ch, n)
|
||
print(string.format("[CRAFT] Result %s x%d -> %s", itemName, n, ch))
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
activity.crafting = false
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
|
||
if success then
|
||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||
print(string.format("[CRAFT] OK: %s x%d", short, recipe.count))
|
||
return true
|
||
else
|
||
print(string.format("[CRAFT] Failed: %s", errMsg or "unknown"))
|
||
return false, errMsg or "Craft failed"
|
||
end
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Smelter Dashboard
|
||
-------------------------------------------------
|
||
|
||
local function drawSmelterDashboard()
|
||
if not smelterMon then return end
|
||
|
||
local w, h = smelterMon.getSize()
|
||
smelterPendingZones = {}
|
||
|
||
-- Offscreen buffer
|
||
draw = window.create(smelterMon, 1, 1, w, h, false)
|
||
draw.setBackgroundColor(colors.black)
|
||
draw.clear()
|
||
|
||
-- ===== Title bar =====
|
||
monFill(1, colors.purple)
|
||
monCenter(1, " ** SMELTER DASHBOARD ** ", colors.white, colors.purple)
|
||
|
||
-- ===== Status bar =====
|
||
monFill(2, colors.gray)
|
||
local activeCount = 0
|
||
for _, fs in ipairs(cache.furnaceStatus or {}) do
|
||
if fs.active then activeCount = activeCount + 1 end
|
||
end
|
||
local statusStr = string.format(" Furnaces: %d Active: %d",
|
||
cache.furnaceCount or 0, activeCount)
|
||
monWrite(2, 2, statusStr, colors.white, colors.gray)
|
||
|
||
-- Pause/Resume button
|
||
local pauseLabel = smeltingPaused and " PAUSED " or " ACTIVE "
|
||
local pauseBg = smeltingPaused and colors.red or colors.lime
|
||
local pauseFg = smeltingPaused and colors.white or colors.black
|
||
monWrite(w - #pauseLabel, 2, pauseLabel, pauseFg, pauseBg)
|
||
addSmelterZone(w - #pauseLabel, 2, w - 1, 2, "toggle_pause", nil)
|
||
|
||
-- ===== Divider =====
|
||
monFill(3, colors.magenta)
|
||
monCenter(3, string.rep("-", math.min(w - 4, 60)), colors.pink, colors.magenta)
|
||
|
||
-- ===== Tab row =====
|
||
monFill(4, colors.black)
|
||
local tabStatusBg = smelterView == "status" and colors.purple or colors.gray
|
||
local tabSmeltBg = smelterView == "smelt" and colors.purple or colors.gray
|
||
local tabCraftBg = smelterView == "craft" and colors.purple or colors.gray
|
||
local tabMissingBg = smelterView == "missing" and colors.purple or colors.gray
|
||
local bx1, by1, bx2, by2
|
||
bx1, by1, bx2, by2 = drawButton(2, 4, "Status", colors.white, tabStatusBg)
|
||
addSmelterZone(bx1, by1, bx2, by2, "tab", "status")
|
||
|
||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Smelt", colors.white, tabSmeltBg)
|
||
addSmelterZone(bx1, by1, bx2, by2, "tab", "smelt")
|
||
|
||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Craft", colors.white, tabCraftBg)
|
||
addSmelterZone(bx1, by1, bx2, by2, "tab", "craft")
|
||
|
||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "Missing", colors.white, tabMissingBg)
|
||
addSmelterZone(bx1, by1, bx2, by2, "tab", "missing")
|
||
|
||
if smelterView == "status" then
|
||
-- ===== Furnace Status View =====
|
||
-- Column headers
|
||
monFill(5, colors.gray)
|
||
local outCol = math.floor(w * 0.40)
|
||
local fuelCol = math.floor(w * 0.65)
|
||
local statCol = w - 6
|
||
monWrite(2, 5, "#", colors.lightGray, colors.gray)
|
||
monWrite(4, 5, "T", colors.lightGray, colors.gray)
|
||
monWrite(6, 5, "Input", colors.lightGray, colors.gray)
|
||
monWrite(outCol, 5, "Output", colors.lightGray, colors.gray)
|
||
monWrite(fuelCol, 5, "Fuel", colors.lightGray, colors.gray)
|
||
monWrite(statCol, 5, "State", colors.lightGray, colors.gray)
|
||
|
||
-- Furnace rows
|
||
local furnaceList = cache.furnaceStatus or {}
|
||
local maxRows = h - 8
|
||
if maxRows < 1 then maxRows = 1 end
|
||
smelterTotalPages = math.max(1, math.ceil(#furnaceList / maxRows))
|
||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||
if smelterPage < 1 then smelterPage = 1 end
|
||
|
||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||
local endIdx = math.min(startIdx + maxRows - 1, #furnaceList)
|
||
|
||
local row = 6
|
||
if #furnaceList == 0 then
|
||
monFill(7, colors.black)
|
||
monCenter(7, "No furnaces found on network", colors.gray, colors.black)
|
||
row = 8
|
||
else
|
||
for i = startIdx, endIdx do
|
||
local fs = furnaceList[i]
|
||
local y = row
|
||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||
monFill(y, rowBg)
|
||
|
||
-- Number
|
||
monWrite(2, y, string.format("%d", i), colors.lightBlue, rowBg)
|
||
|
||
-- Type abbreviation
|
||
local typeAbbr = "F"
|
||
local typeColor = colors.orange
|
||
if fs.type == "minecraft:smoker" then
|
||
typeAbbr = "S"
|
||
typeColor = colors.green
|
||
elseif fs.type == "minecraft:blast_furnace" then
|
||
typeAbbr = "B"
|
||
typeColor = colors.cyan
|
||
end
|
||
monWrite(4, y, typeAbbr, typeColor, rowBg)
|
||
|
||
-- Input
|
||
if fs.input then
|
||
local inName = fs.input.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||
local maxIn = outCol - 8
|
||
if #inName > maxIn then inName = inName:sub(1, maxIn - 2) .. ".." end
|
||
monWrite(6, y, inName, colors.white, rowBg)
|
||
monWrite(outCol - 4, y, "x" .. fs.input.count, colors.yellow, rowBg)
|
||
else
|
||
monWrite(6, y, "(empty)", colors.lightGray, rowBg)
|
||
end
|
||
|
||
-- Output
|
||
if fs.output then
|
||
local outName = fs.output.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||
local maxOut = fuelCol - outCol - 5
|
||
if #outName > maxOut then outName = outName:sub(1, maxOut - 2) .. ".." end
|
||
monWrite(outCol, y, outName, colors.white, rowBg)
|
||
monWrite(fuelCol - 4, y, "x" .. fs.output.count, colors.yellow, rowBg)
|
||
else
|
||
monWrite(outCol, y, "-", colors.lightGray, rowBg)
|
||
end
|
||
|
||
-- Fuel
|
||
if fs.fuel then
|
||
local fuelName = fs.fuel.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||
local maxFuel = statCol - fuelCol - 4
|
||
if #fuelName > maxFuel then fuelName = fuelName:sub(1, maxFuel - 2) .. ".." end
|
||
monWrite(fuelCol, y, fuelName, colors.white, rowBg)
|
||
monWrite(statCol - 4, y, "x" .. fs.fuel.count, colors.yellow, rowBg)
|
||
else
|
||
monWrite(fuelCol, y, "-", colors.lightGray, rowBg)
|
||
end
|
||
|
||
-- Status indicator
|
||
if smeltingPaused then
|
||
monWrite(statCol, y, "PAUSE", colors.red, rowBg)
|
||
elseif fs.active then
|
||
monWrite(statCol, y, " COOK", colors.lime, rowBg)
|
||
elseif fs.input and not fs.fuel then
|
||
monWrite(statCol, y, "FUEL?", colors.orange, rowBg)
|
||
else
|
||
monWrite(statCol, y, " IDLE", colors.lightGray, rowBg)
|
||
end
|
||
|
||
row = row + 1
|
||
end
|
||
end
|
||
|
||
-- Fill remaining rows
|
||
while row <= h - 2 do
|
||
monFill(row, colors.black)
|
||
row = row + 1
|
||
end
|
||
|
||
elseif smelterView == "smelt" then
|
||
-- ===== Smelt Recipe Manager View =====
|
||
|
||
-- Build sorted recipe list
|
||
local recipeList = {}
|
||
for inputName, recipe in pairs(SMELTABLE) do
|
||
local short = inputName:gsub("^minecraft:", ""):gsub("_", " ")
|
||
short = short:sub(1,1):upper() .. short:sub(2)
|
||
local resultShort = recipe.result:gsub("^minecraft:", ""):gsub("_", " ")
|
||
resultShort = resultShort:sub(1,1):upper() .. resultShort:sub(2)
|
||
local types = ""
|
||
for _, ft in ipairs(recipe.furnaces) do
|
||
if ft == "minecraft:furnace" then types = types .. "F"
|
||
elseif ft == "minecraft:smoker" then types = types .. "S"
|
||
elseif ft == "minecraft:blast_furnace" then types = types .. "B"
|
||
end
|
||
end
|
||
local enabled = not disabledRecipes[inputName]
|
||
local inStorage = 0
|
||
if cache.catalogue[inputName] then
|
||
for _, s in ipairs(cache.catalogue[inputName]) do
|
||
inStorage = inStorage + s.total
|
||
end
|
||
end
|
||
table.insert(recipeList, {
|
||
inputName = inputName,
|
||
inputShort = short,
|
||
resultShort = resultShort,
|
||
types = types,
|
||
enabled = enabled,
|
||
inStorage = inStorage,
|
||
})
|
||
end
|
||
table.sort(recipeList, function(a, b) return a.inputShort < b.inputShort end)
|
||
|
||
-- Column positions
|
||
local arrowCol = math.floor(w * 0.30)
|
||
local typeCol = math.floor(w * 0.60)
|
||
local stockCol = math.floor(w * 0.72)
|
||
local toggleCol = w - 5
|
||
|
||
-- Column headers
|
||
monFill(5, colors.gray)
|
||
monWrite(2, 5, "Input", colors.lightGray, colors.gray)
|
||
monWrite(arrowCol, 5, "Output", colors.lightGray, colors.gray)
|
||
monWrite(typeCol, 5, "Type", colors.lightGray, colors.gray)
|
||
monWrite(stockCol, 5, "Stock", colors.lightGray, colors.gray)
|
||
monWrite(toggleCol, 5, "On?", colors.lightGray, colors.gray)
|
||
|
||
-- Bulk action buttons on tab row
|
||
local bulkX = w - 22
|
||
bx1, by1, bx2, by2 = drawButton(bulkX, 4, "All On", colors.white, colors.green)
|
||
addSmelterZone(bx1, by1, bx2, by2, "enable_all", nil)
|
||
bx1, by1, bx2, by2 = drawButton(bx2 + 2, 4, "All Off", colors.white, colors.red)
|
||
addSmelterZone(bx1, by1, bx2, by2, "disable_all", nil)
|
||
|
||
-- Recipe rows
|
||
local maxRows = h - 8
|
||
if maxRows < 1 then maxRows = 1 end
|
||
smelterTotalPages = math.max(1, math.ceil(#recipeList / maxRows))
|
||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||
if smelterPage < 1 then smelterPage = 1 end
|
||
|
||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||
local endIdx = math.min(startIdx + maxRows - 1, #recipeList)
|
||
|
||
local row = 6
|
||
for i = startIdx, endIdx do
|
||
local r = recipeList[i]
|
||
local y = row
|
||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||
monFill(y, rowBg)
|
||
|
||
-- Input name
|
||
local maxInputLen = arrowCol - 3
|
||
local inputDisplay = r.inputShort
|
||
if #inputDisplay > maxInputLen then
|
||
inputDisplay = inputDisplay:sub(1, maxInputLen - 2) .. ".."
|
||
end
|
||
monWrite(2, y, inputDisplay, colors.white, rowBg)
|
||
|
||
-- Output
|
||
local maxOutLen = typeCol - arrowCol - 2
|
||
local outDisplay = r.resultShort
|
||
if #outDisplay > maxOutLen then
|
||
outDisplay = outDisplay:sub(1, maxOutLen - 2) .. ".."
|
||
end
|
||
monWrite(arrowCol, y, outDisplay, colors.lightBlue, rowBg)
|
||
|
||
-- Types
|
||
monWrite(typeCol, y, r.types, colors.orange, rowBg)
|
||
|
||
-- Stock
|
||
monWrite(stockCol, y, tostring(r.inStorage), colors.yellow, rowBg)
|
||
|
||
-- Toggle button
|
||
if r.enabled then
|
||
monWrite(toggleCol, y, " ON ", colors.white, colors.green)
|
||
else
|
||
monWrite(toggleCol, y, " OFF", colors.white, colors.red)
|
||
end
|
||
addSmelterZone(1, y, w, y, "toggle_recipe", r.inputName)
|
||
|
||
row = row + 1
|
||
end
|
||
|
||
-- Fill remaining rows
|
||
while row <= h - 2 do
|
||
monFill(row, colors.black)
|
||
row = row + 1
|
||
end
|
||
|
||
elseif smelterView == "craft" then
|
||
-- ===== Available Crafting Recipes =====
|
||
|
||
-- Turtle status on tab row
|
||
local turtleOk = craftTurtleName and peripheral.wrap(craftTurtleName) ~= nil
|
||
local tLabel = turtleOk and " Turtle OK " or " No Turtle "
|
||
local tBg = turtleOk and colors.lime or colors.red
|
||
local tFg = turtleOk and colors.black or colors.white
|
||
monWrite(w - #tLabel, 4, tLabel, tFg, tBg)
|
||
|
||
-- Build list of craftable recipes
|
||
local availList = {}
|
||
for idx, recipe in ipairs(CRAFTABLE) do
|
||
if canCraftRecipe(recipe) then
|
||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||
short = short:sub(1,1):upper() .. short:sub(2)
|
||
local batches = maxCraftBatches(recipe)
|
||
table.insert(availList, {
|
||
idx = idx,
|
||
short = short,
|
||
count = recipe.count,
|
||
batches = batches,
|
||
})
|
||
end
|
||
end
|
||
|
||
-- Column headers
|
||
monFill(5, colors.gray)
|
||
local makeCol = w - 6
|
||
monWrite(2, 5, "#", colors.lightGray, colors.gray)
|
||
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
|
||
monWrite(math.floor(w * 0.45), 5, "Yield", colors.lightGray, colors.gray)
|
||
monWrite(math.floor(w * 0.60), 5, "Can Make", colors.lightGray, colors.gray)
|
||
monWrite(makeCol, 5, "Go", colors.lightGray, colors.gray)
|
||
|
||
local maxRows = h - 8
|
||
if maxRows < 1 then maxRows = 1 end
|
||
smelterTotalPages = math.max(1, math.ceil(#availList / maxRows))
|
||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||
if smelterPage < 1 then smelterPage = 1 end
|
||
|
||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||
local endIdx = math.min(startIdx + maxRows - 1, #availList)
|
||
|
||
local row = 6
|
||
if #availList == 0 then
|
||
monFill(7, colors.black)
|
||
monCenter(7, "No recipes available to craft", colors.gray, colors.black)
|
||
row = 8
|
||
else
|
||
for i = startIdx, endIdx do
|
||
local r = availList[i]
|
||
local y = row
|
||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||
monFill(y, rowBg)
|
||
|
||
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
|
||
|
||
local maxNameLen = math.floor(w * 0.40)
|
||
local nameDisplay = r.short
|
||
if #nameDisplay > maxNameLen then
|
||
nameDisplay = nameDisplay:sub(1, maxNameLen - 2) .. ".."
|
||
end
|
||
monWrite(4, y, nameDisplay, colors.white, rowBg)
|
||
|
||
monWrite(math.floor(w * 0.45), y, "x" .. r.count, colors.yellow, rowBg)
|
||
monWrite(math.floor(w * 0.60), y,
|
||
string.format("x%d", r.batches), colors.lime, rowBg)
|
||
|
||
-- MAKE button
|
||
if turtleOk then
|
||
monWrite(makeCol, y, " MAKE ", colors.white, colors.green)
|
||
addSmelterZone(makeCol, y, makeCol + 5, y, "craft", r.idx)
|
||
else
|
||
monWrite(makeCol, y, " ---- ", colors.gray, colors.black)
|
||
end
|
||
|
||
row = row + 1
|
||
end
|
||
end
|
||
|
||
while row <= h - 2 do
|
||
monFill(row, colors.black)
|
||
row = row + 1
|
||
end
|
||
|
||
elseif smelterView == "missing" then
|
||
-- ===== Unavailable Crafting Recipes =====
|
||
|
||
-- Build list of recipes that CANNOT be crafted
|
||
local missList = {}
|
||
for idx, recipe in ipairs(CRAFTABLE) do
|
||
if not canCraftRecipe(recipe) then
|
||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||
short = short:sub(1,1):upper() .. short:sub(2)
|
||
local missing = getMissingIngredients(recipe)
|
||
-- Build summary string
|
||
local parts = {}
|
||
for _, m in ipairs(missing) do
|
||
local mShort = m.name:gsub("^minecraft:", ""):gsub("_", " ")
|
||
table.insert(parts, string.format("%s %d/%d", mShort, m.have, m.need))
|
||
end
|
||
table.insert(missList, {
|
||
idx = idx,
|
||
short = short,
|
||
count = recipe.count,
|
||
summary = table.concat(parts, ", "),
|
||
})
|
||
end
|
||
end
|
||
|
||
-- Column headers
|
||
monFill(5, colors.gray)
|
||
monWrite(2, 5, "#", colors.lightGray, colors.gray)
|
||
monWrite(4, 5, "Output", colors.lightGray, colors.gray)
|
||
monWrite(math.floor(w * 0.35), 5, "Missing (have/need)", colors.lightGray, colors.gray)
|
||
|
||
local maxRows = h - 8
|
||
if maxRows < 1 then maxRows = 1 end
|
||
smelterTotalPages = math.max(1, math.ceil(#missList / maxRows))
|
||
if smelterPage > smelterTotalPages then smelterPage = smelterTotalPages end
|
||
if smelterPage < 1 then smelterPage = 1 end
|
||
|
||
local startIdx = (smelterPage - 1) * maxRows + 1
|
||
local endIdx = math.min(startIdx + maxRows - 1, #missList)
|
||
|
||
local row = 6
|
||
if #missList == 0 then
|
||
monFill(7, colors.black)
|
||
monCenter(7, "All recipes can be crafted!", colors.lime, colors.black)
|
||
row = 8
|
||
else
|
||
for i = startIdx, endIdx do
|
||
local r = missList[i]
|
||
local y = row
|
||
local rowBg = ((i - startIdx) % 2 == 0) and colors.black or colors.gray
|
||
monFill(y, rowBg)
|
||
|
||
monWrite(2, y, string.format("%2d", i), colors.lightBlue, rowBg)
|
||
|
||
local nameCol = math.floor(w * 0.35) - 5
|
||
local nameDisplay = r.short .. " x" .. r.count
|
||
if #nameDisplay > nameCol then
|
||
nameDisplay = nameDisplay:sub(1, nameCol - 2) .. ".."
|
||
end
|
||
monWrite(4, y, nameDisplay, colors.white, rowBg)
|
||
|
||
-- Missing items summary
|
||
local missCol = math.floor(w * 0.35)
|
||
local missW = w - missCol - 1
|
||
local summaryDisplay = r.summary
|
||
if #summaryDisplay > missW then
|
||
summaryDisplay = summaryDisplay:sub(1, missW - 2) .. ".."
|
||
end
|
||
monWrite(missCol, y, summaryDisplay, colors.red, rowBg)
|
||
|
||
row = row + 1
|
||
end
|
||
end
|
||
|
||
while row <= h - 2 do
|
||
monFill(row, colors.black)
|
||
row = row + 1
|
||
end
|
||
end
|
||
|
||
-- ===== Pagination (h - 1) =====
|
||
monFill(h - 1, colors.gray)
|
||
local pageStr = string.format("Pg %d/%d", smelterPage, smelterTotalPages)
|
||
monCenter(h - 1, pageStr, colors.white, colors.gray)
|
||
|
||
if smelterPage > 1 then
|
||
monWrite(2, h - 1, " < ", colors.white, colors.lightGray)
|
||
addSmelterZone(2, h - 1, 4, h - 1, "page_prev", nil)
|
||
end
|
||
if smelterPage < smelterTotalPages then
|
||
monWrite(w - 3, h - 1, " > ", colors.white, colors.lightGray)
|
||
addSmelterZone(w - 3, h - 1, w - 1, h - 1, "page_next", nil)
|
||
end
|
||
|
||
-- ===== Bottom accent =====
|
||
monFill(h, colors.purple)
|
||
local bottomMsg = ""
|
||
if smelterView == "status" or smelterView == "smelt" then
|
||
local enabledCount = 0
|
||
local totalRecipes = 0
|
||
for _ in pairs(SMELTABLE) do totalRecipes = totalRecipes + 1 end
|
||
for inputName in pairs(SMELTABLE) do
|
||
if not disabledRecipes[inputName] then enabledCount = enabledCount + 1 end
|
||
end
|
||
bottomMsg = string.format(" Smelt: %d/%d enabled ", enabledCount, totalRecipes)
|
||
if activity.smelting then bottomMsg = " SMELTING... " end
|
||
elseif smelterView == "craft" then
|
||
bottomMsg = " Tap MAKE to craft "
|
||
if activity.crafting then bottomMsg = " CRAFTING... " end
|
||
elseif smelterView == "missing" then
|
||
local totalC = #CRAFTABLE
|
||
local availC = 0
|
||
for _, r in ipairs(CRAFTABLE) do
|
||
if canCraftRecipe(r) then availC = availC + 1 end
|
||
end
|
||
bottomMsg = string.format(" Available: %d/%d recipes ", availC, totalC)
|
||
end
|
||
monCenter(h, bottomMsg, colors.pink, colors.purple)
|
||
|
||
-- Flush to monitor
|
||
draw.setVisible(true)
|
||
|
||
-- Swap zones
|
||
smelterTouchZones = smelterPendingZones
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Barrel auto-sort
|
||
-------------------------------------------------
|
||
|
||
local function sortBarrel(barrelOverride)
|
||
local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or BARREL_NAME
|
||
local barrel = peripheral.wrap(barrelTarget)
|
||
if not barrel then return end
|
||
|
||
local contents = barrel.list()
|
||
if not contents or not next(contents) then return end
|
||
|
||
activity.sorting = true
|
||
needsRedraw = true
|
||
|
||
local catalogue = cache.catalogue
|
||
local chests = getChests()
|
||
|
||
for slot, item in pairs(contents) do
|
||
local moved = 0
|
||
|
||
if catalogue[item.name] then
|
||
for _, entry in ipairs(catalogue[item.name]) do
|
||
local n = barrel.pushItems(entry.chest, slot)
|
||
if n and n > 0 then
|
||
moved = moved + n
|
||
adjustCache(item.name, entry.chest, n)
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
print(string.format("[SORT] %s x%d -> %s", item.name, n, entry.chest))
|
||
end
|
||
if moved >= item.count then break end
|
||
end
|
||
end
|
||
|
||
if moved < item.count then
|
||
for _, chest in ipairs(chests) do
|
||
local n = barrel.pushItems(chest, slot)
|
||
if n and n > 0 then
|
||
moved = moved + n
|
||
adjustCache(item.name, chest, n)
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
print(string.format("[SORT] %s x%d -> %s", item.name, n, chest))
|
||
end
|
||
if moved >= item.count then break end
|
||
end
|
||
end
|
||
|
||
if moved < item.count then
|
||
print(string.format("[WARN] Could not sort %d remaining %s", item.count - moved, item.name))
|
||
end
|
||
end
|
||
|
||
activity.sorting = false
|
||
needsRedraw = true
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Auto-smelt
|
||
-------------------------------------------------
|
||
|
||
local function autoSmelt()
|
||
if smeltingPaused then return false end
|
||
|
||
local furnaces = getFurnaces()
|
||
if #furnaces == 0 then return end
|
||
|
||
local chests = getChests()
|
||
local catalogue = cache.catalogue
|
||
local didWork = false
|
||
|
||
for _, fname in ipairs(furnaces) do
|
||
local furnace = peripheral.wrap(fname)
|
||
if furnace then
|
||
local contents = furnace.list()
|
||
|
||
-- 1) Pull finished output (slot 3) back to chests
|
||
if contents[SLOT_OUTPUT] then
|
||
local outputItem = contents[SLOT_OUTPUT]
|
||
local remaining = outputItem.count
|
||
for _, chest in ipairs(chests) do
|
||
local n = furnace.pushItems(chest, SLOT_OUTPUT)
|
||
if n and n > 0 then
|
||
remaining = remaining - n
|
||
print(string.format("[SMELT] Output %s x%d -> %s",
|
||
outputItem.name, n, chest))
|
||
didWork = true
|
||
if remaining <= 0 then break end
|
||
end
|
||
end
|
||
if remaining > 0 then
|
||
print(string.format("[WARN] Could not move %d %s from %s output (chests full?)",
|
||
remaining, outputItem.name, fname))
|
||
end
|
||
end
|
||
|
||
-- Also check all slots in case output ended up elsewhere
|
||
-- Some modded furnaces or CC versions may use different slot indices
|
||
for slot, item in pairs(contents) do
|
||
if slot ~= SLOT_INPUT and slot ~= SLOT_FUEL and slot ~= SLOT_OUTPUT then
|
||
for _, chest in ipairs(chests) do
|
||
local n = furnace.pushItems(chest, slot)
|
||
if n and n > 0 then
|
||
print(string.format("[SMELT] Extra slot %d: %s x%d -> %s",
|
||
slot, item.name, n, chest))
|
||
didWork = true
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Re-read after output pull
|
||
contents = furnace.list()
|
||
|
||
-- 2) Check for incompatible items in input slot and remove them
|
||
local furnaceType = peripheral.getType(fname)
|
||
local inputItem = contents[SLOT_INPUT]
|
||
if inputItem then
|
||
local recipe = SMELTABLE[inputItem.name]
|
||
local validHere = false
|
||
if recipe then
|
||
for _, ft in ipairs(recipe.furnaces) do
|
||
if ft == furnaceType then
|
||
validHere = true
|
||
break
|
||
end
|
||
end
|
||
end
|
||
if not validHere then
|
||
-- This item doesn't belong in this furnace type — pull it out
|
||
for _, chest in ipairs(chests) do
|
||
local n = furnace.pushItems(chest, SLOT_INPUT)
|
||
if n and n > 0 then
|
||
print(string.format("[SMELT] Removed incompatible %s x%d from %s -> %s",
|
||
inputItem.name, n, fname, chest))
|
||
didWork = true
|
||
break
|
||
end
|
||
end
|
||
-- Re-read after removal
|
||
contents = furnace.list()
|
||
end
|
||
end
|
||
|
||
-- 3) Refuel if fuel slot is empty or low
|
||
local fuelItem = contents[SLOT_FUEL]
|
||
local needFuel = not fuelItem or fuelItem.count < 8
|
||
|
||
if needFuel then
|
||
for _, fuel in ipairs(FUEL_LIST) do
|
||
if catalogue[fuel.name] then
|
||
for _, source in ipairs(catalogue[fuel.name]) do
|
||
local chest = peripheral.wrap(source.chest)
|
||
if chest then
|
||
for slot, slotItem in pairs(chest.list()) do
|
||
if slotItem.name == fuel.name then
|
||
local toMove = math.min(16, slotItem.count)
|
||
local n = chest.pushItems(fname, slot, toMove, SLOT_FUEL)
|
||
if n and n > 0 then
|
||
print(string.format("[SMELT] Fuel %s x%d -> %s",
|
||
fuel.name, n, fname))
|
||
didWork = true
|
||
needFuel = false
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
if not needFuel then break end
|
||
end
|
||
end
|
||
if not needFuel then break end
|
||
end
|
||
end
|
||
|
||
-- 4) Load smeltable items into empty input slot
|
||
inputItem = contents[SLOT_INPUT]
|
||
if not inputItem then
|
||
-- Build sorted candidate list: food first, then everything else
|
||
local candidates = {}
|
||
for itemName, recipe in pairs(SMELTABLE) do
|
||
-- Check if this furnace type is compatible
|
||
local compatible = false
|
||
for _, ft in ipairs(recipe.furnaces) do
|
||
if ft == furnaceType then
|
||
compatible = true
|
||
break
|
||
end
|
||
end
|
||
if compatible and not disabledRecipes[itemName] and catalogue[itemName] then
|
||
local isFood = false
|
||
for _, ft in ipairs(recipe.furnaces) do
|
||
if ft == "minecraft:smoker" then
|
||
isFood = true
|
||
break
|
||
end
|
||
end
|
||
table.insert(candidates, { name = itemName, recipe = recipe, food = isFood })
|
||
end
|
||
end
|
||
-- Sort: food first
|
||
table.sort(candidates, function(a, b)
|
||
if a.food ~= b.food then return a.food end
|
||
return a.name < b.name
|
||
end)
|
||
|
||
-- Try each candidate
|
||
for _, cand in ipairs(candidates) do
|
||
local itemName = cand.name
|
||
-- Count total of this item across all chests
|
||
local totalInStorage = 0
|
||
for _, src in ipairs(catalogue[itemName]) do
|
||
totalInStorage = totalInStorage + src.total
|
||
end
|
||
|
||
-- Only smelt the excess beyond SMELT_RESERVE
|
||
local available = totalInStorage - SMELT_RESERVE
|
||
if available > 0 then
|
||
local loaded = false
|
||
local remaining = math.min(available, 64)
|
||
for _, source in ipairs(catalogue[itemName]) do
|
||
local chest = peripheral.wrap(source.chest)
|
||
if chest then
|
||
for slot, slotItem in pairs(chest.list()) do
|
||
if slotItem.name == itemName then
|
||
local toMove = math.min(slotItem.count, remaining)
|
||
local n = chest.pushItems(fname, slot, toMove, SLOT_INPUT)
|
||
if n and n > 0 then
|
||
print(string.format("[SMELT] Input %s x%d -> %s (reserve %d)",
|
||
itemName, n, fname, math.max(0, totalInStorage - n)))
|
||
didWork = true
|
||
remaining = remaining - n
|
||
if remaining <= 0 then
|
||
loaded = true
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
if loaded then break end
|
||
end
|
||
if loaded or remaining < math.min(available, 64) then break end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
return didWork
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Defrag (consolidate partial stacks)
|
||
-------------------------------------------------
|
||
|
||
local function defragInventory()
|
||
local chests = getChests()
|
||
if #chests == 0 then return end
|
||
|
||
activity.defragging = true
|
||
needsRedraw = true
|
||
|
||
-- Build a map: itemName -> list of { chest, slot, count, maxCount }
|
||
local itemSlots = {}
|
||
for _, chestName in ipairs(chests) do
|
||
local inv = peripheral.wrap(chestName)
|
||
if inv then
|
||
local contents = inv.list()
|
||
local detail_cache = {}
|
||
for slot, item in pairs(contents) do
|
||
if not itemSlots[item.name] then
|
||
itemSlots[item.name] = {}
|
||
end
|
||
-- CC:Tweaked: getItemDetail gives maxCount (stack size)
|
||
local maxCount = 64
|
||
local ok, detail = pcall(inv.getItemDetail, slot)
|
||
if ok and detail and detail.maxCount then
|
||
maxCount = detail.maxCount
|
||
end
|
||
table.insert(itemSlots[item.name], {
|
||
chest = chestName,
|
||
slot = slot,
|
||
count = item.count,
|
||
max = maxCount,
|
||
})
|
||
end
|
||
end
|
||
end
|
||
|
||
-- For each item, try to merge partial stacks
|
||
local totalMerged = 0
|
||
for itemName, slots in pairs(itemSlots) do
|
||
-- Sort: smallest stacks first (donors), fullest last (receivers)
|
||
table.sort(slots, function(a, b) return a.count < b.count end)
|
||
|
||
local i = 1 -- donor (smallest)
|
||
local j = #slots -- receiver (largest, has room)
|
||
|
||
while i < j do
|
||
local donor = slots[i]
|
||
local recv = slots[j]
|
||
|
||
-- Skip if same slot
|
||
if donor.chest == recv.chest and donor.slot == recv.slot then
|
||
i = i + 1
|
||
elseif donor.count == 0 then
|
||
i = i + 1
|
||
elseif recv.count >= recv.max then
|
||
j = j - 1
|
||
else
|
||
local space = recv.max - recv.count
|
||
local toMove = math.min(donor.count, space)
|
||
local donorInv = peripheral.wrap(donor.chest)
|
||
if donorInv then
|
||
local n = donorInv.pushItems(recv.chest, donor.slot, toMove, recv.slot)
|
||
if n and n > 0 then
|
||
donor.count = donor.count - n
|
||
recv.count = recv.count + n
|
||
totalMerged = totalMerged + n
|
||
end
|
||
end
|
||
if donor.count <= 0 then i = i + 1 end
|
||
if recv.count >= recv.max then j = j - 1 end
|
||
end
|
||
end
|
||
end
|
||
|
||
if totalMerged > 0 then
|
||
print(string.format("[DEFRAG] Consolidated %d items", totalMerged))
|
||
end
|
||
|
||
activity.defragging = false
|
||
needsRedraw = true
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Auto-compost
|
||
-------------------------------------------------
|
||
|
||
local function autoCompost()
|
||
local catalogue = cache.catalogue
|
||
local chests = getChests()
|
||
local didWork = false
|
||
|
||
-- 1) Pull bone meal from hopper back to chests
|
||
local hopper = peripheral.wrap(COMPOST_HOPPER)
|
||
if hopper then
|
||
local contents = hopper.list()
|
||
if contents then
|
||
for slot, item in pairs(contents) do
|
||
for _, chest in ipairs(chests) do
|
||
local n = hopper.pushItems(chest, slot)
|
||
if n and n > 0 then
|
||
adjustCache(item.name, chest, n)
|
||
print(string.format("[COMPOST] %s x%d -> %s", item.name, n, chest))
|
||
didWork = true
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
-- 2) Feed compostable items into dropper
|
||
local dropper = peripheral.wrap(COMPOST_DROPPER)
|
||
if not dropper then return didWork end
|
||
|
||
-- Check how much item capacity the dropper has (slots * 64 - current items)
|
||
local dropperContents = dropper.list()
|
||
local dropperUsedSlots = 0
|
||
local dropperUsedItems = 0
|
||
if dropperContents then
|
||
for _, item in pairs(dropperContents) do
|
||
dropperUsedSlots = dropperUsedSlots + 1
|
||
dropperUsedItems = dropperUsedItems + item.count
|
||
end
|
||
end
|
||
local dropperSize = dropper.size()
|
||
local dropperFreeSlots = dropperSize - dropperUsedSlots
|
||
local dropperFreeItems = (dropperSize * 64) - dropperUsedItems
|
||
|
||
if dropperFreeItems <= 0 then return didWork end
|
||
|
||
for _, itemName in ipairs(COMPOSTABLE) do
|
||
if dropperFreeItems <= 0 then break end
|
||
if catalogue[itemName] then
|
||
-- Count total in storage
|
||
local totalInStorage = 0
|
||
for _, src in ipairs(catalogue[itemName]) do
|
||
totalInStorage = totalInStorage + src.total
|
||
end
|
||
|
||
local available = totalInStorage - COMPOST_RESERVE
|
||
if available > 0 then
|
||
local toFeed = math.min(available, dropperFreeItems)
|
||
local fed = 0
|
||
for _, source in ipairs(catalogue[itemName]) do
|
||
local chest = peripheral.wrap(source.chest)
|
||
if chest then
|
||
for slot, slotItem in pairs(chest.list()) do
|
||
if slotItem.name == itemName then
|
||
local batch = math.min(slotItem.count, toFeed - fed)
|
||
local n = chest.pushItems(COMPOST_DROPPER, slot, batch)
|
||
if n and n > 0 then
|
||
adjustCache(itemName, source.chest, -n)
|
||
fed = fed + n
|
||
didWork = true
|
||
print(string.format("[COMPOST] Fed %s x%d -> dropper",
|
||
itemName, n))
|
||
if fed >= toFeed then break end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
if fed >= toFeed then break end
|
||
end
|
||
dropperFreeItems = dropperFreeItems - fed
|
||
end
|
||
end
|
||
end
|
||
|
||
return didWork
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Low-stock alert checker
|
||
-------------------------------------------------
|
||
|
||
local function checkAlerts()
|
||
local alerts = {}
|
||
for _, alert in ipairs(LOW_STOCK_ALERTS) do
|
||
local total = 0
|
||
if cache.catalogue[alert.name] then
|
||
for _, src in ipairs(cache.catalogue[alert.name]) do
|
||
total = total + src.total
|
||
end
|
||
end
|
||
if total < alert.min then
|
||
table.insert(alerts, {
|
||
label = alert.label,
|
||
current = total,
|
||
min = alert.min,
|
||
})
|
||
end
|
||
end
|
||
activeAlerts = alerts
|
||
if #alerts > 0 then
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
end
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Order
|
||
-------------------------------------------------
|
||
|
||
local function orderItem(itemName, amount, dropperOverride)
|
||
activity.dispensing = true
|
||
needsRedraw = true
|
||
|
||
-- Use client-specified dropper if provided, otherwise master's default
|
||
local dropperTarget = (dropperOverride and dropperOverride ~= "") and dropperOverride or DROPPER_NAME
|
||
|
||
local catalogue = cache.catalogue
|
||
|
||
if not catalogue[itemName] then
|
||
statusMessage = "Not found: " .. itemName:gsub("^minecraft:", "")
|
||
statusColor = colors.red
|
||
statusTimer = 5
|
||
activity.dispensing = false
|
||
needsRedraw = true
|
||
return false
|
||
end
|
||
|
||
local dropper = peripheral.wrap(dropperTarget)
|
||
if not dropper then
|
||
statusMessage = "Dropper offline: " .. dropperTarget
|
||
statusColor = colors.red
|
||
statusTimer = 5
|
||
activity.dispensing = false
|
||
needsRedraw = true
|
||
return false
|
||
end
|
||
|
||
local remaining = amount
|
||
for _, entry in ipairs(catalogue[itemName]) do
|
||
local chest = peripheral.wrap(entry.chest)
|
||
if chest then
|
||
for slot, slotItem in pairs(chest.list()) do
|
||
if slotItem.name == itemName then
|
||
local toMove = math.min(remaining, slotItem.count)
|
||
local moved = chest.pushItems(dropperTarget, slot, toMove)
|
||
if moved and moved > 0 then
|
||
remaining = remaining - moved
|
||
adjustCache(itemName, entry.chest, -moved)
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
print(string.format("[ORDER] %s x%d from %s", itemName, moved, entry.chest))
|
||
end
|
||
if remaining <= 0 then break end
|
||
end
|
||
end
|
||
end
|
||
if remaining <= 0 then break end
|
||
end
|
||
|
||
local sent = amount - remaining
|
||
local short = itemName:gsub("^minecraft:", ""):gsub("_", " ")
|
||
if sent > 0 then
|
||
statusMessage = string.format("Dispensing %s x%d", short, sent)
|
||
statusColor = colors.lime
|
||
print(string.format("[OK] Ordered %s x%d", short, sent))
|
||
else
|
||
statusMessage = "Could not order " .. short
|
||
statusColor = colors.red
|
||
end
|
||
statusTimer = 5
|
||
|
||
activity.dispensing = false
|
||
needsRedraw = true
|
||
return sent > 0
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Touch handler (no peripheral calls — instant)
|
||
-------------------------------------------------
|
||
|
||
local function handleTouch(x, y)
|
||
local action, data = hitTest(x, y)
|
||
if not action then
|
||
print("[TOUCH] No zone hit")
|
||
return
|
||
end
|
||
|
||
if action == "amount" then
|
||
selectedAmount = data
|
||
print("[UI] Amount set to " .. data)
|
||
needsRedraw = true
|
||
|
||
elseif action == "order" then
|
||
local itemName = data
|
||
if itemName then
|
||
local short = itemName:gsub("^minecraft:", ""):gsub("_", " ")
|
||
statusMessage = string.format("Ordering %s x%d...", short, selectedAmount)
|
||
statusColor = colors.cyan
|
||
statusTimer = 10
|
||
activity.dispensing = true
|
||
needsRedraw = true
|
||
orderItem(itemName, selectedAmount)
|
||
end
|
||
|
||
elseif action == "scan" then
|
||
statusMessage = "Refreshing..."
|
||
statusColor = colors.cyan
|
||
statusTimer = 3
|
||
needsRedraw = true
|
||
print("[UI] Manual refresh")
|
||
|
||
elseif action == "kb_toggle" then
|
||
showKeyboard = not showKeyboard
|
||
print("[UI] Keyboard " .. (showKeyboard and "open" or "closed"))
|
||
needsRedraw = true
|
||
|
||
elseif action == "kb_key" then
|
||
if #searchQuery < 30 then
|
||
searchQuery = searchQuery .. data
|
||
end
|
||
currentPage = 1
|
||
needsRedraw = true
|
||
|
||
elseif action == "kb_bksp" then
|
||
if #searchQuery > 0 then
|
||
searchQuery = searchQuery:sub(1, -2)
|
||
end
|
||
currentPage = 1
|
||
needsRedraw = true
|
||
|
||
elseif action == "kb_space" then
|
||
if #searchQuery < 30 then
|
||
searchQuery = searchQuery .. " "
|
||
end
|
||
currentPage = 1
|
||
needsRedraw = true
|
||
|
||
elseif action == "kb_done" then
|
||
showKeyboard = false
|
||
print("[UI] Keyboard closed")
|
||
needsRedraw = true
|
||
|
||
elseif action == "kb_clear" then
|
||
searchQuery = ""
|
||
currentPage = 1
|
||
print("[UI] Search cleared")
|
||
needsRedraw = true
|
||
|
||
elseif action == "page_prev" then
|
||
if currentPage > 1 then
|
||
currentPage = currentPage - 1
|
||
print("[UI] Page " .. currentPage)
|
||
end
|
||
needsRedraw = true
|
||
|
||
elseif action == "page_next" then
|
||
if currentPage < totalPages then
|
||
currentPage = currentPage + 1
|
||
print("[UI] Page " .. currentPage)
|
||
end
|
||
needsRedraw = true
|
||
end
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Smelter touch handler
|
||
-------------------------------------------------
|
||
|
||
local function handleSmelterTouch(x, y)
|
||
local action, data = smelterHitTest(x, y)
|
||
if not action then return end
|
||
|
||
if action == "tab" then
|
||
smelterView = data
|
||
smelterPage = 1
|
||
print("[SMELT-UI] Tab: " .. data)
|
||
smelterNeedsRedraw = true
|
||
|
||
elseif action == "toggle_pause" then
|
||
smeltingPaused = not smeltingPaused
|
||
print("[SMELT-UI] Smelting " .. (smeltingPaused and "PAUSED" or "RESUMED"))
|
||
saveDisabledRecipes()
|
||
smelterNeedsRedraw = true
|
||
needsRedraw = true
|
||
|
||
elseif action == "toggle_recipe" then
|
||
if disabledRecipes[data] then
|
||
disabledRecipes[data] = nil
|
||
else
|
||
disabledRecipes[data] = true
|
||
end
|
||
local short = data:gsub("^minecraft:", ""):gsub("_", " ")
|
||
print("[SMELT-UI] Recipe " .. short .. ": " .. (disabledRecipes[data] and "OFF" or "ON"))
|
||
saveDisabledRecipes()
|
||
smelterNeedsRedraw = true
|
||
|
||
elseif action == "enable_all" then
|
||
disabledRecipes = {}
|
||
print("[SMELT-UI] All recipes enabled")
|
||
saveDisabledRecipes()
|
||
smelterNeedsRedraw = true
|
||
|
||
elseif action == "disable_all" then
|
||
for inputName in pairs(SMELTABLE) do
|
||
disabledRecipes[inputName] = true
|
||
end
|
||
print("[SMELT-UI] All recipes disabled")
|
||
saveDisabledRecipes()
|
||
smelterNeedsRedraw = true
|
||
|
||
elseif action == "page_prev" then
|
||
if smelterPage > 1 then
|
||
smelterPage = smelterPage - 1
|
||
end
|
||
smelterNeedsRedraw = true
|
||
|
||
elseif action == "page_next" then
|
||
if smelterPage < smelterTotalPages then
|
||
smelterPage = smelterPage + 1
|
||
end
|
||
smelterNeedsRedraw = true
|
||
|
||
elseif action == "craft" then
|
||
local recipeIdx = data
|
||
local recipe = CRAFTABLE[recipeIdx]
|
||
if recipe then
|
||
local short = recipe.output:gsub("^minecraft:", ""):gsub("_", " ")
|
||
print(string.format("[CRAFT-UI] Craft request: %s (#%d)", short, recipeIdx))
|
||
local ok, err = craftItem(recipeIdx)
|
||
if ok then
|
||
statusMessage = "Crafted " .. short .. " x" .. recipe.count
|
||
statusColor = colors.lime
|
||
else
|
||
statusMessage = "Craft failed: " .. (err or "unknown")
|
||
statusColor = colors.red
|
||
end
|
||
statusTimer = 5
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
end
|
||
end
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Network broadcast (sends state to client displays)
|
||
-------------------------------------------------
|
||
|
||
local function broadcastState()
|
||
if not networkModem then return end
|
||
local state = {
|
||
type = "state",
|
||
cache = {
|
||
itemList = cache.itemList,
|
||
grandTotal = cache.grandTotal,
|
||
chestCount = cache.chestCount,
|
||
totalSlots = cache.totalSlots,
|
||
usedSlots = cache.usedSlots,
|
||
freeSlots = cache.freeSlots,
|
||
usedRatio = cache.usedRatio,
|
||
dropperOk = cache.dropperOk,
|
||
barrelOk = cache.barrelOk,
|
||
furnaceCount = cache.furnaceCount,
|
||
furnaceStatus = cache.furnaceStatus,
|
||
},
|
||
activity = activity,
|
||
alerts = activeAlerts,
|
||
smeltingPaused = smeltingPaused,
|
||
disabledRecipes = disabledRecipes,
|
||
smeltable = SMELTABLE,
|
||
craftable = CRAFTABLE,
|
||
craftTurtleOk = craftTurtleName and peripheral.wrap(craftTurtleName) ~= nil,
|
||
}
|
||
networkModem.transmit(BROADCAST_CHANNEL, ORDER_CHANNEL, state)
|
||
end
|
||
|
||
-------------------------------------------------
|
||
-- Main
|
||
-------------------------------------------------
|
||
|
||
local function main()
|
||
print("=================================")
|
||
print(" Inventory Manager v2 (Touch)")
|
||
print("=================================")
|
||
print("")
|
||
|
||
if peripheral.wrap(DROPPER_NAME) then
|
||
print("[OK] Dropper: " .. DROPPER_NAME)
|
||
else
|
||
print("[WARN] Dropper not found: " .. DROPPER_NAME)
|
||
end
|
||
|
||
if peripheral.wrap(BARREL_NAME) then
|
||
print("[OK] Barrel: " .. BARREL_NAME)
|
||
else
|
||
print("[WARN] Barrel not found: " .. BARREL_NAME)
|
||
end
|
||
|
||
if setupMonitor() then
|
||
print("[OK] Monitor: " .. (monName or MONITOR_SIDE))
|
||
else
|
||
print("[WARN] No monitor on " .. MONITOR_SIDE)
|
||
end
|
||
|
||
if setupSmelterMonitor() then
|
||
print("[OK] Smelter monitor: " .. (smelterMonName or SMELTER_MONITOR_SIDE))
|
||
else
|
||
print("[WARN] No smelter monitor on " .. SMELTER_MONITOR_SIDE)
|
||
end
|
||
|
||
-- Find modem for client communication
|
||
for _, name in ipairs(peripheral.getNames()) do
|
||
if peripheral.getType(name) == "modem" then
|
||
networkModem = peripheral.wrap(name)
|
||
networkModemName = name
|
||
networkModem.open(ORDER_CHANNEL)
|
||
networkModem.open(CRAFT_REPLY_CHANNEL)
|
||
break
|
||
end
|
||
end
|
||
if networkModem then
|
||
print("[OK] Network modem: " .. networkModemName)
|
||
else
|
||
print("[WARN] No modem found for client sync")
|
||
end
|
||
|
||
-- Detect crafting turtle on network
|
||
for _, name in ipairs(peripheral.getNames()) do
|
||
if name:match("^turtle_") then
|
||
craftTurtleName = name
|
||
break
|
||
end
|
||
end
|
||
if craftTurtleName then
|
||
print("[OK] Crafting turtle: " .. craftTurtleName)
|
||
else
|
||
print("[WARN] No crafting turtle found")
|
||
end
|
||
|
||
-- Load recipe toggles from disk
|
||
loadDisabledRecipes()
|
||
if smeltingPaused then
|
||
print("[INIT] Smelting is PAUSED (toggle on smelter monitor)")
|
||
end
|
||
local enabledCount = 0
|
||
local totalRecipeCount = 0
|
||
for _ in pairs(SMELTABLE) do totalRecipeCount = totalRecipeCount + 1 end
|
||
for k in pairs(SMELTABLE) do
|
||
if not disabledRecipes[k] then enabledCount = enabledCount + 1 end
|
||
end
|
||
print(string.format("[INIT] %d/%d recipes enabled", enabledCount, totalRecipeCount))
|
||
|
||
-- Detect compost peripherals
|
||
if peripheral.wrap(COMPOST_DROPPER) then
|
||
print("[OK] Compost dropper: " .. COMPOST_DROPPER)
|
||
else
|
||
print("[WARN] Compost dropper not found: " .. COMPOST_DROPPER)
|
||
end
|
||
if peripheral.wrap(COMPOST_HOPPER) then
|
||
print("[OK] Compost hopper: " .. COMPOST_HOPPER)
|
||
else
|
||
print("[WARN] Compost hopper not found: " .. COMPOST_HOPPER)
|
||
end
|
||
|
||
print(string.format("[INIT] Tracking %d low-stock alerts", #LOW_STOCK_ALERTS))
|
||
|
||
print("")
|
||
print("Console shows log. Use the monitors to interact.")
|
||
print("")
|
||
|
||
-- Try loading cached inventory from disk for instant startup
|
||
local cacheLoaded = loadCacheFromDisk()
|
||
if cacheLoaded then
|
||
print("[INIT] Loaded cached inventory (" .. #cache.itemList .. " types)")
|
||
print("[INIT] Background refresh starting...")
|
||
else
|
||
-- No cache: do full scan with progress bar
|
||
print("[INIT] No cache found. Scanning inventories...")
|
||
if mon then
|
||
local w, h = mon.getSize()
|
||
local buf = window.create(mon, 1, 1, w, h, false)
|
||
|
||
local function drawBoot(current, total, chestName)
|
||
buf.setBackgroundColor(colors.black)
|
||
buf.clear()
|
||
|
||
-- Title
|
||
buf.setBackgroundColor(colors.blue)
|
||
buf.setCursorPos(1, 1)
|
||
buf.write(string.rep(" ", w))
|
||
local title = " INVENTORY MANAGER "
|
||
buf.setCursorPos(math.floor((w - #title) / 2) + 1, 1)
|
||
buf.setTextColor(colors.white)
|
||
buf.write(title)
|
||
|
||
-- Scanning label
|
||
local midY = math.floor(h / 2)
|
||
buf.setBackgroundColor(colors.black)
|
||
buf.setTextColor(colors.lightGray)
|
||
local label = "Scanning inventories..."
|
||
buf.setCursorPos(math.floor((w - #label) / 2) + 1, midY - 2)
|
||
buf.write(label)
|
||
|
||
-- Chest name
|
||
local short = chestName or ""
|
||
if #short > w - 4 then short = ".." .. short:sub(-(w - 6)) end
|
||
buf.setTextColor(colors.gray)
|
||
buf.setCursorPos(math.floor((w - #short) / 2) + 1, midY - 1)
|
||
buf.write(short)
|
||
|
||
-- Progress bar
|
||
local barW = math.min(w - 8, 40)
|
||
local barX = math.floor((w - barW) / 2) + 1
|
||
local ratio = total > 0 and (current / total) or 0
|
||
local filled = math.floor(ratio * barW)
|
||
|
||
buf.setCursorPos(barX, midY + 1)
|
||
buf.setBackgroundColor(colors.lime)
|
||
buf.write(string.rep(" ", filled))
|
||
buf.setBackgroundColor(colors.gray)
|
||
buf.write(string.rep(" ", barW - filled))
|
||
|
||
-- Percentage + count
|
||
buf.setBackgroundColor(colors.black)
|
||
buf.setTextColor(colors.white)
|
||
local pct = string.format("%d/%d (%d%%)", current, total, math.floor(ratio * 100))
|
||
buf.setCursorPos(math.floor((w - #pct) / 2) + 1, midY + 3)
|
||
buf.write(pct)
|
||
|
||
-- Bottom accent
|
||
buf.setCursorPos(1, h)
|
||
buf.setBackgroundColor(colors.blue)
|
||
buf.write(string.rep(" ", w))
|
||
|
||
buf.setVisible(true)
|
||
buf.setVisible(false)
|
||
end
|
||
|
||
refreshCache(drawBoot)
|
||
else
|
||
refreshCache()
|
||
end
|
||
print("[INIT] Done. Found " .. #cache.itemList .. " item types.")
|
||
end
|
||
print("")
|
||
|
||
parallel.waitForAny(
|
||
-- Task 1: Background inventory scanner
|
||
function()
|
||
-- If we loaded from disk cache, refresh immediately in background
|
||
if cacheLoaded then
|
||
pcall(refreshCache)
|
||
pcall(checkAlerts)
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
print("[INIT] Background refresh complete. " .. #cache.itemList .. " types.")
|
||
end
|
||
while true do
|
||
sleep(SCAN_INTERVAL)
|
||
pcall(refreshCache)
|
||
pcall(checkAlerts)
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
end
|
||
end,
|
||
|
||
-- Task 2: Barrel auto-sort
|
||
function()
|
||
while true do
|
||
pcall(sortBarrel)
|
||
sleep(POLL_INTERVAL)
|
||
end
|
||
end,
|
||
|
||
-- Task 3: Auto-smelt
|
||
function()
|
||
while true do
|
||
activity.smelting = true
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
local ok, didWork = pcall(autoSmelt)
|
||
activity.smelting = false
|
||
-- Update furnace status quickly after smelt cycle
|
||
pcall(refreshFurnaceStatus)
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
sleep(SMELT_INTERVAL)
|
||
end
|
||
end,
|
||
|
||
-- Task 4: Defrag (consolidate partial stacks)
|
||
function()
|
||
sleep(10) -- initial delay to let first scan finish
|
||
while true do
|
||
activity.defragging = true
|
||
needsRedraw = true
|
||
pcall(defragInventory)
|
||
activity.defragging = false
|
||
needsRedraw = true
|
||
sleep(DEFRAG_INTERVAL)
|
||
end
|
||
end,
|
||
|
||
-- Task 5: Auto-compost
|
||
function()
|
||
while true do
|
||
activity.composting = true
|
||
needsRedraw = true
|
||
pcall(autoCompost)
|
||
activity.composting = false
|
||
needsRedraw = true
|
||
pcall(checkAlerts)
|
||
sleep(COMPOST_INTERVAL)
|
||
end
|
||
end,
|
||
|
||
-- Task 6: Low-stock alert checker
|
||
function()
|
||
sleep(5) -- initial delay
|
||
pcall(checkAlerts)
|
||
needsRedraw = true
|
||
while true do
|
||
sleep(ALERT_INTERVAL)
|
||
pcall(checkAlerts)
|
||
needsRedraw = true
|
||
end
|
||
end,
|
||
|
||
-- Task 7: Inventory dashboard redraw (event-driven, checks every 0.1s)
|
||
function()
|
||
needsRedraw = true
|
||
while true do
|
||
if needsRedraw then
|
||
needsRedraw = false
|
||
pcall(drawDashboard)
|
||
end
|
||
-- Decrement status timer
|
||
if statusTimer > 0 then
|
||
statusTimer = statusTimer - 0.1
|
||
if statusTimer <= 0 then
|
||
statusTimer = 0
|
||
needsRedraw = true
|
||
end
|
||
end
|
||
-- Redraw periodically for alert cycling
|
||
if #activeAlerts > 0 then
|
||
needsRedraw = true
|
||
end
|
||
sleep(0.1)
|
||
end
|
||
end,
|
||
|
||
-- Task 8: Smelter dashboard redraw
|
||
function()
|
||
smelterNeedsRedraw = true
|
||
while true do
|
||
if smelterNeedsRedraw then
|
||
smelterNeedsRedraw = false
|
||
pcall(drawSmelterDashboard)
|
||
end
|
||
sleep(0.1)
|
||
end
|
||
end,
|
||
|
||
-- Task 9: Touch event listener (both monitors)
|
||
function()
|
||
while true do
|
||
local event, side, x, y = os.pullEvent("monitor_touch")
|
||
if smelterMonName and side == smelterMonName then
|
||
print(string.format("[SMELT-TOUCH] x=%d y=%d", x, y))
|
||
handleSmelterTouch(x, y)
|
||
else
|
||
print(string.format("[TOUCH] x=%d y=%d", x, y))
|
||
handleTouch(x, y)
|
||
end
|
||
end
|
||
end,
|
||
|
||
-- Task 10: Network state broadcast
|
||
function()
|
||
while true do
|
||
pcall(broadcastState)
|
||
sleep(BROADCAST_INTERVAL)
|
||
end
|
||
end,
|
||
|
||
-- Task 11: Network order/command listener
|
||
function()
|
||
if not networkModem then return end
|
||
while true do
|
||
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
|
||
if channel == ORDER_CHANNEL and type(message) == "table" then
|
||
if message.type == "order" and message.itemName and message.amount then
|
||
print(string.format("[NET] Order: %s x%d", message.itemName, message.amount))
|
||
local success = orderItem(message.itemName, message.amount, message.dropperName)
|
||
networkModem.transmit(replyChannel, ORDER_CHANNEL, {
|
||
type = "order_result",
|
||
success = success,
|
||
message = statusMessage,
|
||
color = statusColor,
|
||
})
|
||
pcall(broadcastState)
|
||
elseif message.type == "scan" then
|
||
print("[NET] Scan request from client")
|
||
pcall(refreshCache)
|
||
pcall(checkAlerts)
|
||
needsRedraw = true
|
||
smelterNeedsRedraw = true
|
||
pcall(broadcastState)
|
||
elseif message.type == "toggle_pause" then
|
||
smeltingPaused = not smeltingPaused
|
||
print("[NET] Smelting " .. (smeltingPaused and "PAUSED" or "RESUMED"))
|
||
saveDisabledRecipes()
|
||
smelterNeedsRedraw = true
|
||
needsRedraw = true
|
||
pcall(broadcastState)
|
||
elseif message.type == "toggle_recipe" and message.recipe then
|
||
if disabledRecipes[message.recipe] then
|
||
disabledRecipes[message.recipe] = nil
|
||
else
|
||
disabledRecipes[message.recipe] = true
|
||
end
|
||
print("[NET] Recipe toggle: " .. message.recipe)
|
||
saveDisabledRecipes()
|
||
smelterNeedsRedraw = true
|
||
pcall(broadcastState)
|
||
elseif message.type == "enable_all" then
|
||
disabledRecipes = {}
|
||
print("[NET] All recipes enabled")
|
||
saveDisabledRecipes()
|
||
smelterNeedsRedraw = true
|
||
pcall(broadcastState)
|
||
elseif message.type == "disable_all" then
|
||
for inputName in pairs(SMELTABLE) do
|
||
disabledRecipes[inputName] = true
|
||
end
|
||
print("[NET] All recipes disabled")
|
||
saveDisabledRecipes()
|
||
smelterNeedsRedraw = true
|
||
pcall(broadcastState)
|
||
elseif message.type == "sort_barrel" and message.barrelName then
|
||
print("[NET] Sort barrel: " .. message.barrelName)
|
||
pcall(sortBarrel, message.barrelName)
|
||
pcall(broadcastState)
|
||
elseif message.type == "craft" and message.recipeIdx then
|
||
print(string.format("[NET] Craft request: recipe #%d", message.recipeIdx))
|
||
local ok, err = craftItem(message.recipeIdx)
|
||
networkModem.transmit(replyChannel, ORDER_CHANNEL, {
|
||
type = "craft_result",
|
||
success = ok,
|
||
error = err,
|
||
})
|
||
smelterNeedsRedraw = true
|
||
needsRedraw = true
|
||
pcall(broadcastState)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
)
|
||
end
|
||
|
||
main()
|