Files
opus-apps/builder/builder.lua
kepler155c@gmail.com b4f51df463 nothing to see here
2019-07-18 17:25:07 -06:00

777 lines
20 KiB
Lua

if not _G.turtle and not _G.commands then
error('Must be run on a turtle or a command computer')
end
local Adapter = require('core.inventoryAdapter')
local Event = require('opus.event')
local GPS = require('opus.gps')
local itemDB = require('core.itemDB')
local Schematic = require('builder.schematic')
local TableDB = require('core.tableDB')
local UI = require('opus.ui')
local Util = require('opus.util')
local colors = _G.colors
local fs = _G.fs
local BUILDER_DIR = 'usr/builder'
local substitutionPage
local Builder
if _G.commands then
Builder = require('builder.commands')
else
Builder = require('builder.turtle')
end
Builder = Builder()
Builder.schematic = Schematic()
local function convertSingleBack(item)
if item then
item.id = item.name
item.dmg = item.damage
item.qty = item.count
item.max_size = item.maxCount
item.display_name = item.displayName
end
return item
end
local function convertBack(t)
for _,v in pairs(t) do
convertSingleBack(v)
end
return t
end
--[[-- SubDB --]]--
local subDB = TableDB({
fileName = fs.combine(BUILDER_DIR, 'sub.db'),
})
function subDB:load()
if fs.exists(self.fileName) then
TableDB.load(self)
elseif not Builder.isCommandComputer then
self:seedDB()
end
end
function subDB:seedDB()
self.data = {
[ "minecraft:redstone_wire:0" ] = "minecraft:redstone:0",
[ "minecraft:wall_sign:0" ] = "minecraft:sign:0",
[ "minecraft:standing_sign:0" ] = "minecraft:sign:0",
[ "minecraft:potatoes:0" ] = "minecraft:potato:0",
[ "minecraft:unlit_redstone_torch:0" ] = "minecraft:redstone_torch:0",
[ "minecraft:powered_repeater:0" ] = "minecraft:repeater:0",
[ "minecraft:unpowered_repeater:0" ] = "minecraft:repeater:0",
[ "minecraft:carrots:0" ] = "minecraft:carrot:0",
[ "minecraft:cocoa:0" ] = "minecraft:dye:3",
[ "minecraft:unpowered_comparator:0" ] = "minecraft:comparator:0",
[ "minecraft:powered_comparator:0" ] = "minecraft:comparator:0",
[ "minecraft:piston_head:0" ] = "minecraft:air:0",
[ "minecraft:piston_extension:0" ] = "minecraft:air:0",
[ "minecraft:portal:0" ] = "minecraft:air:0",
[ "minecraft:double_wooden_slab:0" ] = "minecraft:planks:0",
[ "minecraft:double_wooden_slab:1" ] = "minecraft:planks:1",
[ "minecraft:double_wooden_slab:2" ] = "minecraft:planks:2",
[ "minecraft:double_wooden_slab:3" ] = "minecraft:planks:3",
[ "minecraft:double_wooden_slab:4" ] = "minecraft:planks:4",
[ "minecraft:double_wooden_slab:5" ] = "minecraft:planks:5",
[ "minecraft:lit_redstone_lamp:0" ] = "minecraft:redstone_lamp:0",
[ "minecraft:double_stone_slab:1" ] = "minecraft:sandstone:0",
[ "minecraft:double_stone_slab:2" ] = "minecraft:planks:0",
[ "minecraft:double_stone_slab:3" ] = "minecraft:cobblestone:0",
[ "minecraft:double_stone_slab:4" ] = "minecraft:brick_block:0",
[ "minecraft:double_stone_slab:5" ] = "minecraft:stonebrick:0",
[ "minecraft:double_stone_slab:6" ] = "minecraft:nether_brick:0",
[ "minecraft:double_stone_slab:7" ] = "minecraft:quartz_block:0",
[ "minecraft:double_stone_slab:9" ] = "minecraft:sandstone:2",
[ "minecraft:double_stone_slab2:0" ] = "minecraft:sandstone:0",
[ "minecraft:stone_slab:2" ] = "minecraft:wooden_slab:0",
[ "minecraft:wheat:0" ] = "minecraft:wheat_seeds:0",
[ "minecraft:flowing_water:0" ] = "minecraft:air:0",
[ "minecraft:lit_furnace:0" ] = "minecraft:furnace:0",
[ "minecraft:wall_banner:0" ] = "minecraft:banner:0",
[ "minecraft:standing_banner:0" ] = "minecraft:banner:0",
[ "minecraft:tripwire:0" ] = "minecraft:string:0",
[ "minecraft:pumpkin_stem:0" ] = "minecraft:pumpkin_seeds:0",
}
self.dirty = true
self:flush()
end
function subDB:add(s)
TableDB.add(self, { s.id, s.dmg }, table.concat({ s.sid, s.sdmg }, ':'))
self:flush()
end
function subDB:remove(s)
-- TODO: tableDB.remove should take table key
TableDB.remove(self, s.id .. ':' .. s.dmg)
self:flush()
end
function subDB:extract(s)
local id, dmg = s:match('(.+):(%d+)')
return id, tonumber(dmg)
end
function subDB:getSubstitutedItem(id, dmg)
local sub = TableDB.get(self, { id, dmg })
if sub then
id, dmg = self:extract(sub)
end
return { id = id, dmg = dmg }
end
function subDB:lookupBlocksForSub(sid, sdmg)
local t = { }
for k,v in pairs(self.data) do
local id, dmg = self:extract(v)
if id == sid and dmg == sdmg then
id, dmg = self:extract(k)
t[k] = { id = id, dmg = dmg, sid = sid, sdmg = sdmg }
end
end
return t
end
--[[-- blankPage --]]--
local blankPage = UI.Page()
function blankPage:draw()
self:clear(colors.black)
self:setCursorPos(1, 1)
end
function blankPage:enable()
self:sync()
UI.Page.enable(self)
end
--[[-- selectSubstitutionPage --]]--
local selectSubstitutionPage = UI.Page({
titleBar = UI.TitleBar({
title = 'Select a substitution',
previousPage = 'listing'
}),
grid = UI.ScrollingGrid({
columns = {
{ heading = 'id', key = 'id' },
{ heading = 'dmg', key = 'dmg' },
},
sortColumn = 'id',
height = UI.term.height-1,
autospace = true,
y = 2,
}),
})
function selectSubstitutionPage:enable()
self.grid:adjustWidth()
self.grid:setIndex(1)
UI.Page.enable(self)
end
function selectSubstitutionPage:eventHandler(event)
if event.type == 'grid_select' then
substitutionPage.sub = event.selected
UI:setPage(substitutionPage)
elseif event.type == 'key' and event.key == 'q' then
UI:setPreviousPage()
else
return UI.Page.eventHandler(self, event)
end
return true
end
--[[-- substitutionPage --]]--
substitutionPage = UI.Page {
titleBar = UI.TitleBar {
previousPage = true,
title = 'Substitute a block'
},
menuBar = UI.MenuBar {
y = 2,
buttons = {
{ text = 'Accept', event = 'accept', help = 'Accept' },
{ text = 'Revert', event = 'revert', help = 'Restore to original' },
{ text = 'Air', event = 'air', help = 'Air' },
},
},
info = UI.Window { y = 4, width = UI.term.width, height = 3 },
grid = UI.ScrollingGrid {
columns = {
{ heading = 'Name', key = 'display_name', width = UI.term.width-9 },
{ heading = 'Qty', key = 'fQty', width = 5 },
},
sortColumn = 'display_name',
height = UI.term.height-7,
y = 7,
},
throttle = UI.Throttle { },
statusBar = UI.StatusBar { }
}
substitutionPage.menuBar:add({
filterLabel = UI.Text({
value = 'Search',
x = UI.term.width-14,
}),
filter = UI.TextEntry({
x = UI.term.width-7,
width = 7,
})
})
function substitutionPage.info:draw()
local sub = self.parent.sub
local inName = itemDB:getName({ name = sub.id, damage = sub.dmg })
local outName = ''
if sub.sid then
outName = itemDB:getName({ name = sub.sid, damage = sub.sdmg })
end
self:clear()
self:setCursorPos(1, 1)
self:print(' Replace ' .. inName .. '\n')
--self:print(' ' .. sub.id .. ':' .. sub.dmg .. '\n', nil, colors.yellow)
self:print(' With ' .. outName)
end
function substitutionPage:enable()
self.allItems = convertBack(Builder.itemAdapter:refresh())
self.grid.values = self.allItems
for _,item in pairs(self.grid.values) do
item.key = item.id .. ':' .. item.dmg
item.lname = string.lower(item.display_name)
item.fQty = Util.toBytes(item.qty)
end
self.grid:update()
self.menuBar.filter:reset()
self:setFocus(self.menuBar.filter)
UI.Page.enable(self)
end
function substitutionPage:applySubstitute(id, dmg)
self.sub.sid = id
self.sub.sdmg = dmg
end
function substitutionPage:eventHandler(event)
if event.type == 'grid_focus_row' then
local s = string.format('%s:%d',
event.selected.id,
event.selected.dmg)
self.statusBar:setStatus(s)
self.statusBar:draw()
elseif event.type == 'grid_select' then
self:applySubstitute(event.selected.id, event.selected.dmg)
self.info:draw()
elseif event.type == 'text_change' then
local text = event.text
if #text == 0 then
self.grid.values = self.allItems
else
self.grid.values = { }
for _,item in pairs(self.allItems) do
if string.find(item.lname, text) then
table.insert(self.grid.values, item)
end
end
end
self.grid:update()
self.grid:setIndex(1)
self.grid:draw()
elseif event.type == 'accept' or event.type == 'air' or event.type == 'revert' then
self.statusBar:setStatus('Saving changes...')
self.statusBar:draw()
self:sync()
if event.type == 'air' then
self:applySubstitute('minecraft:air', 0)
end
if event.type == 'revert' then
subDB:remove(self.sub)
elseif not self.sub.sid then
self.statusBar:setStatus('Select a substition')
self.statusBar:draw()
return UI.Page.eventHandler(self, event)
else
subDB:add(self.sub)
end
self.throttle:enable()
Builder:reloadSchematic(function() self.throttle:update() end)
self.throttle:disable()
UI:setPage('listing')
elseif event.type == 'cancel' then
UI:setPreviousPage()
end
return UI.Page.eventHandler(self, event)
end
--[[-- ListingPage --]]--
local listingPage = UI.Page({
titleBar = UI.TitleBar({
title = 'Supply List',
previousPage = 'start'
}),
menuBar = UI.MenuBar({
y = 2,
buttons = {
{ text = 'Craft', event = 'craft', help = 'Request crafting' },
{ text = 'Refresh', event = 'refresh', help = 'Refresh inventory' },
{ text = 'Toggle', event = 'toggle', help = 'Toggles needed blocks' },
{ text = 'Substitute', event = 'edit', help = 'Substitute a block' },
}
}),
grid = UI.ScrollingGrid({
columns = {
{ heading = 'Name', key = 'display_name', width = UI.term.width - 14 },
{ heading = 'Need', key = 'need', width = 5 },
{ heading = 'Have', key = 'qty', width = 5 },
},
sortColumn = 'display_name',
y = 3,
height = UI.term.height-3,
help = 'Set a block type or pick a substitute block'
}),
accelerators = {
q = 'menu',
c = 'craft',
r = 'refresh',
t = 'toggle',
},
statusBar = UI.StatusBar(),
fullList = true
})
function listingPage:enable(throttle)
listingPage:refresh(throttle)
UI.Page.enable(self)
end
function listingPage:eventHandler(event)
if event.type == 'craft' then
local s = self.grid:getSelected()
local item = convertSingleBack(Builder.itemAdapter:getItemInfo({
name = s.id,
damage = s.dmg,
nbtHash = s.nbt_hash,
}))
if item and item.is_craftable then
local qty = math.max(0, s.need - item.qty)
if item and Builder.itemAdapter.craftItems then
Builder.itemAdapter:craftItems({{ name = s.id, damage = s.dmg, nbtHash = s.nbt_hash, count = qty }})
local name = s.display_name or s.id
self.statusBar:timedStatus('Requested ' .. qty .. ' ' .. name, 3)
end
else
self.statusBar:timedStatus('Unable to craft')
end
elseif event.type == 'grid_focus_row' then
self.statusBar:setStatus(event.selected.id .. ':' .. event.selected.dmg)
self.statusBar:draw()
elseif event.type == 'refresh' then
self:refresh()
self:draw()
self.statusBar:timedStatus('Refreshed ', 3)
elseif event.type == 'toggle' then
self.fullList = not self.fullList
self:refresh()
self:draw()
elseif event.type == 'menu' then
UI:setPage('start')
elseif event.type == 'edit' or event.type == 'grid_select' then
self:manageBlock(self.grid:getSelected())
elseif event.type == 'focus_change' then
if event.focused.help then
self.statusBar:timedStatus(event.focused.help, 3)
end
end
return UI.Page.eventHandler(self, event)
end
function listingPage.grid:getDisplayValues(row)
row = Util.shallowCopy(row)
row.need = Util.toBytes(row.need)
row.qty = Util.toBytes(row.qty)
return row
end
function listingPage.grid:getRowTextColor(row, selected)
if row.is_craftable then
return colors.yellow
end
return UI.Grid:getRowTextColor(row, selected)
end
function listingPage:refresh(throttle)
local supplyList = Builder:getBlockCounts()
Builder.itemAdapter:refresh(throttle)
for _,b in pairs(supplyList) do
if b.need > 0 then
local item = convertSingleBack(Builder.itemAdapter:getItemInfo({
name = b.id,
damage = b.dmg,
nbtHash = b.nbt_hash,
}))
if item then
b.display_name = item.display_name
b.qty = item.qty
b.is_craftable = item.is_craftable
else
b.display_name = itemDB:getName({ name = b.id, damage = b.dmg })
end
end
if throttle then
throttle()
end
end
if self.fullList then
self.grid:setValues(supplyList)
else
local t = {}
for _,b in pairs(supplyList) do
if self.fullList or b.qty < b.need then
table.insert(t, b)
end
end
self.grid:setValues(t)
end
self.grid:setIndex(1)
end
function listingPage:manageBlock(selected)
local substitutes = subDB:lookupBlocksForSub(selected.id, selected.dmg)
if Util.empty(substitutes) then
substitutionPage.sub = { id = selected.id, dmg = selected.dmg }
UI:setPage(substitutionPage)
elseif Util.size(substitutes) == 1 then
local _,sub = next(substitutes)
substitutionPage.sub = sub
UI:setPage(substitutionPage)
else
selectSubstitutionPage.selected = selected
selectSubstitutionPage.grid:setValues(substitutes)
UI:setPage(selectSubstitutionPage)
end
end
--[[-- startPage --]]--
local wy = 2
local my = 3
if UI.term.width < 30 then
wy = 9
my = 2
end
local startPage = UI.Page {
window = UI.Window {
x = UI.term.width-16,
y = wy,
width = 16,
height = 9,
backgroundColor = colors.gray,
grid = UI.Grid {
columns = {
{ heading = 'Name', key = 'name', width = 6 },
{ heading = 'Value', key = 'value', width = 7 },
},
disableHeader = true,
x = 1,
y = 2,
width = 16,
height = 9,
inactive = true,
backgroundColor = colors.gray
},
},
menu = UI.Menu {
x = 2,
y = my,
height = 7,
backgroundColor = UI.Page.defaults.backgroundColor,
menuItems = {
{ prompt = 'Set starting level', event = 'startLevel' },
{ prompt = 'Set starting block', event = 'startBlock' },
{ prompt = 'Set starting point', event = 'startPoint' },
{ prompt = 'Supply list', event = 'assignBlocks' },
{ prompt = 'Toggle mode', event = 'toggleMode' },
{ prompt = 'Begin', event = 'begin' },
{ prompt = 'Quit', event = 'quit' }
}
},
startLevel = UI.Dialog {
title = 'Enter Starting Level',
height = 7,
form = UI.Form {
y = 3, x = 2, height = 4,
event = 'setStartLevel',
cancelEvent = 'slide_hide',
text = UI.Text {
x = 5, y = 1, width = 20,
textColor = colors.gray,
},
textEntry = UI.TextEntry {
formKey = 'level',
x = 15, y = 1, width = 7,
},
},
statusBar = UI.StatusBar(),
},
startBlock = UI.Dialog {
title = 'Enter Block Number',
height = 7,
form = UI.Form {
y = 3, x = 2, height = 4,
event = 'setStartBlock',
cancelEvent = 'slide_hide',
text = UI.Text {
x = 2, y = 1, width = 20,
textColor = colors.gray,
},
textEntry = UI.TextEntry {
x = 16, y = 1,
width = 10, limit = 8,
},
},
statusBar = UI.StatusBar(),
},
startPoint = UI.Dialog {
title = 'Set starting point',
height = 11,
form = UI.Form {
y = 2, x = 2, ey = -2,
cancelEvent = 'slide_hide',
text1 = UI.Text {
x = 1, y = 2, value = 'Turtle location' },
xLoc = UI.TextEntry {
x = 1, y = 3, formKey = 'x', width = 7, limit = 16, shadowText = 'x', required = true },
yLoc = UI.TextEntry {
x = 9, y = 3, formKey = 'y', width = 7, limit = 16, shadowText = 'y', required = true },
zLoc = UI.TextEntry {
x = 17, y = 3, formKey = 'z', width = 7, limit = 16, shadowText = 'z', required = true },
text2 = UI.Text {
x = 1, y = 5, value = 'Starting Point' },
xrLoc = UI.TextEntry {
x = 1, y = 6, formKey = 'rx', width = 7, limit = 16, shadowText = 'x', required = true },
yrLoc = UI.TextEntry {
x = 9, y = 6, formKey = 'ry', width = 7, limit = 16, shadowText = 'y', required = true },
zrLoc = UI.TextEntry {
x = 17, y = 6, formKey = 'rz', width = 7, limit = 16, shadowText = 'z', required = true },
revert = UI.Button {
x = 1, y = -2, text = 'Revert', event = 'revert' },
accelerators = {
form_cancel = 'slide_hide',
},
},
statusBar = UI.StatusBar({ values = 'Optional start point'}),
},
throttle = UI.Throttle { },
accelerators = {
x = 'test',
[ 'control-q' ] = 'quit'
}
}
function startPage:draw()
local t = {
{ name = 'mode', value = Builder.mode },
{ name = 'start', value = Builder.index },
{ name = 'blocks', value = #Builder.schematic.blocks },
{ name = 'length', value = Builder.schematic.length },
{ name = 'width', value = Builder.schematic.width },
{ name = 'height', value = Builder.schematic.height },
}
self.window.grid:setValues(t)
UI.Page.draw(self)
end
function startPage:enable()
self:setFocus(self.menu)
UI.Page.enable(self)
end
function startPage.startPoint:eventHandler(event)
if event.type == 'form_complete' then
for k,v in pairs(event.values) do
Builder.loc[k] = tonumber(v)
end
Builder:saveProgress(Builder.index)
self:hide()
elseif event.type == 'revert' then
Builder.loc = { }
Builder:saveProgress(Builder.index)
self:hide()
elseif event.type == 'form_invalid' then
self.statusBar:setStatus(event.message)
elseif event.type == 'form_cancel' or event.type == 'cancel' then
self:hide()
else
return UI.Dialog.eventHandler(self, event)
end
return true
end
function startPage:eventHandler(event)
if event.type == 'startLevel' then
self.startLevel.form.text.value = '0 - ' .. Builder.schematic.height
self.startLevel:show()
elseif event.type == 'setStartLevel' then
local l = tonumber(self.startLevel.form.textEntry.value)
if l and l < Builder.schematic.height and l >= 0 then
for k,v in pairs(Builder.schematic.blocks) do
if v.y >= l then
Builder.index = k
Builder:saveProgress(Builder.index)
break
end
end
self.startLevel:hide()
self:draw()
else
self.startLevel.statusBar:setStatus('Invalid start level')
end
elseif event.type == 'startBlock' then
self.startBlock.form.text.value = '1 - ' .. #Builder.schematic.blocks
self.startBlock.form.textEntry.value = tostring(Builder.index)
self.startBlock:show()
elseif event.type == 'setStartBlock' then
local bn = tonumber(self.startBlock.form.textEntry.value)
if bn and bn < #Builder.schematic.blocks and bn >= 0 then
Builder.index = bn
Builder:saveProgress(Builder.index)
self.startBlock:hide()
self:draw()
else
self.startLevel.statusBar:setStatus('Invalid start block')
end
elseif event.type == 'startPoint' then
local loc = Util.shallowCopy(Builder.loc)
if not loc.x then
if _G.turtle then
local pt = GPS.getPoint()
if pt then
loc.x = pt.x
loc.y = pt.y
loc.z = pt.z
end
elseif _G.commands then
loc.x, loc.y, loc.z = _G.commands.getBlockPosition()
end
end
self.startPoint.form:setValues(loc)
self.startPoint:show()
elseif event.type == 'assignBlocks' then
-- this might be an approximation of the blocks needed
-- as the current level's route may or may not have been
-- computed
Builder:dumpInventory()
UI:setPage('listing', function() self.throttle:update() end)
self.throttle:disable()
elseif event.type == 'toggleMode' then
if Builder.mode == 'build' then
if Builder.index == 1 then
Builder.index = #Builder.schematic.blocks
end
Builder.mode = 'destroy'
else
if Builder.index == #Builder.schematic.blocks then
Builder.index = 1
end
Builder.mode = 'build'
end
self:draw()
elseif event.type == 'begin' then
UI:setPage('blank')
self:sync()
print('Reloading schematic')
Builder:reloadSchematic(Util.throttle())
Builder:begin()
elseif event.type == 'quit' then
UI.term:reset()
Event.exitPullEvents()
end
return UI.Page.eventHandler(self, event)
end
--[[-- startup logic --]]--
local args = {...}
if #args < 1 then
error('supply file name or URL')
end
Builder.itemAdapter = Adapter.wrap({ side = 'bottom', direction = 'up' })
if not Builder.itemAdapter then
error('A chest or ME interface must be below turtle')
end
subDB:load()
UI.term:reset()
print('Loading schematic')
Builder.schematic:load(args[1])
print('Substituting blocks')
Builder.subDB = subDB
Builder:substituteBlocks(Util.throttle())
if not fs.exists(BUILDER_DIR) then
fs.makeDir(BUILDER_DIR)
end
Builder:loadProgress(Builder.schematic.filename .. '.progress')
Event.on('build', function()
Builder:build()
end)
UI:setPages({
listing = listingPage,
start = startPage,
blank = blankPage
})
UI:setPage('start')
UI:pullEvents()