SuperMcBrent

crafty turtle on pattern provider

Oct 8th, 2026 (edited)
25
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 16.30 KB | Gaming | 0 0
  1. -- PPV crafty turtle, CC: Restitched 1.100.8.
  2. -- One craft per batch; ordinary ingredients; no recipe remainders.
  3.  
  4. local GRID = {1, 2, 3, 5, 6, 7, 9, 10, 11}
  5. local IN_GRID = {}
  6.  
  7. for _, slot in ipairs(GRID) do
  8.   IN_GRID[slot] = true
  9. end
  10.  
  11. local FILE = "/ppv-memory."
  12. local db, fault, page = nil, nil, nil
  13. local outputBlocked = false
  14. local buttons, lastKey, lastChange = {}, nil, os.clock()
  15.  
  16. local function snapshot()
  17.   local result = {}
  18.  
  19.   for slot = 1, 16 do
  20.     local item = turtle.getItemDetail(slot)
  21.  
  22.     if item then
  23.       result[slot] = {
  24.         name = item.name,
  25.         count = item.count
  26.       }
  27.     end
  28.   end
  29.  
  30.   return result
  31. end
  32.  
  33. local function totals(inv)
  34.   local result, count = {}, 0
  35.  
  36.   for _, item in pairs(inv) do
  37.     result[item.name] = (result[item.name] or 0) + item.count
  38.     count = count + item.count
  39.   end
  40.  
  41.   return result, count
  42. end
  43.  
  44. local function key(inv)
  45.   local amounts = totals(inv)
  46.   local names, parts = {}, {}
  47.  
  48.   for name in pairs(amounts) do
  49.     names[#names + 1] = name
  50.   end
  51.  
  52.   table.sort(names)
  53.  
  54.   for _, name in ipairs(names) do
  55.     parts[#parts + 1] =
  56.       #name .. ":" .. name .. "=" .. amounts[name]
  57.   end
  58.  
  59.   return table.concat(parts, ";")
  60. end
  61.  
  62. local function layoutInventory(layout)
  63.   local inv = {}
  64.  
  65.   for index = 1, 9 do
  66.     if layout[index] ~= false then
  67.       inv[GRID[index]] = {
  68.         name = layout[index],
  69.         count = 1
  70.       }
  71.     end
  72.   end
  73.  
  74.   return inv
  75. end
  76.  
  77. local function validDatabase(value)
  78.   if type(value) ~= "table"
  79.     or value.version ~= 1
  80.     or type(value.revision) ~= "number"
  81.     or value.revision < 0
  82.     or value.revision % 1 ~= 0
  83.     or type(value.recipes) ~= "table"
  84.     or #value.recipes > 9 then
  85.     return false
  86.   end
  87.  
  88.   local function validLayout(layout)
  89.     if type(layout) ~= "table" then
  90.       return false
  91.     end
  92.  
  93.     for index = 1, 9 do
  94.       if layout[index] ~= false
  95.         and type(layout[index]) ~= "string" then
  96.         return false
  97.       end
  98.     end
  99.  
  100.     return true
  101.   end
  102.  
  103.   for _, recipe in ipairs(value.recipes) do
  104.     if type(recipe) ~= "table"
  105.       or not validLayout(recipe.layout)
  106.       or recipe.key ~= key(layoutInventory(recipe.layout))
  107.       or type(recipe.output) ~= "table"
  108.       or type(recipe.output.name) ~= "string"
  109.       or type(recipe.output.count) ~= "number" then
  110.       return false
  111.     end
  112.   end
  113.  
  114.   local p = value.pending
  115.  
  116.   if p then
  117.     if type(p) ~= "table"
  118.       or type(p.key) ~= "string"
  119.       or not ({
  120.         learn = true,
  121.         arrange = true,
  122.         craft = true,
  123.         output = true
  124.       })[p.mode] then
  125.       return false
  126.     end
  127.  
  128.     if p.mode ~= "learn"
  129.       and (
  130.         not validLayout(p.layout)
  131.         or p.key ~= key(layoutInventory(p.layout))
  132.       ) then
  133.       return false
  134.     end
  135.  
  136.     if p.mode == "output"
  137.       and (
  138.         type(p.output) ~= "table"
  139.         or type(p.output.name) ~= "string"
  140.         or type(p.output.count) ~= "number"
  141.       ) then
  142.       return false
  143.     end
  144.   end
  145.  
  146.   return true
  147. end
  148.  
  149. local function loadMemory()
  150.   local newest, found = nil, false
  151.  
  152.   for index = 0, 1 do
  153.     local path = FILE .. index
  154.  
  155.     if fs.exists(path) then
  156.       found = true
  157.  
  158.       local ok, value = pcall(function()
  159.         local handle = assert(
  160.           fs.open(path, "r"),
  161.           "Cannot read memory"
  162.         )
  163.  
  164.         local data = handle.readAll()
  165.         handle.close()
  166.  
  167.         return textutils.unserialize(data)
  168.       end)
  169.  
  170.       if ok and validDatabase(value)
  171.         and (
  172.           not newest
  173.           or value.revision > newest.revision
  174.         ) then
  175.         newest = value
  176.       end
  177.     end
  178.   end
  179.  
  180.   assert(
  181.     newest or not found,
  182.     "Memory files unreadable: do not overwrite them"
  183.   )
  184.  
  185.   return newest or {
  186.     version = 1,
  187.     revision = 0,
  188.     recipes = {}
  189.   }
  190. end
  191.  
  192. local function saveMemory()
  193.   -- CC rejects repeated references to the same table.
  194.   -- Expand them into independent copies before serialization.
  195.   local function copy(value)
  196.     if type(value) ~= "table" then
  197.       return value
  198.     end
  199.  
  200.     local result = {}
  201.  
  202.     for k, v in pairs(value) do
  203.       result[k] = copy(v)
  204.     end
  205.  
  206.     return result
  207.   end
  208.  
  209.   -- Alternate files preserve the previous complete revision.
  210.   local revision = db.revision + 1
  211.   db.revision = revision
  212.  
  213.   local handle = assert(
  214.     fs.open(FILE .. (revision % 2), "w"),
  215.     "Cannot save memory"
  216.   )
  217.  
  218.   handle.write(textutils.serialize(copy(db)))
  219.   handle.close()
  220. end
  221.  
  222. local function findRecipe(ingredientKey)
  223.   for index, recipe in ipairs(db.recipes) do
  224.     if recipe.key == ingredientKey then
  225.       return index, recipe
  226.     end
  227.   end
  228. end
  229.  
  230. local function taughtLayout(inv, ingredientKey)
  231.   assert(
  232.     key(inv) == ingredientKey,
  233.     "Ingredients changed: restore the original batch"
  234.   )
  235.  
  236.   local layout = {}
  237.  
  238.   for slot, item in pairs(inv) do
  239.     assert(
  240.       IN_GRID[slot],
  241.       "Move all items into the displayed crafting grid"
  242.     )
  243.  
  244.     assert(
  245.       item.count == 1,
  246.       "Place exactly ONE ingredient in each occupied grid slot"
  247.     )
  248.   end
  249.  
  250.   for index, slot in ipairs(GRID) do
  251.     layout[index] = inv[slot] and inv[slot].name or false
  252.   end
  253.  
  254.   return layout
  255. end
  256.  
  257. local function arrange(layout, ingredientKey)
  258.   assert(
  259.     key(snapshot()) == ingredientKey,
  260.     "Ingredients changed"
  261.   )
  262.  
  263.   local fixed = {}
  264.  
  265.   local function move(from, to, count)
  266.     turtle.select(from)
  267.  
  268.     assert(
  269.       turtle.transferTo(to, count),
  270.       "Inventory transfer failed"
  271.     )
  272.   end
  273.  
  274.   local function emptySlot(exclude)
  275.     for slot = 1, 16 do
  276.       if slot ~= exclude
  277.         and not fixed[slot]
  278.         and turtle.getItemCount(slot) == 0 then
  279.         return slot
  280.       end
  281.     end
  282.  
  283.     error("No free rearrangement slot")
  284.   end
  285.  
  286.   for index, target in ipairs(GRID) do
  287.     local want = layout[index]
  288.     local current = turtle.getItemDetail(target)
  289.  
  290.     if current and current.name ~= want then
  291.       move(target, emptySlot(target), current.count)
  292.       current = nil
  293.     end
  294.  
  295.     if want then
  296.       if current and current.count > 1 then
  297.         move(
  298.           target,
  299.           emptySlot(target),
  300.           current.count - 1
  301.         )
  302.       elseif not current then
  303.         local source
  304.  
  305.         for slot = 1, 16 do
  306.           local item = turtle.getItemDetail(slot)
  307.  
  308.           if slot ~= target
  309.             and not fixed[slot]
  310.             and item
  311.             and item.name == want then
  312.             source = slot
  313.             break
  314.           end
  315.         end
  316.  
  317.         assert(source, "Missing ingredient: " .. want)
  318.         move(source, target, 1)
  319.       end
  320.     end
  321.  
  322.     fixed[target] = true
  323.   end
  324.  
  325.   local actual = taughtLayout(snapshot(), ingredientKey)
  326.  
  327.   for index = 1, 9 do
  328.     assert(
  329.       actual[index] == layout[index],
  330.       "Arranged pattern does not match memory"
  331.     )
  332.   end
  333. end
  334.  
  335. local function craftBatch()
  336.   local p = db.pending
  337.  
  338.   taughtLayout(snapshot(), p.key)
  339.  
  340.   local possible, reason = turtle.craft(0)
  341.  
  342.   assert(
  343.     possible,
  344.     reason or "This arrangement is not a crafting recipe"
  345.   )
  346.  
  347.   assert(
  348.     peripheral.hasType("top", "inventory"),
  349.     "Put an inventory chest above the turtle"
  350.   )
  351.  
  352.   turtle.select(16)
  353.   p.mode = "craft"
  354.  
  355.   -- Persist before consuming ingredients.
  356.   -- An interrupted craft must not be repeated blindly.
  357.   saveMemory()
  358.  
  359.   local ok, why = turtle.craft(1)
  360.  
  361.   if not ok then
  362.     p.mode = p.recipeIndex and "arrange" or "learn"
  363.     saveMemory()
  364.     error(why or "Craft failed")
  365.   end
  366.  
  367.   local inv = snapshot()
  368.  
  369.   assert(
  370.     inv[16],
  371.     "Result was not placed in slot 16: inspect the inventory"
  372.   )
  373.  
  374.   for slot in pairs(inv) do
  375.     assert(
  376.       slot == 16,
  377.       "Unexpected ingredients or remainders: inspect the inventory"
  378.     )
  379.   end
  380.  
  381.   local result = turtle.getItemDetail(16, true)
  382.  
  383.   p.output = {
  384.     name = result.name,
  385.     count = result.count,
  386.     label = result.displayName or result.name
  387.   }
  388.  
  389.   if not p.recipeIndex then
  390.     assert(
  391.       #db.recipes < 9,
  392.       "All nine memory slots are occupied"
  393.     )
  394.  
  395.     db.recipes[#db.recipes + 1] = {
  396.       key = p.key,
  397.       layout = p.layout,
  398.       output = p.output
  399.     }
  400.  
  401.     p.recipeIndex = #db.recipes
  402.   else
  403.     local expected = db.recipes[p.recipeIndex].output
  404.  
  405.     assert(
  406.       result.name == expected.name
  407.         and result.count == expected.count,
  408.       "Recipe output changed: inspect the inventory"
  409.     )
  410.   end
  411.  
  412.   p.mode = "output"
  413.   saveMemory()
  414. end
  415.  
  416. local function outputBatch()
  417.   local p = db.pending
  418.   local item = turtle.getItemDetail(16)
  419.  
  420.   if not item then
  421.     db.pending = nil
  422.     saveMemory()
  423.  
  424.     outputBlocked = false
  425.     lastKey, lastChange = nil, os.clock()
  426.     return
  427.   end
  428.  
  429.   assert(
  430.     item.name == p.output.name
  431.       and item.count <= p.output.count,
  432.     "Unexpected item in output slot 16"
  433.   )
  434.  
  435.   assert(
  436.     peripheral.hasType("top", "inventory"),
  437.     "Output chest is missing"
  438.   )
  439.  
  440.   turtle.select(16)
  441.  
  442.   -- If the chest is full, keep the result and retry later.
  443.   turtle.dropUp(item.count)
  444.   outputBlocked = turtle.getItemCount(16) > 0
  445. end
  446.  
  447. local function tick()
  448.   local p = db.pending
  449.  
  450.   if p then
  451.     if p.mode == "arrange" then
  452.       arrange(p.layout, p.key)
  453.       craftBatch()
  454.     elseif p.mode == "output" then
  455.       outputBatch()
  456.     elseif p.mode == "craft" then
  457.       error(
  458.         "Interrupted craft: empty the turtle, then click Retry"
  459.       )
  460.     end
  461.  
  462.     return
  463.   end
  464.  
  465.   local inv = snapshot()
  466.   local ingredientKey = key(inv)
  467.  
  468.   if ingredientKey ~= lastKey then
  469.     lastKey, lastChange = ingredientKey, os.clock()
  470.     return
  471.   end
  472.  
  473.   if ingredientKey == ""
  474.     or os.clock() - lastChange < 1 then
  475.     return
  476.   end
  477.  
  478.   local _, count = totals(inv)
  479.  
  480.   assert(
  481.     count <= 9,
  482.     "More than nine ingredients: check the PPV batch size"
  483.   )
  484.  
  485.   local index, recipe = findRecipe(ingredientKey)
  486.  
  487.   assert(
  488.     recipe or #db.recipes < 9,
  489.     "Memory full: remove this unknown batch"
  490.   )
  491.  
  492.   db.pending = {
  493.     key = ingredientKey,
  494.     mode = recipe and "arrange" or "learn",
  495.     recipeIndex = index,
  496.     layout = recipe and recipe.layout or nil
  497.   }
  498.  
  499.   saveMemory()
  500. end
  501.  
  502. local function confirm()
  503.   assert(
  504.     db.pending and db.pending.mode == "learn",
  505.     "No recipe awaiting confirmation"
  506.   )
  507.  
  508.   db.pending.layout = taughtLayout(
  509.     snapshot(),
  510.     db.pending.key
  511.   )
  512.  
  513.   craftBatch()
  514. end
  515.  
  516. local function retry()
  517.   if db.pending and db.pending.mode == "craft" then
  518.     assert(
  519.       key(snapshot()) == "",
  520.       "Inspect and empty the turtle before resetting"
  521.     )
  522.  
  523.     db.pending = nil
  524.     saveMemory()
  525.   elseif not db.pending then
  526.     lastKey, lastChange = nil, os.clock()
  527.   end
  528.  
  529.   fault = nil
  530. end
  531.  
  532. local function safely(action)
  533.   local ok, message = pcall(action)
  534.  
  535.   if not ok then
  536.     fault, page = tostring(message), "error"
  537.   end
  538. end
  539.  
  540. local function draw()
  541.   local width, height = term.getSize()
  542.   buttons = {}
  543.  
  544.   term.setBackgroundColor(colors.black)
  545.   term.setTextColor(colors.white)
  546.   term.clear()
  547.  
  548.   local function text(x, y, value, color, limit)
  549.     if y < 1 or y > height then
  550.       return
  551.     end
  552.  
  553.     term.setCursorPos(x, y)
  554.     term.setTextColor(color or colors.white)
  555.  
  556.     term.write(
  557.       tostring(value):sub(
  558.         1,
  559.         math.min(limit or width, width - x + 1)
  560.       )
  561.     )
  562.   end
  563.  
  564.   local function button(x, label, action, color)
  565.     text(x, height, label, color or colors.cyan)
  566.  
  567.     buttons[#buttons + 1] = {
  568.       x = x,
  569.       y = height,
  570.       w = #label,
  571.       action = action
  572.     }
  573.   end
  574.  
  575.   local inv = snapshot()
  576.   local p = db.pending
  577.  
  578.   text(
  579.     1,
  580.     1,
  581.     "PPV Crafter     Memory: " .. #db.recipes .. "/9",
  582.     colors.yellow
  583.   )
  584.  
  585.   if page == "error" then
  586.     text(
  587.       1,
  588.       2,
  589.       "Paused - " .. (p and p.mode or "intake"),
  590.       colors.red
  591.     )
  592.  
  593.     local message = fault or "Unknown error"
  594.  
  595.     for row = 3, height - 1 do
  596.       text(1, row, message:sub(1, width))
  597.       message = message:sub(width + 1)
  598.     end
  599.  
  600.     button(1, "[ Retry ]", function()
  601.       retry()
  602.       page = nil
  603.     end)
  604.  
  605.   elseif page then
  606.     local recipe =
  607.       type(page) == "number" and db.recipes[page] or nil
  608.  
  609.     local items =
  610.       recipe and layoutInventory(recipe.layout) or inv
  611.  
  612.     local amounts = totals(items)
  613.     local names = {}
  614.  
  615.     for name in pairs(amounts) do
  616.       names[#names + 1] = name
  617.     end
  618.  
  619.     table.sort(names)
  620.  
  621.     text(
  622.       1,
  623.       2,
  624.       recipe
  625.         and (
  626.           recipe.output.count .. " x "
  627.           .. recipe.output.name
  628.         )
  629.         or "Current ingredients"
  630.     )
  631.  
  632.     for index, name in ipairs(names) do
  633.       text(
  634.         1,
  635.         index + 3,
  636.         amounts[name] .. " x " .. name
  637.       )
  638.     end
  639.  
  640.     button(1, "[ Back ]", function()
  641.       page = nil
  642.     end)
  643.  
  644.   else
  645.     local status = fault and "Paused"
  646.       or not p and "Waiting for batch"
  647.       or ({
  648.         learn = "Arrange, then confirm",
  649.         arrange = "Arranging",
  650.         craft = "Crafting",
  651.         output = outputBlocked
  652.           and "Output chest blocked"
  653.           or "Sending output"
  654.       })[p.mode]
  655.  
  656.     text(
  657.       1,
  658.       2,
  659.       status,
  660.       fault and colors.red or colors.lime
  661.     )
  662.  
  663.     text(1, 3, "Crafting grid")
  664.     text(1, 7, "1 item per cell", colors.gray, 15)
  665.  
  666.     for index, slot in ipairs(GRID) do
  667.       local x = 1 + ((index - 1) % 3) * 5
  668.       local y = 4 + math.floor((index - 1) / 3)
  669.  
  670.       text(
  671.         x,
  672.         y,
  673.         string.format("[%02d]", slot),
  674.         colors.gray
  675.       )
  676.  
  677.       local item = inv[slot]
  678.  
  679.       text(
  680.         x,
  681.         y + 4,
  682.         item
  683.           and item.name:match("[^:]+$"):sub(1, 4)
  684.           or "----",
  685.         colors.cyan,
  686.         4
  687.       )
  688.     end
  689.  
  690.     local outside = 0
  691.  
  692.     for slot, item in pairs(inv) do
  693.       if not IN_GRID[slot] then
  694.         outside = outside + item.count
  695.       end
  696.     end
  697.  
  698.     text(
  699.       1,
  700.       11,
  701.       "Outside: " .. outside,
  702.       colors.gray,
  703.       15
  704.     )
  705.  
  706.     local split = 17
  707.  
  708.     for index = 1, 9 do
  709.       local recipe = db.recipes[index]
  710.  
  711.       text(
  712.         split,
  713.         index + 2,
  714.         index .. " " .. (
  715.           recipe
  716.             and (
  717.               recipe.output.count .. "x "
  718.               .. recipe.output.label
  719.             )
  720.             or "-"
  721.         ),
  722.         recipe and colors.white or colors.gray
  723.       )
  724.  
  725.       if recipe then
  726.         buttons[#buttons + 1] = {
  727.           x = split,
  728.           y = index + 2,
  729.           w = width - split + 1,
  730.           action = function()
  731.             page = index
  732.           end
  733.         }
  734.       end
  735.     end
  736.  
  737.     if fault then
  738.       button(
  739.         1,
  740.         "[ Error ]",
  741.         function()
  742.           page = "error"
  743.         end,
  744.         colors.red
  745.       )
  746.     elseif p and p.mode == "learn" then
  747.       button(
  748.         1,
  749.         "[ Confirm ]",
  750.         confirm,
  751.         colors.lime
  752.       )
  753.     end
  754.  
  755.     button(
  756.       16,
  757.       "[ Ingredients ]",
  758.       function()
  759.         page = "ingredients"
  760.       end
  761.     )
  762.   end
  763. end
  764.  
  765. -- Start program.
  766.  
  767. assert(
  768.   turtle and turtle.craft,
  769.   "Run this on a crafty turtle"
  770. )
  771.  
  772. assert(
  773.   term.isColor(),
  774.   "Use an advanced turtle"
  775. )
  776.  
  777. db = loadMemory()
  778.  
  779. if db.pending and db.pending.mode == "craft" then
  780.   fault =
  781.     "Interrupted craft: inspect and empty the turtle, then click Retry"
  782.   page = "error"
  783. end
  784.  
  785. draw()
  786. local timer = os.startTimer(0.25)
  787.  
  788. while true do
  789.   local event, a, x, y = os.pullEvent()
  790.  
  791.   if event == "timer" and a == timer then
  792.     if not fault then
  793.       safely(tick)
  794.     end
  795.  
  796.     draw()
  797.  
  798.     -- Schedule only after turtle operations and redraw finish.
  799.     timer = os.startTimer(0.25)
  800.  
  801.   elseif event == "mouse_click" and a == 1 then
  802.     for _, hit in ipairs(buttons) do
  803.       if y == hit.y
  804.         and x >= hit.x
  805.         and x < hit.x + hit.w then
  806.  
  807.         os.cancelTimer(timer)
  808.  
  809.         safely(hit.action)
  810.         draw()
  811.  
  812.         -- Blocking turtle calls can consume an existing timer.
  813.         -- Always start a fresh one after the action completes.
  814.         timer = os.startTimer(0.25)
  815.         break
  816.       end
  817.     end
  818.  
  819.   elseif event == "turtle_inventory"
  820.     or event == "term_resize" then
  821.  
  822.     os.cancelTimer(timer)
  823.     draw()
  824.     timer = os.startTimer(0.25)
  825.   end
  826. end
Add Comment
Please, Sign In to add comment