local preload = type(package) == "table" and type(package.preload) == "table" and package.preload or {} local require = require if type(require) ~= "function" then local loading = {} local loaded = {} require = function(name) local result = loaded[name] if result ~= nil then if result == loading then error("loop or previous error loading module '" .. name .. "'", 2) end return result end loaded[name] = loading local contents = preload[name] if contents then result = contents(name) else error("cannot load '" .. name .. "'", 2) end if result == nil then result = true end loaded[name] = result return result end end preload["util.sound"] = function(...) local function playSound(speaker, sound) if not speaker then return end speaker.playSound(sound.name, sound.volume, sound.pitch) end return { playSound = playSound } end preload["util.setPalette"] = function(...) require("modules.rif") -- Initialize colors return function(t, palette) for k, v in pairs(palette) do t.setPaletteColor(k, v) end end end preload["util.score"] = function(...) local score = {} ---Applies {fun} to each element of {list} and returns a list of the results. ---@generic T, U ---@param list T[] ---@param fun fun(x: T, i: integer): U ---@return U[] function score.map(list, fun) local result = {__type = "list"} for i, v in ipairs(list) do result[i] = fun(v, i) end return result end function score.range(start, stop, step) local result = {__type = "list"} if step == nil then step = 1 end if stop == nil then stop = start start = 1 end for i = start, stop, step do result[#result + 1] = i end return result end function score.rangeMap(start, stop, step, fun) if type(start) == "function" then fun = start start = 1 stop = nil step = nil elseif type(stop) == "function" then fun = stop stop = start start = 1 step = nil elseif type(step) == "function" then fun = step step = 1 end return score.map(score.range(start, stop, step), fun) end ---Returns a list of the elements from {list} that satisfy the predicate {fun}. ---@generic T ---@param list T[] ---@param fun fun(x: T): boolean ---@return T[] function score.filter(list, fun) local result = {__type = "list"} for i, v in ipairs(list) do if fun(v) then result[#result + 1] = v end end return result end function score.intersectSeq(list1, list2) local result = {__type = "list"} if #list1 > #list2 then list1, list2 = list2, list1 end for i = #list1 + 1, #list2 do result[#result + 1] = list2[i] end return result end function score.flat(list) local result = {__type = "list"} for i, v in ipairs(list) do if v.__type == "list" then for j, w in ipairs(v) do result[#result + 1] = w end else result[#result + 1] = v end end return result end ---Fisher yates shuffle ---@generic T ---@param list T[] ---@return T[] function score.shuffle(list) local n = #list while n > 2 do local k = math.random(n) list[n], list[k] = list[k], list[n] n = n - 1 end return list end function score.append(list, value) list = {unpack(list)} list[#list + 1] = value return list end ---Deeply copies a table, except for tables that have a __opaque property. ---@generic T: table ---@param t T ---@return T function score.copyDeep(t) if type(t) ~= "table" then return t end local copy = {} for k, v in pairs(t) do if type(v) == "table" and v.__opaque == nil and v.__nocopy == nil then copy[k] = score.copyDeep(v) else copy[k] = v end end return copy end ---@generic T ---@param list T[] ---@return T[] function score.filterTruthy(list) local result = {__type = "list"} for i, v in ipairs(list) do if v then result[#result + 1] = v end end return result end return score end preload["util.renderHelpers"] = function(...) local bigFont = require("fonts.bigfont") local smolFont = require("fonts.smolfont") local Pricing = require("core.Pricing") local function getDisplayedProducts(allProducts, settings, currency) local displayedProducts = {} for i = 1, #allProducts do local product = allProducts[i] product.id = i local productPrice = Pricing.getProductPrice(product, currency) if (not settings.hideUnavailableProducts or (product.quantity and product.quantity > 0)) and (not settings.hideNegativePrices or (productPrice and productPrice >= 0)) then table.insert(displayedProducts, product) end end return displayedProducts end local function getCurrencySymbol(currency, layout) if currency.krypton and currency.krypton.currency then currencySymbol = currency.krypton.currency.currency_symbol elseif not currencySymbol and currency.name and currency.name:find("%.") then currencySymbol = currency.name:sub(currency.name:find("%.")+1, #currency.name) elseif currency.id == "tenebra" then currencySymbol = "tst" else currencySymbol = "KST" end if currencySymbol == "TST" then currencySymbol = "tst" end if currencySymbol:lower() == "kst" and (layout == "medium" or layout == "small") then currencySymbol = "kst" elseif currencySymbol:lower() == "kst" then currencySymbol = "\164" end return currencySymbol end local function getCategories(products) local categories = {} for _, product in ipairs(products) do if not product.hidden then local category = product.category if not category then category = "*" end local found = nil for i = 1, #categories do if categories[i].name == category then found = i break end end if not found then if category == "*" then table.insert(categories, 1, {name=category, products={}}) found = 1 else table.insert(categories, {name=category, products={}}) found = #categories end end table.insert(categories[found].products, product) end end return categories end local function getWidth(text, fontSize) if fontSize == "large" then return bigFont:getWidth(text) elseif fontSize == "medium" then return smolFont:getWidth(text) else return #text end end local function getThemeSetting(theme, path, layout) -- Split on "." layoutTheme = {} if theme.layouts and theme.layouts[layout] then layoutTheme = theme.layouts[layout] end local paths = path:gmatch("[^%.]+") for subpath in paths do if layoutTheme and layoutTheme[subpath] then layoutTheme = layoutTheme[subpath] else layoutTheme = nil end if theme and theme[subpath] then theme = theme[subpath] else theme = nil end if not theme and not layoutTheme then return nil end end if layoutTheme then return layoutTheme else return theme end end return { getDisplayedProducts = getDisplayedProducts, getCurrencySymbol = getCurrencySymbol, getCategories = getCategories, getWidth = getWidth, getThemeSetting = getThemeSetting, } end preload["util.misc"] = function(...) local _ = require("util.score") local Solyd = require("modules.solyd") local Canvases = require("modules.canvas") local PixelCanvas = Canvases.PixelCanvas local function tableSize(t) if type(t) ~= "table" then return nil end local count = 0 for k, v in pairs(t) do count = count + 1 end return count end ---Renders a Solyd tree and bakes the canvases into one function bakeToCanvas(rootComponent) local tree = Solyd.render(nil, rootComponent) local context = Solyd.getTopologicalContext(tree, { "canvas" }) local minX, minY = math.huge, math.huge local maxX, maxY = -math.huge, -math.huge for _, canvas in ipairs(context.canvas) do minX = math.min(minX, canvas[2]) minY = math.min(minY, canvas[3]) maxX = math.max(maxX, canvas[2] + canvas[1].width - 1) maxY = math.max(maxY, canvas[3] + canvas[1].height - 1) end local canvas = PixelCanvas.new(maxX - minX + 1, maxY - minY + 1) canvas:composite(_.map(context.canvas, function(c) return {c[1], c[2] - minX + 1, c[3] - minY + 1} end)) return canvas end --Deeply copies a table, not taking into account __opaque and __nocopy local function plainDeepCopy(t) if type(t) ~= "table" then return t end local copy = {} for k, v in pairs(t) do if type(v) == "table" then copy[k] = plainDeepCopy(v) else copy[k] = v end end return copy end return { tableSize = tableSize, bakeToCanvas = bakeToCanvas, plainDeepCopy = plainDeepCopy } end preload["util.iter"] = function(...) -- Iterator for list local function list(xs) local i = 0 return function() i = i + 1 return xs[i] end end return { list = list, } end preload["util.eventHook"] = function(...) local function executeHook(func, ...) local args = {...} local ret = {pcall(func, unpack(args))} if not ret[1] then print("Error in hook: " .. ret[2]) return end table.remove(ret, 1) return unpack(ret) end return { execute = executeHook } end preload["util.configHelpers"] = function(...) local score = require("util.score") function getPeripherals(config, peripherals) local modem local failed = 0 repeat if config.peripherals.modem then modem = peripheral.wrap(config.peripherals.modem) else modem = peripheral.find("modem", function(pName) return not peripheral.wrap(pName).isWireless() end) if not modem then error("No modem found") end if not modem.getNameLocal() then error("Modem is not connected! Turn it on by right clicking it!") end end if not modem then failed = failed + 1 sleep(2) end until modem or failed > 2 if not modem then error("No modem found") end local shopSyncModem if config.peripherals.shopSyncModem then shopSyncModem = peripheral.wrap(config.peripherals.shopSyncModem) else shopSyncModem = peripheral.find("modem", function(pName) return peripheral.wrap(pName).isWireless() end) if not shopSyncModem and config.shopSync and config.shopSync.enabled then error("No wireless modem found but ShopSync is enabled!") end end local speaker if config.peripherals.speaker then speaker = peripheral.wrap(config.peripherals.speaker) else speaker = peripheral.find("speaker") end if modem then peripherals.modem = modem end if shopSyncModem then peripherals.shopSyncModem = shopSyncModem end if speaker then peripherals.speaker = speaker end return peripherals end local colorNames = { [colors.black] = "colors.black", [colors.blue] = "colors.blue", [colors.purple] = "colors.purple", [colors.green] = "colors.green", [colors.brown] = "colors.brown", [colors.gray] = "colors.gray", [colors.lightGray] = "colors.lightGray", [colors.red] = "colors.red", [colors.orange] = "colors.orange", [colors.yellow] = "colors.yellow", [colors.lime] = "colors.lime", [colors.cyan] = "colors.cyan", [colors.magenta] = "colors.magenta", [colors.pink] = "colors.pink", [colors.lightBlue] = "colors.lightBlue", [colors.white] = "colors.white", } function getColorName(num) return colorNames[num] end function getNewConfig(config, configDiffs, arrayAdds, arrayRemoves) local newConfig = score.copyDeep(config) for k, addSchema in pairs(arrayAdds) do local subConfig = newConfig local fullPath = nil for path in k:gmatch("([^%[?%]?%.?]+)") do if fullPath then fullPath = fullPath .. "." .. path else fullPath = path end if path:match("%d+") then path = tonumber(path) or path end if subConfig[path] then subConfig = subConfig[path] else if fullPath == k and type(addSchema) == "string" and addSchema:sub(1,6) == "number" then subConfig[path] = 0 else subConfig[path] = {} end subConfig = subConfig[path] end end end for k, v in pairs(configDiffs) do local subConfig = newConfig for path in k:gmatch("([^%[?%]?%.?]+)%.+") do if path:match("%d+") then path = tonumber(path) or path end if subConfig[path] then subConfig = subConfig[path] else subConfig[path] = {} subConfig = subConfig[path] end end local lastPath = k:match("^.+%.([^%.]+)$") if lastPath:match("%d+") then lastPath = tonumber(lastPath) or lastPath end if v == "%nil%" then v = nil end subConfig[lastPath] = v end local removalArrays = {} for k, _ in pairs(arrayRemoves) do local subConfig = newConfig for path in k:gmatch("([^%[?%]?%.?]+)%.+") do if path:match("%d+") then path = tonumber(path) or path end if subConfig[path] then subConfig = subConfig[path] else subConfig[path] = {} subConfig = subConfig[path] end end local lastPath, index = ("." .. k):match("^.*%.([^%.]+)%.([^%.]+)$") if not lastPath then lastPath = "" end if not index then index = k:match("^.*%.([^%.]+)$") end if lastPath:match("%d+") then lastPath = tonumber(lastPath) or lastPath end if index:match("%d+") then index = tonumber(index) or index end local array = subConfig -- if lastPath ~= "" then -- array = subConfig[lastPath] -- else -- array = subConfig -- end if not removalArrays[lastPath] then removalArrays[lastPath] = { array = array, indices = {} } end table.insert(removalArrays[lastPath].indices, index) end for k, v in pairs(removalArrays) do table.sort(v.indices, function(a, b) return a > b end) for _, index in ipairs(v.indices) do table.remove(v.array, index) end end if newConfig.currencies then for k,v in pairs(newConfig.currencies) do newConfig.currencies[k].krypton = nil end end newConfig.hooks = nil return newConfig end function loadDefaults(config, defaults) for k, v in pairs(defaults) do if type(v) == "table" then if v[1] then -- Is a table, only replace if not exists if not config[k] then config[k] = {} loadDefaults(config[k], v) end else if not config[k] then config[k] = {} end loadDefaults(config[k], v) end else if config[k] == nil then config[k] = v end end end end return { getPeripherals = getPeripherals, getColorName = getColorName, getNewConfig = getNewConfig, loadDefaults = loadDefaults, } end preload["util.base64"] = function(...) --[[ base64 -- v1.5.3 public domain Lua base64 encoder/decoder no warranty implied; use at your own risk Needs bit32.extract function. If not present it's implemented using BitOp or Lua 5.3 native bit operators. For Lua 5.1 fallbacks to pure Lua implementation inspired by Rici Lake's post: http://ricilake.blogspot.co.uk/2007/10/iterating-bits-in-lua.html author: Ilya Kolbin (iskolbin@gmail.com) url: github.com/iskolbin/lbase64 COMPATIBILITY Lua 5.1+, LuaJIT LICENSE See end of file for license information. --]] local base64 = {} local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode if not extract then if _G.bit then -- LuaJIT local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band extract = function( v, from, width ) return band( shr( v, from ), shl( 1, width ) - 1 ) end elseif _G._VERSION == "Lua 5.1" then extract = function( v, from, width ) local w = 0 local flag = 2^from for i = 0, width-1 do local flag2 = flag + flag if v % flag2 >= flag then w = w + 2^i end flag = flag2 end return w end else -- Lua 5.3+ extract = load[[return function( v, from, width ) return ( v >> from ) & ((1 << width) - 1) end]]() end end function base64.makeencoder( s62, s63, spad ) local encoder = {} for b64code, char in pairs{[0]='A','B','C','D','E','F','G','H','I','J', 'K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y', 'Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n', 'o','p','q','r','s','t','u','v','w','x','y','z','0','1','2', '3','4','5','6','7','8','9',s62 or '+',s63 or'/',spad or'='} do encoder[b64code] = char:byte() end return encoder end function base64.makedecoder( s62, s63, spad ) local decoder = {} for b64code, charcode in pairs( base64.makeencoder( s62, s63, spad )) do decoder[charcode] = b64code end return decoder end local DEFAULT_ENCODER = base64.makeencoder() local DEFAULT_DECODER = base64.makedecoder() local char, concat = string.char, table.concat function base64.encode( str, encoder, usecaching ) encoder = encoder or DEFAULT_ENCODER local t, k, n = {}, 1, #str local lastn = n % 3 local cache = {} for i = 1, n-lastn, 3 do if i % 900 == 0 then os.queueEvent("yieldb64") os.pullEvent("yieldb64") end local a, b, c = str:byte( i, i+2 ) local v = a*0x10000 + b*0x100 + c local s if usecaching then s = cache[v] if not s then s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)]) cache[v] = s end else s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)]) end t[k] = s k = k + 1 end if lastn == 2 then local a, b = str:byte( n-1, n ) local v = a*0x10000 + b*0x100 t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[64]) elseif lastn == 1 then local v = str:byte( n )*0x10000 t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[64], encoder[64]) end return concat( t ) end function base64.decode( b64, decoder, usecaching ) decoder = decoder or DEFAULT_DECODER local pattern = '[^%w%+%/%=]' if decoder then local s62, s63 for charcode, b64code in pairs( decoder ) do if b64code == 62 then s62 = charcode elseif b64code == 63 then s63 = charcode end end pattern = ('[^%%w%%%s%%%s%%=]'):format( char(s62), char(s63) ) end b64 = b64:gsub( pattern, '' ) local cache = usecaching and {} local t, k = {}, 1 local n = #b64 local padding = b64:sub(-2) == '==' and 2 or b64:sub(-1) == '=' and 1 or 0 for i = 1, padding > 0 and n-4 or n, 4 do local a, b, c, d = b64:byte( i, i+3 ) local s if usecaching then local v0 = a*0x1000000 + b*0x10000 + c*0x100 + d s = cache[v0] if not s then local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d] s = char( extract(v,16,8), extract(v,8,8), extract(v,0,8)) cache[v0] = s end else local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d] s = char( extract(v,16,8), extract(v,8,8), extract(v,0,8)) end t[k] = s k = k + 1 end if padding == 1 then local a, b, c = b64:byte( n-3, n-1 ) local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 t[k] = char( extract(v,16,8), extract(v,8,8)) elseif padding == 2 then local a, b = b64:byte( n-3, n-2 ) local v = decoder[a]*0x40000 + decoder[b]*0x1000 t[k] = char( extract(v,16,8)) end return concat( t ) end return base64 --[[ ------------------------------------------------------------------------------ This software is available under 2 licenses -- choose whichever you prefer. ------------------------------------------------------------------------------ ALTERNATIVE A - MIT License Copyright (c) 2018 Ilya Kolbin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------ ALTERNATIVE B - Public Domain (www.unlicense.org) This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------ --]] end preload["res.smolfont"] = function(...) return "UklWAgGkAAcBAAAAAAAAAAAAAAAAcAAAAABwAAAAcAAAcAAHAABwcAB3AAAAAAAAAAAAAAAAAAAABwAAAAAAAAAAAAAAAAAHAHcAB3B3AHdwd3AHcHBwd3AAcHBwcABwcHBwBwB3AAcAdwAHcHdwcHBwcHBwcHBwcHdwB3AHAHcAdwBwcHdwB3B3cHdwd3AAAAAABwAABwAHdwd3AAAHdwBwAAAAdwBwB3AAdwd3BwAAAAcAcHBwcAdwcAB3AAcAAHBwAHBwAAAAAAAAAAAAcAd3AAcAcAAAAAAAAAAAAAAAAAAAAHcAdwAHcAdwB3AHAAdwdwAAAAAAcHAHAHdwdwAHAHcAB3AHcAdwd3BwcHBwcHBwcHBwd3BwcHBwcABwcHAAcABwAHBwBwAAcHBwcAB3cHdwcHBwcHBwcHBwAAcAcHBwcHBwcHBwcABwcHB3AABwAHBwcHAAcAAAcHBwcHBwAHAAcAd3AHAABwcABwAABwcHAAAAcABwAHAHcAd3AHAAAAcAcHB3cHcAAHB3AAcABwAHAAcABwAAAAAAAAAAcHAAcHd3cAAAAAAAAAAAAAAAAAAAAAdwcHBwAHBwcHB3cHBwcHAHAABwdwAHAHdwcHBwcHBwcHBwAHcABwBwcHBwd3AHAHBwB3B3cHcAcABwcHdwd3B3cHdwBwAAcHcAcAB3cHdwcHB3AHBwd3AHAAcAcHBwcHdwBwAHAAcAcHAHAAcABwB3cHcAd3AHAHdwd3AAAAAHAAAAAAcAcAcAAHAABwAAAAAHAAAAAAcAAAd3AAAAAAcAAABwcAdwBwB3cAAABwAHAHBwd3AAAHdwAAAHAHBwcAdwAAAAAAAAAAAAAAAAAAAAAHBwcHBwAHBwdwAHAHdwcHAHAABwdwAHAHdwcHBwcHBwcHBwAAdwBwBwcHdwd3AHAAdwdwBwcHBwcABwcHAAcABwcHBwBwBwcHBwcABwcHdwcHBwAHdwdwAAcAcAcHAHAHdwcHAHAHAAcHAHAHAAAHAAcABwcHBwAHBwAHBwAHAAcAd3AHAAAAcAAAcABwAAAAAAcABwAHAAAAd3AAAAAAAAAAB3cHcAcABwcAAABwAHAAAABwAHAAAAAABwAHB3cHd3cAAAAAAAAAAAAAAAAAAAAHdwdwAHcAdwB3AHAABwcHAHAHBwcHB3cHBwcHAHAHcAB3BwAHcAB3AHcAcAd3BwcABwd3BwcHcAB3B3AHdwcAAHcHBwd3AHAHBwd3BwcHBwBwBwAAdwcHB3AAcAB3AHAHBwcHAHAHdwdwAHAHdwdwAAcHcAd3BwAHdwdwAABwAABwAABwAAcAd3AAAHdwAAB3cAdwBwB3AAAAd3AAAAAAcAAABwcAcAAHAHcAAAAHBwAAAAAABwAAAABwBwAHAAAAcHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAcAAAAAAAAAAAAAAHAAAHAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAd3cAcAcAAAAAAAAAAAAAAAAAAAAAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAABAAABAAAAAAAAAAAAAAAAAAAAO+/v++9zv///9/////NyYipuOqqzc2JqqqK2cyKiYj/fUd87M/mRH+rpnOv6///xu3////PnJmd/K+N3JyZqKqqqOrq7tqrjqiq6q2qqqu8q+6r6m7sdV/97mbstyIvd3f3/68L/v///6muiqq93Kiq6tyq2JrIroiIveyIyordqtjd2t3I2Ih//23v/fffx3+/ZuN3Kz5+q/n///+v6sqN2suNqqqerYidrOrqrtqqrqiOvK2N2q7tu6uu627s993/7n78/yO7fvd3/7sI/v///8iZ2autiqrN6ZzZqIvKyeip2Iqq7anc2aqN3MjL6Mj3fW98fMzmx3+/9uav//u3+/X////////f//3//77////f/////////////////////////////////////////////3/Y/v///3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3e7u7u7u7u7u93d3d3d3d3dfd////8P" end preload["res.font"] = function(...) return "UklWAgA7AAYBAAAAAAAAAAAAAAAADwAPAP8A/wDw8P/wD/D/8A8ADwAPAP/wDwDw8AAPDw/wAA8ADw8PDwAPAAAPDw8PDw8PAPAPDw8PAADw8A8ADwAPAA/w/wD/8ADwDwAP8P/wDwDw8P8AAA8PAPAPAAAPAA8ADw8PAPAPDwAPDw8A8A/wDw8AAA8A//AP8P8AAPD/AP8ADwAPAADw8PD/AA/w8PAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" end preload["res.cfont"] = function(...) return "UklWAgH0AAoBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAABwAAAAAHcAAAAHAAAHAABwcAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAB3cAd3cAB3cAd3cAd3dwd3dwB3dwcABwd3AABwcABwcAAHAAcHAAcAd3AHdwAHdwB3dwAHd3B3d3BwAHBwAHBwAHBwAHBwAHB3d3AHdwAAcAAHdwAHdwAAB3B3d3AAdwB3d3AHdwAHdwAAAHAAAAAAAAAABwBwBwAAAAAHAAAHd3cAAHAABwcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAcAAAAAcAAAAABwAAAAAAAHAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAHAAcHAAcHAAcHAAcHAAAHAAAHAAAHAAcAcAAAcHAHAHAAB3B3B3AHBwAHBwBwcABwcABwcAAAAHAAcABwcABwcABwBwcABwcAAABwcABwB3AAcABwcABwAHBwcAAABwAAcABwcABwcABwAAcAAAAAAAAAAAcHAAcABwAAd3AAdwAHcABwAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHdwBwdwAHdwAHcHAHdwB3dwB3dwcHcAcAAHBwBwcAdwcAd3cAB3cAcHcAB3BwcHcAB3dwd3BwAHBwAHBwAHBwAHBwAHB3d3B3d3B3dwBwAABwAHB3cAB3cABwB3B3d3AHAAAHB3cABwAAcHBwcHBwcABwd3AHAAcHd3AAd3AABwAHAAcHAAcHAAcABwAABwAAAHAHAHcABwAAAAcAAAcAcAcHd3AHAAAAAAcHAAcHAAcAAHAAAAAAAAAAB3d3AHAAAAB3d3AHAHcHAHAAcAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwdwBwcABwcAdwcABwBwAHAAcHcAcHAABwcHAHAHBwcHAAcHAAcHcAcHAHcHcAcHAAAAcAcABwcABwcABwBwcAcABwAAcAcABwcABwcAAAcABwcAAAcAAAcABwcABwBwAABwcAcAcAAHAAcHAHcHAAcHAABwAHBwAHAAAHAAcABwAHBwAHBwAHAHBwAAcAAAcABwcHAAcAAAdwAAdwBwAHAAAHB3dwAABwAHdwAHd3AAcAB3d3AAAAAAB3AABwAAAHd3d3BwcHBwAABwAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAd3cHAAcHAAAHAAcHd3cAcABwAHBwAHBwAAcHcABwBwcHBwAHBwAHBwAHBwAHBwAAAHdwAHAHAAcHAAcHBwcABwAHAAcABwAHAAcHAAcHAAAHAAcHAAAHAAAHAAcHAAcAcAAAcHAAcHAABwAHBwAHBwAHBwAAcABwcABwAABwAHAAcABwBwcAcHBwcABwAHAABwAAdwBwAHAABwAAAABwd3dwAABwcABwAHAAcABwAABwBwAAAAAAAAAAAHd3cAcAAAB3BwdwcHd3cAAABwAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHBwAHBwAHBwAHBwAAAHAAB3dwcABwcHAHBwcAcAcABwcABwcABwd3cAB3dwcAAAAABwBwBwAHAHBwBwcHAHBwAHd3AHAABwAHBwAHBwAHBwAHBwAABwAABwAHBwAHAHAHAHBwAHBwAAcABwcABwcABwcAAHAHAHAAcHAAcABwAHAAcAcHAHcHcHAAcABwAHAAAHAAcABwAHAAcHAAcAAAcHAAcHAAcABwAHAAcAAHAAcAAAAAAAAAAAAHBwAAAAcAAAcAAHcAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB3dwd3cAB3cAB3dwB3dwBwAAAAcHAAcHBwBwcAcAcHAAcHAAcAd3AHAAAAAAcHAAAHd3AABwB3dwAHAAB3dwcABwAABwd3dwcABwd3cAB3cAd3cAd3dwcAAAB3cAcABwd3AHcAcABwd3cHAAcHAAcAd3AHAAAHcHBwAHAHdwAAcAAHdwAAcABwAHBwAHAAcAB3d3AHdwB3d3B3d3AHdwAAAHAHdwAHdwAAcAAHdwAHcABwAAAAAAAHAAcABwBwBwAAAAB3cAAHd3cAAAAABwcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB3dwAAAAAAB3AAAAAAAAAAAAAAAAAABwAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAHd3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAQAAAQAAAQAAAQAAAQAAEAAAEAAAEBAAAQAAEAEAAAEAAAEAAAEAAAEAAAEAAAEAAAEAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQAAAQABAAAQAAAQAAEAAAEAAAEAAAEAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAABAAEAAQAAAQABAAEAAAABAAAAAQABAAAQAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////9//3//+fte7///////+/////9jOIZBMHTxut51YxzDIei6ruuC8R7HJziDcfz+/23/ffBe///////////////3//fvv//3/v////+//////991Xdf3fbev7UnWta7r767r1vXr5q67+r3ruvf/X3ePc+7u/////////////2M5TmMYyl5rGo7lVA7RdV3XBUHYd3HcBPviXlVd7MLx7rruvt/m/r4t7L+ue///YH+waff9/////////////2Vd0+0u69VWXZc1Wb9d163d23V91/ddt6/tXdP1rvvuruvW+67u8+y+8B2HO/if9wOqvr//////////////Q9d3we66Xm7VdV3Xj9t11d3dXdd3fd91+7redV3vuu/u1qq79+zev+Dr7u7b//9gP6Xg9/3////////////fdV3X76Gr1XZdFw79t91arcPedV3X9123retd1/W26+5uTbq77+6u+7ru7t79/19/3/P/7v////////////9DOA7D/rpaW9eN/deHN9xD9wVdOIZBP3Yxu9B1Y5+68R7vrruDMQjGbxzvce7/3W3/ePBf////////////////////8H/+///ff///////4f/////////////////////////////////////f/////////////////////77v+773fb237/u+7/vu+77v+77v+77v++597/u+933f933f933f933f933f9313393f3733////////////" end preload["radon"] = function(...) local version = "1.3.34-katze" local configHelpers = require "util.configHelpers" local schemas = require "core.schemas" local ScanInventory = require("core.inventory.ScanInventory") local oldPullEvent = os.pullEvent os.pullEvent = os.pullEventRaw local oldPrint = print local logs = {} local maxLogs = 100 function print(...) local args = {...} local str = "" for i = 1, #args do str = str .. tostring(args[i]) if i ~= #args then str = str .. " " end end table.insert(logs, 1, {time = os.time("utc"), text = str}) if #logs > maxLogs then table.remove(logs, #logs) end end --- Imports local _ = require("util.score") local misc = require("util.misc") local sound = require("util.sound") local eventHook = require("util.eventHook") local Display = require("modules.display") local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useCanvas = hooks.useCanvas local Button = require("components.Button") local SmolButton = require("components.SmolButton") local BasicButton = require("components.BasicButton") local BigText = require("components.BigText") local Modal = require("components.Modal") local Logs = require("components.Logs") local ConfigEditor = require("components.ConfigEditor") local bigFont = require("fonts.bigfont") local SmolText = require("components.SmolText") local smolFont = require("fonts.smolfont") local BasicText = require("components.BasicText") local Rect = require("components.Rect") local RenderCanvas = require("components.RenderCanvas") local Core = require("core.ShopState") local Pricing = require("core.Pricing") local ShopRunner = require("core.ShopRunner") local ConfigValidator = require("core.ConfigValidator") local defaultLayout = require("DefaultLayout") local loadRIF = require("modules.rif") local configDefaults = require("configDefaults") local config = misc.plainDeepCopy(require("config")) local products = misc.plainDeepCopy(require("products")) local eventHooks = {} if fs.exists(fs.combine(fs.getDir(shell.getRunningProgram()), "eventHooks.lua")) then eventHooks = require("eventHooks") end --- End Imports configHelpers.loadDefaults(config, configDefaults) -- these are for the GUI config editor, so that it doesnt save runtime stuff local editConfig = misc.plainDeepCopy(config) local editProducts = misc.plainDeepCopy(products) local configErrors = ConfigValidator.validateConfig(config) local productsErrors = ConfigValidator.validateProducts(products) if (configErrors and #configErrors > 0) or (productsErrors and #productsErrors > 0) then config.ready = false else config.ready = true end local peripherals = {} configHelpers.getPeripherals(config, peripherals) -- if config.shopSync and config.shopSync.enabled and not config.shopSync.force then -- error("ShopSync is not yet finalized, please update Radon to use this feature, or set config.shopSync.force to true to use current ShopSync spec") -- end local display = Display.new({theme=config.theme, monitor=config.peripherals.monitor}) local terminal = Display.new({theme=config.terminalTheme, monitor=term}) local layout = nil local layoutFile = nil local layoutRenderer = nil local configState = { config = config, products = products, eventHooks = eventHooks, editConfig = editConfig, editProducts = editProducts, } local Main = Solyd.wrapComponent("Main", function(props) local canvas = useCanvas(display) local flatCanvas = {} if props.configState.config.ready and props.shopState.kryptonReady then local theme = props.configState.config.theme if theme.formatting.layout ~= "custom" or not theme.formatting.layoutFile then local addBg = false if theme.formatting.layout ~= layout then layout = theme.formatting.layout if theme.palette then require("util.setPalette")(display.mon, theme.palette) end if theme.colors and theme.colors.bgColor then display.ccCanvas.clear = theme.colors.bgColor addBg = true end display.ccCanvas:outputFlush(display.mon) end flatCanvas = defaultLayout(canvas, display, props, theme, version) if addBg then table.insert(flatCanvas, Rect { display = display, x = 1, y = 1, width = display.bgCanvas.width, height = display.bgCanvas.height, color = theme.colors.bgColor, }) end else local addBg = false if theme.formatting.layoutFile ~= layoutFile or theme.formatting.layout ~= layout then layout = "custom" layoutFile = theme.formatting.layoutFile local f = fs.open(layoutFile, "r") if not f then error("Could not open layout file: " .. layoutFile) end layoutRendererString = f.readAll() f.close() local loadedString, err = load(layoutRendererString, layoutFile, "t", setmetatable({ require = require }, {__index = _ENV})) if not loadedString then error("Could not load layout file: " .. err) end layoutRenderer = loadedString() if theme.layouts[layoutFile] and theme.layouts[layoutFile].palette then require("util.setPalette")(display.mon, theme.layouts[layoutFile].palette) end if theme.layouts[layoutFile] and theme.layouts[layoutFile].colors and theme.layouts[layoutFile].colors.bgColor then display.ccCanvas.clear = theme.layouts[layoutFile].colors.bgColor addBg = true end display.ccCanvas:outputFlush(display.mon) end flatCanvas = layoutRenderer(canvas, display, props, theme, version) if addBg then table.insert(flatCanvas, 1, Rect { display = display, x = 1, y = 1, width = display.bgCanvas.width, height = display.bgCanvas.height, color = theme.layouts[layoutFile].colors.bgColor, }) end end elseif not props.configState.config.ready then flatCanvas = { BasicText({ display = display, text = "Waiting on config...", x = 1, y = 1, color = colors.white, bgColor = colors.black, }) } else flatCanvas = { BasicText({ display = display, text = "Connecting...", x = 1, y = 1, color = colors.white, bgColor = colors.black, }) } end return _.flat({ _.flat(flatCanvas) }), { canvas = {canvas, 1, 1}, config = props.configState.config or {}, shopState = props.shopState or {}, products = props.shopState.products, peripherals = props.peripherals or {}, } end) local terminalState = { prevCatagory = "logs", activeCatagory = "logs", configErrors = configErrors, productsErrors = productsErrors, scroll = 0, maxScroll = 0, } --local mbsMode = settings.get("mbs.shell.enabled") local Terminal = Solyd.wrapComponent("Terminal", function(props) local canvas = useCanvas(terminal) local theme = props.configState.config.terminalTheme local flatCanvas = {} local versionString = "Radon " .. version local terminalCatagories = { "logs", "config", "products" } local bodyHeight = math.floor(terminal.bgCanvas.height / 3) - 1 local bodyWidth = math.floor(terminal.bgCanvas.width / 2) table.insert(flatCanvas, Rect { key = "header", display = terminal, x = 1, y = 1, width = terminal.bgCanvas.width, height = 3, color = theme.colors.titleBgColor, }) table.insert(flatCanvas, BasicText { key = "title", display = terminal, align = "left", text = versionString, x = 1, y = 1, color = theme.colors.titleTextColor, bg = theme.colors.titleBgColor, }) local catagoriesX = 1 + #versionString for i = 1, #terminalCatagories do local bgColor = theme.colors.catagoryBgColor if props.terminalState.activeCatagory == terminalCatagories[i] then bgColor = theme.colors.activeCatagoryBgColor end table.insert(flatCanvas, BasicButton { key = "catagory-" .. terminalCatagories[i], display = terminal, align = "center", text = " " .. terminalCatagories[i] .. " ", x = 2 + catagoriesX, y = 1, color = theme.colors.catagoryTextColor, bg = bgColor, onClick = function() props.terminalState.activeCatagory = terminalCatagories[i] end }) catagoriesX = catagoriesX + 3 + #terminalCatagories[i] end if (props.terminalState.configErrors and #props.terminalState.configErrors > 0) or terminalState.activeCatagory == "config" then if terminalState.prevCatagory ~= "config" then terminalState.prevCatagory = "config" terminalState.configPath = "" terminalState.scroll = 0 end table.insert(flatCanvas, ConfigEditor { key = "config-editor", display = terminal, x = 1, y = 2, width = bodyWidth, height = bodyHeight, config = props.configState.config, editConfig = props.configState.editConfig, schema = schemas.configSchema, errors = props.terminalState.configErrors, errorPrefix = "config", terminalState = props.terminalState, theme = theme.colors.configEditor, onSave = function(newConfig, newEditConfig) props.shopState.oldConfig = props.configState.config props.shopState.config = newConfig props.configState.config = newConfig props.configState.editConfig = newEditConfig props.terminalState.configErrors = ConfigValidator.validateConfig(newConfig) if (not props.terminalState.configErrors or #props.terminalState.configErrors == 0) and (not props.terminalState.productsErrors or #props.terminalState.productsErrors == 0) then newConfig.ready = true end configHelpers.getPeripherals(newConfig, peripherals) -- TODO: Detect if we actually need to update currencies props.shopState.changedCurrencies = true local serialized = textutils.serialize(newEditConfig) local f = fs.open("config.lua", "w") f.write("return " .. serialized) f.close() print("Configs updated!") if props.configState.eventHooks and props.configState.eventHooks.configSaved then props.configState.eventHooks.configSaved(newEditConfig) end end }) elseif (props.terminalState.productsErrors and #props.terminalState.productsErrors > 0) or terminalState.activeCatagory == "products" then if terminalState.prevCatagory ~= "products" then terminalState.prevCatagory = "products" terminalState.configPath = "" terminalState.scroll = 0 end table.insert(flatCanvas, ConfigEditor { key = "products-editor", display = terminal, x = 1, y = 2, width = bodyWidth, height = bodyHeight, config = props.shopState.products, editConfig = props.configState.editProducts, schema = schemas.productsSchema, errors = props.terminalState.productsErrors, errorPrefix = "products", terminalState = props.terminalState, theme = theme.colors.productsEditor, onSave = function(newConfig, newEditConfig) props.shopState.products = newConfig props.configState.products = newConfig props.configState.editProducts = newEditConfig props.terminalState.productsErrors = ConfigValidator.validateProducts(products) if (not props.terminalState.configErrors or #props.terminalState.configErrors == 0) and (not props.terminalState.productsErrors or #props.terminalState.productsErrors == 0) then props.configState.config.ready = true end ScanInventory.clearNbtCache() local serialized = textutils.serialize(newEditConfig) local f = fs.open("products.lua", "w") f.write("return " .. serialized) f.close() print("Products updated!") if props.configState.eventHooks and props.configState.eventHooks.productsSaved then props.configState.eventHooks.productsSaved(newEditConfig) end end }) elseif terminalState.activeCatagory == "logs" then table.insert(flatCanvas, Logs { key = "logs", display = terminal, x = 1, y = 2, width = bodyWidth, height = bodyHeight, logs = props.logs, color = theme.colors.logTextColor, bg = theme.colors.bgColor, }) end table.insert(flatCanvas, Modal { key="modal" }) return _.flat({ _.flat(flatCanvas) }), { canvas = {canvas, 1, 1}, config = props.configState.config or {}, shopState = props.shopState or {}, products = props.shopState.products, terminalState = props.terminalState, peripherals = props.peripherals or {}, modal = props.modal } end) local t = 0 local tree = nil local terminalTree = nil local lastClock = os.epoch("utc") local lastCanvases = { { stack = {}, hash = {} }, { stack = {}, hash = {} }, } local function diffCanvasStack(diffDisplay, newStack, lastCanvas) -- Find any canvases that were removed local removed = {} local kept, newCanvasHash = {}, {} for i = 1, #lastCanvas.stack do removed[lastCanvas.stack[i][1]] = lastCanvas.stack[i] end for i = 1, #newStack do if removed[newStack[i][1]] then kept[#kept+1] = newStack[i] removed[newStack[i][1]] = nil newStack[i][1].allDirty = false else -- New newStack[i][1].allDirty = true end newCanvasHash[newStack[i][1]] = newStack[i] end -- Mark rectangle of removed canvases on bgCanvas (TODO: using bgCanvas is a hack) for _, canvas in pairs(removed) do if canvas[1].brand == "TextCanvas" then diffDisplay.bgCanvas:dirtyRect(canvas[2], canvas[3], canvas[1].width*2, canvas[1].height*3) else diffDisplay.bgCanvas:dirtyRect(canvas[2], canvas[3], canvas[1].width, canvas[1].height) end end -- For each kept canvas, mark the bounds if the new bounds are different for i = 1, #kept do local newCanvas = kept[i] local oldCanvas = lastCanvas.hash[newCanvas[1]] if oldCanvas then if oldCanvas[2] ~= newCanvas[2] or oldCanvas[3] ~= newCanvas[3] then -- TODO: Optimize this? if oldCanvas[1].brand == "TextCanvas" then diffDisplay.bgCanvas:dirtyRect(oldCanvas[2], oldCanvas[3], oldCanvas[1].width*2, oldCanvas[1].height*3) diffDisplay.bgCanvas:dirtyRect(newCanvas[2], newCanvas[3], newCanvas[1].width*2, newCanvas[1].height*3) else diffDisplay.bgCanvas:dirtyRect(oldCanvas[2], oldCanvas[3], oldCanvas[1].width, oldCanvas[1].height) diffDisplay.bgCanvas:dirtyRect(newCanvas[2], newCanvas[3], newCanvas[1].width, newCanvas[1].height) end end end end lastCanvas.stack = newStack lastCanvas.hash = newCanvasHash end local shopState = Core.ShopState.new(config, products, peripherals, version, logs, eventHooks) --local Profiler = require("profile") local deltaTimer = os.startTimer(0) local success, err = pcall(function() ShopRunner.launchShop(shopState, function() --Profiler:activate() print("Radon " .. version .. " started") if eventHooks and eventHooks.start then eventHook.execute(eventHooks.start, version, config, products, shopState) end while true do -- add t = t if we need animations tree = Solyd.render(tree, Main { configState = configState, shopState = shopState, peripherals = peripherals}) local context = Solyd.getTopologicalContext(tree, { "canvas", "aabb" }) diffCanvasStack(display, context.canvas, lastCanvases[1]) local t1 = os.epoch("utc") local cstack = { {display.bgCanvas, 1, 1}, unpack(context.canvas) } -- cstack[#cstack+1] = {display.textCanvas, 1, 1} display.ccCanvas:composite(unpack(cstack)) display.ccCanvas:outputDirty(display.mon) local t2 = os.epoch("utc") --print("Render time: " .. (t2-t1) .. "ms") terminalTree = Solyd.render(terminalTree, Terminal { configState = configState, products = products, shopState = shopState, peripherals = peripherals, logs = logs, terminalState = terminalState, modal = {}}) local terminalContext = Solyd.getTopologicalContext(terminalTree, { "canvas", "aabb", "input" }) diffCanvasStack(terminal, terminalContext.canvas, lastCanvases[2]) t1 = os.epoch("utc") local cstackT = { {terminal.bgCanvas, 1, 1}, unpack(terminalContext.canvas) } -- cstack[#cstack+1] = {display.textCanvas, 1, 1} terminal.ccCanvas:composite(unpack(cstackT)) terminal.ccCanvas:outputDirty(terminal.mon) t2 = os.epoch("utc") -- print("Render time: " .. (t2-t1) .. "ms") local activeNode = hooks.findActiveInput(terminalContext.input) if activeNode then if activeNode.inputState.cursorX and activeNode.inputState.cursorY then terminal.mon.setCursorPos(activeNode.inputState.cursorX, activeNode.inputState.cursorY) else terminal.mon.setCursorPos(activeNode.x, activeNode.y) end terminal.mon.setTextColor(colors.black) terminal.mon.setCursorBlink(true) else terminal.mon.setCursorBlink(false) end local receivedEvent = false local terminate = false while not receivedEvent do local e = { os.pullEvent() } receivedEvent = true local name = e[1] if name == "timer" and e[2] == deltaTimer then -- local clock = os.epoch("utc") -- local dt = (clock - lastClock)/1000 -- t = t + dt -- lastClock = clock -- deltaTimer = os.startTimer(0) -- hooks.tickAnimations(dt) elseif name == "timer" then receivedEvent = false elseif name == "monitor_touch" and e[2] == peripheral.getName(display.mon) then local x, y = e[3], e[4] local node = hooks.findNodeAt(context.aabb, x, y) if node then node.onClick() end elseif name == "term_resize" then terminal.ccCanvas:outputFlush(terminal.mon) elseif name == "mouse_click" then local x, y = e[3], e[4] local clearedInput = hooks.clearActiveInput(terminalContext.input, x, y) if clearedInput and clearedInput.onBlur then clearedInput.onBlur() end local node = hooks.findNodeAt(terminalContext.aabb, x, y) if node then node.onClick() end elseif name == "mouse_scroll" then local dir = e[2] local x, y = e[3], e[4] local node = hooks.findNodeAt(terminalContext.aabb, x, y) local cancelScroll = false if node and node.onScroll then if node.onScroll(dir) then cancelScroll = true end end if not cancelScroll then if dir >= 1 and terminalState.scroll < terminalState.maxScroll then terminalState.scroll = math.min(terminalState.scroll + dir, terminalState.maxScroll) elseif dir <= -1 and terminalState.scroll > 0 then terminalState.scroll = math.max(terminalState.scroll + dir, 0) end end elseif name == "char" then local char = e[2] local node = hooks.findActiveInput(terminalContext.input) if node then node.onChar(char) end elseif name == "key" then --if e[2] == keys.q then -- break --end local key, held = e[2] or 0, e[3] or false local node = hooks.findActiveInput(terminalContext.input) if node then node.onKey(key, held) end elseif name == "paste" then local contents = e[2] local node = hooks.findActiveInput(terminalContext.input) if node and node.onPaste then node.onPaste(contents) end elseif name == "terminate" then terminate = true break end end if terminate then break end end ---Profiler:deactivate() end) end) display.mon.clear() terminal.mon.setBackgroundColor(colors.black) terminal.mon.setTextColor(colors.white) terminal.mon.clear() terminal.mon.setCursorPos(1,1) for i = 1, #shopState.config.currencies do local currency = shopState.config.currencies[i] if (currency.krypton and currency.krypton.ws) then currency.krypton.ws:disconnect() end end os.pullEvent = oldPullEvent if not success then if eventHooks and eventHooks.programError then eventHook.execute(eventHooks.programError, err) end error(err) end print("Radon terminated, goodbye!") --Profiler:write_results(nil, "profile.txt") end preload["profile"] = function(...) --| Profiler module. --b "Pedro Miller Rabinovitch" --$Id: prof.lua,v 1.4 2003/10/17 00:17:21 miller Exp $ --TODO add function call profiling. Some of the skeleton is already in --- place, but call profiling behaves different, so a couple of new --- functionalities must be added. --TODO add methods for proper result retrieval --TODO use optional clock() function for millisecond precision, if it's --- available local E, I = {}, {} --& Profiler module. -- Profiler = E --. Keeps track of the hit counts of each item E.counts = { line = {} } --. These should be inside the _line_ table above. E.last_line = nil E.last_time = os.epoch("utc") E.started, E.ended = nil, nil local dontdie = 0 --% Activates the profiling system. --@ [kind] (string) Optional hook kind. For now, only 'line' works, --- so just avoid it. >: ) function E:activate( kind ) kind = kind or 'line' local function hook_counter( hk_name, param,... ) local t = self.counts[hk_name][param] if t == nil then t = { count=0, time = 0, name = debug.getinfo(2).short_src } self.counts[hk_name][param] = t end self.counts[hk_name][param].count = self.counts[hk_name][param].count + 1 dontdie = dontdie + 1 if dontdie > 1000000 then dontdie = 0 print("yielding") self.ended = os.epoch("utc") E:write_results("line", "profile.txt") coroutine.yield() end if self.last_line then local delta = os.epoch("utc") - self.last_time if delta > 0 then self.counts[hk_name][self.last_line].time = self.counts[hk_name][self.last_line].time + delta self.last_time = os.epoch("utc") end end self.last_line = param end self.started = os.epoch("utc") debug.sethook( hook_counter, kind ) end --% Deactivates the profiling system. --@ [kind] (string) Optional hook kind. For now, only 'line' works, --- so just avoid it. function E:deactivate( kind ) kind = kind or 'line' self.ended = os.epoch("utc") debug.sethook( nil, kind ) end --% Prints the results. --@ [kind] (string) Optional hook... Aah, you got it by now. --TODO add print output formatting and sorting function E:print_results( kind ) kind = kind or 'line' print( kind, 'count', 'approx. time (s)' ) print( '----', '-----', '----------------' ) for i,v in pairs( self.counts[kind] ) do print( i, v.count, v.time ) end print( self.ended - self.started, ' second(s) total (approximately).' ) end function E:write_results( kind, filename ) kind = kind or 'line' local f = io.open( filename, 'w' ) f:write( 'file', kind, ' count', ' approx. time (s)', '\n' ) f:write( '-------------', '----', ' -----', ' ----------------', '\n' ) for i,v in pairs( self.counts[kind] ) do f:write( v.name, ' ', i, ' ', v.count, ' ', v.time, '\n' ) end f:write( self.ended - self.started, ' second(s) total (approximately).', '\n' ) f:close() end return E end preload["modules.solyd"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local _ = require("util.score") local Solyd = {} -- like lyqyd but not local __hook local function getKey() local key = __hook.__volatile.key if key == nil then key = 1 __hook.__volatile.key = 2 else __hook.__volatile.key = key + 1 end return key end ---Helper definition for the lazy variant of useState, the type system isn't stronk enough otherwise ---@alias UseStateFn fun(initial: fun(): T): T, fun(newValue: T): T ---@alias UseState fun(initial: T): T, fun(newValue: T): T local setChange = false ---Use State as a hook, sets the initial value if it is not set. ---@generic T ---@param initial T ---@return T, fun(value: T): T function Solyd.useState(initial) local key = getKey() local state = __hook[key] if state == nil then state = { value = type(initial) == "function" and initial() or initial } __hook[key] = state end state.dirty = false local function setState(newState) state.value = newState state.dirty = true setChange = true return newState end return state.value, setState end ---Returns returns a mutable ref object whose .value property is initialized to the passed argument {initial}. ---The returned object will persist for the full lifetime of the component. ---@generic T ---@param initial fun(): T ---@return { value: T } function Solyd.useRef(initial) local key = getKey() local ref = __hook[key] if ref == nil then ref = { value = initial() } __hook[key] = ref end return ref end ---Pass a “create” function and an array of dependencies. useMemo will only recompute the memoized ---value when one of the dependencies has changed. This optimization helps to avoid expensive ---calculations on every render. ---@generic T ---@param fun fun(): T ---@param deps any[]? ---@return T function Solyd.useMemo(fun, deps) local key = getKey() local memo = __hook[key] if memo == nil then memo = { value = fun() } __hook[key] = memo end if memo.deps == nil then memo.deps = deps else for i, v in ipairs(deps) do if v ~= memo.deps[i] then memo.value = fun() memo.deps = deps break end end end return memo.value end function Solyd.useCallback(fun, deps) return Solyd.useMemo(function() return fun end, deps) end ---Accepts a function that contains imperative, possibly effectful code. ---The function passed to useEffect may return a clean-up function ---@param fun fun(): fun()? ---@param deps any[]? function Solyd.useEffect(fun, deps) local key = getKey() local memo = __hook[key] if memo == nil then memo = { unmount = fun() } __hook[key] = memo end if memo.deps == nil then memo.deps = deps else for i, v in ipairs(deps) do if v ~= memo.deps[i] then memo.unmount() memo.deps = deps memo.unmount = fun() break end end end end ---Get a value from the component context using the given key, returns nil if the key is not found. ---@generic T ---@param key any ---@return T? function Solyd.useContext(key) local parentHookCtx = __hook.__volatile.parentContext while parentHookCtx do if parentHookCtx.context and parentHookCtx.context[key] then parentHookCtx.contextConsumers[key] = parentHookCtx.contextConsumers[key] or {} for i = 1, #parentHookCtx.contextConsumers[key] do if parentHookCtx.contextConsumers[key][i] == __hook then return parentHookCtx.context[key] end end table.insert(parentHookCtx.contextConsumers[key], __hook) __hook.contextSubscriptions = __hook.contextSubscriptions or {} __hook.contextSubscriptions[key] = parentHookCtx.contextConsumers return parentHookCtx.context[key] end parentHookCtx = parentHookCtx.__volatile and parentHookCtx.__volatile.parentContext end return nil end ---Gets the topologically ordered list of context values that were assigned with the given keys. ---@param tree table? ---@param keys string[] ---@return table function Solyd.getTopologicalContext(tree, keys) local values = {} for i, key in ipairs(keys) do values[key] = {} end if not tree then return values end local queue = {tree} while #queue > 0 do local node = table.remove(queue, 1) if node.context then for i, key in ipairs(keys) do if node.context[key] then table.insert(values[key], node.context[key]) end end end if node.dom then if node.dom.src and node.dom.src.__tag == "element" then table.insert(queue, 1, node.dom) else for i = #node.dom, 1, -1 do if type(node.dom[i]) == "table" and node.dom[i].src then assert(node.dom[i].src.__tag == "element", "Invalid tree") table.insert(queue, 1, node.dom[i]) end end end end end return values end ---@alias ElementType "element" ---@alias SolydElement { __tag: ElementType, props: table, propsDiff: table, key: any?, component: fun(props: table): SolydElement | SolydElement[] } ---Create a Solyd element from a comonent and props. ---@generic P: table ---@param component fun(P): table The component function ---@param props P ---@param key any? ---@return SolydElement function Solyd.createElement(name, component, props, key) return { __tag="element", name = name, component = component, props = props, propsDiff = _.copyDeep(props), key = key } end local function propsChanged(oldProps, newProps) if type(newProps) ~= "table" or newProps.__opaque ~= nil then return oldProps ~= newProps end if type(oldProps) ~= type(newProps) then return true end local keySet = {} for k, v in pairs(newProps) do keySet[k] = true if type(v) == "table" and v.__opaque == nil then if propsChanged(oldProps[k], v) then return true end elseif oldProps[k] ~= v then return true end end for k, v in pairs(oldProps) do if not keySet[k] then return true end end return false end -- local function tableSize(t) -- if type(t) ~= "table" then -- return nil -- end -- local count = 0 -- for k, v in pairs(t) do -- count = count + 1 -- end -- return count -- end ---Call any unmount functions bottom-up to ensure everything is cleaned up. local function _unmount(node) if node.dom then if node.dom.src and node.dom.src.__tag == "element" then _unmount(node.dom) else for i = 1, #node.dom do _unmount(node.dom[i]) end end end if node.src and node.src.component then for k, v in pairs(node.hook) do if type(v) == "table" and v.unmount then v.unmount() end end if node.hook.contextSubscriptions then for key, consumers in pairs(node.hook.contextSubscriptions) do for i = 1, #consumers do if consumers[i] == node.hook then table.remove(consumers, i) break end end end end end end ---Renders a Solyd element via tree expansion, pass the return value to the next invocation. ---@param previousTree table? ---@param rootComponent SolydElement? ---@return table? local function _render(previousTree, rootComponent, parentContext, forceRender) local nextTree = previousTree if type(rootComponent) ~= "table" then return { src = nil } end if forceRender or not previousTree or propsChanged(previousTree.src.propsDiff, rootComponent.propsDiff) -- or propsChanged(previousTree.hook.__volatile.parentContext, parentContext) then -- upkeep local hook = previousTree and previousTree.hook or { contextConsumers = {} } hook.__volatile = { __opaque = true, previousTree = previousTree, rootComponent = rootComponent, parentContext = parentContext or {} } __hook = hook -- update local newTree, context = rootComponent.component(rootComponent.props) if context and context.gameState then -- print(hook.contextDiff, context.gameState, propsChanged(hook.contextDiff, context)) end -- TODO: Optimize only call context consumers for the context that changed if propsChanged(hook.contextDiff, context) then for k, v in pairs(hook.contextConsumers or {}) do for i = 1, #v do v[i].contextDirty = { dirty = true } end end hook.contextDiff = _.copyDeep(context) end hook.context = context -- hook.contextConsumers = {} if not newTree then -- Check if we need to unmount children if previousTree and previousTree.dom and previousTree.dom.src then if previousTree.dom.src.__tag == "element" then _unmount(previousTree.dom) else for i = 1, #previousTree.dom do _unmount(previousTree.dom[i]) end end end nextTree = { src = rootComponent, context = context, hook = hook } else -- TODO: verif ythat support swapping between singel and multiple children returned if newTree.__tag == "element" and ((not previousTree) or previousTree.dom.src ~= nil) then local oldChild = previousTree and previousTree.dom if oldChild and oldChild.src.component == newTree.component then nextTree = { src = rootComponent, hook = hook, context = context, dom = _render(oldChild, newTree, hook) } else if oldChild then _unmount(previousTree) -- component changed, unmount old tree end nextTree = { src = rootComponent, hook = hook, context = context, dom = _render(nil, newTree, hook) } end else local oldChildren = previousTree and previousTree.dom ---@type table? -- this supports the swapping behavior if oldChildren and oldChildren.src ~= nil then oldChildren = { oldChildren } end if newTree.__tag == "element" then newTree = { newTree } end local children = {} local previousUniqueComponents = previousTree and previousTree.keyed or {} local nextUniqueComponents, nIndex = {}, 0 for i, child in ipairs(newTree) do if type(child) == "table" and child.key then local prevMatch = previousUniqueComponents[child.key] if prevMatch and prevMatch.src and prevMatch.src.component ~= child.component then prevMatch = nil end local newChild = _render(prevMatch, child, hook) nextUniqueComponents[child.key] = newChild children[i] = newChild elseif type(child) == "table" then nIndex = nIndex + 1 local prevMatch = previousUniqueComponents[nIndex] if prevMatch and prevMatch.src and prevMatch.src.component ~= child.component then prevMatch = nil end local newChild = _render(prevMatch, child, hook) nextUniqueComponents[nIndex] = newChild children[i] = newChild else -- should not mount nIndex = nIndex + 1 children[i] = { src = nil } end end -- Find any children that no longer exist in the new tree for key, child in pairs(previousUniqueComponents) do if child and not nextUniqueComponents[key] or nextUniqueComponents[key].src.component ~= child.src.component then _unmount(child) end end nextTree = { src = rootComponent, hook = hook, context = context, dom = children, keyed = nextUniqueComponents } end end hook.__volatile.nextTree = nextTree end return nextTree end ---Traverses the tree to find dirty nodes and re-renders them, updating them in-place. ---@param tree table? ---@return table? local function _cleanDirty(tree) local queue = {{nil, tree, nil}} local queuedMounts = {} while #queue > 0 do local didUpdate = false local pair = table.remove(queue, 1) local parent, node = pair[1], pair[2] local originalNode = node if node.hook then local dirty = false for k, v in pairs(node.hook) do if type(v) == "table" and v.dirty then dirty = true v.dirty = false end end if dirty then local volatile = node.hook.__volatile node = _render(volatile.nextTree, volatile.rootComponent, volatile.parentContext, true) didUpdate = true end end if didUpdate then table.insert(queuedMounts, {parent, node, originalNode}) end if node and node.dom then if node.dom.src and node.dom.src.__tag == "element" then table.insert(queue, {node, node.dom, parent}) else for i = 1, #node.dom do table.insert(queue, {node, node.dom[i], parent}) end end end end for i = 1, #queuedMounts do local triple = queuedMounts[i] local parent, node, originalNode = triple[1], triple[2], triple[3] if parent and parent.dom then if parent.dom and parent.dom.src and parent.dom.src.__tag == "element" then assert(parent.dom == originalNode, "parent dom is not original node") parent.dom = node else local found = false for j = 1, #parent.dom do if parent.dom[j] == originalNode then parent.dom[j] = node found = true break end end local found2 = false for k, v in pairs(parent.keyed) do if v == originalNode then parent.keyed[k] = node found2 = true break end end assert(found, "not found" .. parent.src.name) assert(found2, "not found2" .. parent.src.name) end elseif not parent then tree = node -- root else error("SHIT") end end return tree end ---Renders a Solyd element via tree expansion and sweeping, pass the return value to the next invocation. ---@param previousTree table? ---@param rootComponent SolydElement ---@return table? function Solyd.render(previousTree, rootComponent) local wasHook = __hook local tree = _render(previousTree, rootComponent) repeat setChange = false tree = _cleanDirty(tree) until not setChange __hook = wasHook -- Support nested render calls return tree end ---Wrap a component function to allow calling it directly without invoking createElement. ---@generic P: table ---@param component fun(props: P): table ---@return fun(props: P): table function Solyd.wrapComponent(name, component) return function(props) return Solyd.createElement(name, component, props, props.key) end end return Solyd end preload["modules.rif"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local canvases = require("modules.canvas") local base64 = require("util.base64") local PixelCanvas = canvases.PixelCanvas -- local palMap = { -- colors.black, -- colors.blue, -- colors.purple, -- colors.green, -- colors.brown, -- colors.gray, -- colors.lightGray, -- colors.red, -- colors.orange, -- colors.yellow, -- colors.lime, -- colors.cyan, -- colors.magenta, -- colors.pink, -- colors.darkGreen, -- colors.white -- } local revPalMap = { "black", "blue", "purple", "green", "brown", "gray", "lightGray", "red", "orange", "yellow", "lime", "cyan", "magenta", "pink", "darkGreen", "white" } local palMap = {} for i = 1, 16 do palMap[i] = 2^(i - 1) --colors[revPalMap[i]] = 2^(i - 1) end local fileCache = {} return function(filename) -- Riko 4 image format local data = fileCache[filename] if not data then data = base64.decode(require(filename)) end local width, height = data:byte(5) * 256 + data:byte(6), data:byte(7) * 256 + data:byte(8) local canv = PixelCanvas(width, height) local buffer = canv.canvas for i = 1, math.ceil(width * height / 2) do local byte = data:byte(19 + i) local fp = bit.brshift(bit.band(byte, 240), 4) local sp = bit.band(byte, 15) -- 1, 3, 5 local pix = i * 2 - 1 local x = (pix - 1) % width + 1 local y = math.ceil(pix / width) buffer[y][x] = palMap[fp + 1] local x2 = pix % width + 1 local y2 = math.ceil((pix + 1) / width) if buffer[y2] then buffer[y2][x2] = palMap[sp + 1] end end if data:byte(9) == 1 then -- Transparency map local max = 19 + math.ceil(width * height / 2) for i = 1, math.ceil(width * height / 8) do local byte = data:byte(max + i) for j = 1, 8 do local pix = (i - 1) * 8 + j local x = (pix - 1) % width + 1 local y = math.ceil(pix / width) if bit.band(byte, 2 ^ (j - 1)) ~= 0 then if buffer[y] then buffer[y][x] = nil end end end end end return canv end end preload["modules.regex.util"] = function(...) local util = {} function util.deepClone(tab) local nt = {} for k, v in pairs(tab) do if type(v) == "table" then nt[k] = util.deepClone(v) else nt[k] = v end end return nt end function util.nub(tab) local entries = {} local nt, i = {}, 1 for k, v in pairs(tab) do if not entries[v] then entries[v] = true nt[i] = v i = i + 1 end end return nt end return util end preload["modules.regex.reducer"] = function(...) --[[ MIT License Copyright (c) 2019 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local dr = {} local pprint = require("modules.regex.pprint") local util = require("modules.regex.util") local function isEpsilon(condition) return type(condition) == "table" and condition.type == "epsilon" end local function traverseEpsilon(machine, state, seen) seen = seen or {} local func = {state} local edges = machine.states[state].edges for i = 1, #edges do local edge = edges[i] if isEpsilon(edge.condition) then if not seen[edge.dest] then seen[edge.dest] = true -- Epsilon transition, add to E function func[#func + 1] = edge.dest -- Traverse through its destination and fully evaluate the epsilon path local extraFn = traverseEpsilon(machine, edge.dest, seen) -- Append those new states to this E function local pos = #func for j = 1, #extraFn do func[pos + 1] = extraFn[j] pos = pos + 1 end end end end return util.nub(func) end local function nameFromST(st) table.sort(st) return table.concat(st) end function dr.reduceNFA(nfa) -- Construct E function local eFunc = {} for k in pairs(nfa.states) do local eI = 1 eFunc[k] = traverseEpsilon(nfa, k) end local newMachine = { states = {}, startState = nameFromST(eFunc[nfa.startState]), acceptStates = {}, properties = nfa.properties } local todoStates = {eFunc[nfa.startState]} local completeStates = {} while todoStates[1] do -- while todoStates is not empty local workingState = table.remove(todoStates, 1) local newState = { edges = {}, enter = {} } local lang = {} local isAccepted = false -- Get all possible inputs by traversing states for i = 1, #workingState do local stateName = workingState[i] if nfa.acceptStates[stateName] then isAccepted = true end local state = nfa.states[stateName] for j = 1, #state.edges do local cond = state.edges[j].condition if type(cond) == "string" then lang[cond] = lang[cond] or {} lang[cond][#lang[cond] + 1] = state.edges[j].dest end end end -- For each possible input, compute the resultant state, and create an edge for k, v in pairs(lang) do local st, si = {}, 1 for i = 1, #v do local throughput = eFunc[v[i]] for j = 1, #throughput do st[si] = throughput[j] si = si + 1 end end st = util.nub(st) table.sort(st) local destState = nameFromST(st) newState.edges[#newState.edges + 1] = { condition = k, dest = destState } if not completeStates[destState] then todoStates[#todoStates + 1] = st completeStates[destState] = true end end -- Append each enter condition local ei = 1 for i = 1, #workingState do local stateName = workingState[i] local state = nfa.states[stateName] if state.enter then for j = 1, #state.enter do newState.enter[ei] = state.enter[j] ei = ei + 1 end end end local stateName = nameFromST(workingState) newMachine.states[stateName] = newState if isAccepted then newMachine.acceptStates[stateName] = true end completeStates[stateName] = true end return newMachine end return dr end preload["modules.regex.pprint"] = function(...) -- luacheck: ignore local pprint = { VERSION = '0.1' } local depth = 1 pprint.defaults = { -- If set to number N, then limit table recursion to N deep. depth_limit = false, -- type display trigger, hide not useful datatypes by default -- custom types are treated as table show_nil = true, show_boolean = true, show_number = true, show_string = true, show_table = true, show_function = false, show_thread = false, show_userdata = false, -- additional display trigger show_metatable = false, -- show metatable show_all = false, -- override other show settings and show everything use_tostring = false, -- use __tostring to print table if available filter_function = nil, -- called like callback(value[,key, parent]), return truty value to hide object_cache = 'local', -- cache blob and table to give it a id, 'local' cache per print, 'global' cache -- per process, falsy value to disable (might cause infinite loop) -- format settings indent_size = 2, -- indent for each nested table level level_width = 80, -- max width per indent level wrap_string = true, -- wrap string when it's longer than level_width wrap_array = false, -- wrap every array elements sort_keys = true, -- sort table keys } local TYPES = { ['nil'] = 1, ['boolean'] = 2, ['number'] = 3, ['string'] = 4, ['table'] = 5, ['function'] = 6, ['thread'] = 7, ['userdata'] = 8 } -- seems this is the only way to escape these, as lua don't know how to map char '\a' to 'a' local ESCAPE_MAP = { ['\a'] = '\\a', ['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t', ['\v'] = '\\v', ['\\'] = '\\\\', } -- generic utilities local function escape(s) s = s:gsub('([%c\\])', ESCAPE_MAP) local dq = s:find('"') local sq = s:find("'") if dq and sq then return s:gsub('"', '\\"'), '"' elseif sq then return s, '"' else return s, "'" end end local function is_plain_key(key) return type(key) == 'string' and key:match('^[%a_][%a%d_]*$') end local CACHE_TYPES = { ['table'] = true, ['function'] = true, ['thread'] = true, ['userdata'] = true } -- cache would be populated to be like: -- { -- function = { `fun1` = 1, _cnt = 1 }, -- object id -- table = { `table1` = 1, `table2` = 2, _cnt = 2 }, -- visited_tables = { `table1` = 7, `table2` = 8 }, -- visit count -- } -- use weakrefs to avoid accidentall adding refcount local function cache_apperance(obj, cache, option) if not cache.visited_tables then cache.visited_tables = setmetatable({}, {__mode = 'k'}) end local t = type(obj) -- TODO can't test filter_function here as we don't have the ix and key, -- might cause different results? -- respect show_xxx and filter_function to be consistent with print results if (not TYPES[t] and not option.show_table) or (TYPES[t] and not option['show_'..t]) then return end if CACHE_TYPES[t] or TYPES[t] == nil then if not cache[t] then cache[t] = setmetatable({}, {__mode = 'k'}) cache[t]._cnt = 0 end if not cache[t][obj] then cache[t]._cnt = cache[t]._cnt + 1 cache[t][obj] = cache[t]._cnt end end if t == 'table' or TYPES[t] == nil then if cache.visited_tables[obj] == false then -- already printed, no need to mark this and its children anymore return elseif cache.visited_tables[obj] == nil then cache.visited_tables[obj] = 1 else -- visited already, increment and continue cache.visited_tables[obj] = cache.visited_tables[obj] + 1 return end for k, v in pairs(obj) do cache_apperance(k, cache, option) cache_apperance(v, cache, option) end local mt = getmetatable(obj) if mt and option.show_metatable then cache_apperance(mt, cache, option) end end end -- makes 'foo2' < 'foo100000'. string.sub makes substring anyway, no need to use index based method local function str_natural_cmp(lhs, rhs) while #lhs > 0 and #rhs > 0 do local lmid, lend = lhs:find('%d+') local rmid, rend = rhs:find('%d+') if not (lmid and rmid) then return lhs < rhs end local lsub = lhs:sub(1, lmid-1) local rsub = rhs:sub(1, rmid-1) if lsub ~= rsub then return lsub < rsub end local lnum = tonumber(lhs:sub(lmid, lend)) local rnum = tonumber(rhs:sub(rmid, rend)) if lnum ~= rnum then return lnum < rnum end lhs = lhs:sub(lend+1) rhs = rhs:sub(rend+1) end return lhs < rhs end local function cmp(lhs, rhs) local tleft = type(lhs) local tright = type(rhs) if tleft == 'number' and tright == 'number' then return lhs < rhs end if tleft == 'string' and tright == 'string' then return str_natural_cmp(lhs, rhs) end if tleft == tright then return str_natural_cmp(tostring(lhs), tostring(rhs)) end -- allow custom types local oleft = TYPES[tleft] or 9 local oright = TYPES[tright] or 9 return oleft < oright end -- setup option with default local function make_option(option) if option == nil then option = {} end for k, v in pairs(pprint.defaults) do if option[k] == nil then option[k] = v end if option.show_all then for t, _ in pairs(TYPES) do option['show_'..t] = true end option.show_metatable = true end end return option end -- override defaults and take effects for all following calls function pprint.setup(option) pprint.defaults = make_option(option) end -- format lua object into a string function pprint.pformat(obj, option, printer) option = make_option(option) local buf = {} local function default_printer(s) table.insert(buf, s) end printer = printer or default_printer local cache if option.object_cache == 'global' then -- steal the cache into a local var so it's not visible from _G or anywhere -- still can't avoid user explicitly referentce pprint._cache but it shouldn't happen anyway cache = pprint._cache or {} pprint._cache = nil elseif option.object_cache == 'local' then cache = {} end local last = '' -- used for look back and remove trailing comma local status = { indent = '', -- current indent len = 0, -- current line length } local wrapped_printer = function(s) printer(last) last = s end local function _indent(d) status.indent = string.rep(' ', d + #(status.indent)) end local function _n(d) wrapped_printer('\n') wrapped_printer(status.indent) if d then _indent(d) end status.len = 0 return true -- used to close bracket correctly end local function _p(s, nowrap) status.len = status.len + #s if not nowrap and status.len > option.level_width then _n() wrapped_printer(s) status.len = #s else wrapped_printer(s) end end local formatter = {} local function format(v) local f = formatter[type(v)] f = f or formatter.table -- allow patched type() if option.filter_function and option.filter_function(v, nil, nil) then return '' else return f(v) end end local function tostring_formatter(v) return tostring(v) end local function number_formatter(n) return n == math.huge and '[[math.huge]]' or tostring(n) end local function nop_formatter(v) return '' end local function make_fixed_formatter(t, has_cache) if has_cache then return function (v) return string.format('[[%s %d]]', t, cache[t][v]) end else return function (v) return '[['..t..']]' end end end local function string_formatter(s, force_long_quote) local s, quote = escape(s) local quote_len = force_long_quote and 4 or 2 if quote_len + #s + status.len > option.level_width then _n() -- only wrap string when is longer than level_width if option.wrap_string and #s + quote_len > option.level_width then -- keep the quotes together _p('[[') while #s + status.len >= option.level_width do local seg = option.level_width - status.len _p(string.sub(s, 1, seg), true) _n() s = string.sub(s, seg+1) end _p(s) -- print the remaining parts return ']]' end end return force_long_quote and '[['..s..']]' or quote..s..quote end local function table_formatter(t) if option.use_tostring then local mt = getmetatable(t) if mt and mt.__tostring then return string_formatter(tostring(t), true) end end local print_header_ix = nil local ttype = type(t) if option.object_cache then local cache_state = cache.visited_tables[t] local tix = cache[ttype][t] -- FIXME should really handle `cache_state == nil` -- as user might add things through filter_function if cache_state == false then -- already printed, just print the the number return string_formatter(string.format('%s %d', ttype, tix), true) elseif cache_state > 1 then -- appeared more than once, print table header with number print_header_ix = tix cache.visited_tables[t] = false else -- appeared exactly once, print like a normal table end end local limit = tonumber(option.depth_limit) if limit and depth > limit then if print_header_ix then return string.format('[[%s %d]]...', ttype, print_header_ix) end return string_formatter(tostring(t), true) end local tlen = #t local wrapped = false _p('{') _indent(option.indent_size) _p(string.rep(' ', option.indent_size - 1)) if print_header_ix then _p(string.format('--[[%s %d]] ', ttype, print_header_ix)) end for ix = 1,tlen do local v = t[ix] if formatter[type(v)] == nop_formatter or (option.filter_function and option.filter_function(v, ix, t)) then -- pass else if option.wrap_array then wrapped = _n() end depth = depth+1 _p(format(v)..', ') depth = depth-1 end end -- hashmap part of the table, in contrast to array part local function is_hash_key(k) if type(k) ~= 'number' then return true end local numkey = math.floor(tonumber(k)) if numkey ~= k or numkey > tlen or numkey <= 0 then return true end end local function print_kv(k, v, t) -- can't use option.show_x as obj may contain custom type if formatter[type(v)] == nop_formatter or formatter[type(k)] == nop_formatter or (option.filter_function and option.filter_function(v, k, t)) then return end wrapped = _n() if is_plain_key(k) then _p(k, true) else _p('[') -- [[]] type string in key is illegal, needs to add spaces inbetween local k = format(k) if string.match(k, '%[%[') then _p(' '..k..' ', true) else _p(k, true) end _p(']') end _p(' = ', true) depth = depth+1 _p(format(v), true) depth = depth-1 _p(',', true) end if option.sort_keys then local keys = {} for k, _ in pairs(t) do if is_hash_key(k) then table.insert(keys, k) end end table.sort(keys, cmp) for _, k in ipairs(keys) do print_kv(k, t[k], t) end else for k, v in pairs(t) do if is_hash_key(k) then print_kv(k, v, t) end end end if option.show_metatable then local mt = getmetatable(t) if mt then print_kv('__metatable', mt, t) end end _indent(-option.indent_size) -- make { } into {} last = string.gsub(last, '^ +$', '') -- peek last to remove trailing comma last = string.gsub(last, ',%s*$', ' ') if wrapped then _n() end _p('}') return '' end -- set formatters formatter['nil'] = option.show_nil and tostring_formatter or nop_formatter formatter['boolean'] = option.show_boolean and tostring_formatter or nop_formatter formatter['number'] = option.show_number and number_formatter or nop_formatter -- need to handle math.huge formatter['function'] = option.show_function and make_fixed_formatter('function', option.object_cache) or nop_formatter formatter['thread'] = option.show_thread and make_fixed_formatter('thread', option.object_cache) or nop_formatter formatter['userdata'] = option.show_userdata and make_fixed_formatter('userdata', option.object_cache) or nop_formatter formatter['string'] = option.show_string and string_formatter or nop_formatter formatter['table'] = option.show_table and table_formatter or nop_formatter if option.object_cache then -- needs to visit the table before start printing cache_apperance(obj, cache, option) end _p(format(obj)) printer(last) -- close the buffered one -- put cache back if global if option.object_cache == 'global' then pprint._cache = cache end return table.concat(buf) end -- pprint all the arguments function pprint.pprint( ... ) local args = {...} -- select will get an accurate count of array len, counting trailing nils local len = select('#', ...) for ix = 1,len do pprint.pformat(args[ix], nil, io.write) io.write('\n') end end setmetatable(pprint, { __call = function (_, ...) pprint.pprint(...) end }) return pprint end preload["modules.regex.parser"] = function(...) -- Regex Parser -- Parses to an internal IL representation used for the construction of an NFA --[[ MIT License Copyright (c) 2019 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local parser = {} function parser.lexRegex(regexStr) local termEaten local function peek() return regexStr:sub(1, 1) end local pos = 0 local function eatc() local c = peek() termEaten = termEaten .. c regexStr = regexStr:sub(2) pos = pos + 1 return c end local switchTable = { ["|"] = "union", ["*"] = function() if peek() == "?" then eatc() return "ng-star" end return "star" end, ["+"] = function() if peek() == "?" then eatc() return "ng-plus" end return "plus" end, ["?"] = "optional", ["("] = "l-paren", [")"] = "r-paren", ["{"] = "l-bracket", ["}"] = "r-bracket", ["."] = "any", ["^"] = "start", ["$"] = "eos", ["\\"] = function() local metas = {d = "[0-9]", w = "[a-zA-Z]", n = "\n"} local c = eatc() if metas[c] then regexStr = metas[c] .. regexStr pos = pos - #metas[c] return false end termEaten = termEaten:sub(2) return "char" end, ["["] = function() if peek() == "^" then eatc() return "open-negset" end return "open-set" end, ["]"] = "close-set", ["-"] = "range" } local tokens = {} while #regexStr > 0 do termEaten = "" local c = eatc() local lexFn = switchTable[c] local ret = "char" if lexFn then if type(lexFn) == "string" then ret = lexFn else ret = lexFn() end end if ret then tokens[#tokens + 1] = { type = ret, source = termEaten, position = pos } end end tokens[#tokens + 1] = {type = "eof", source = "", position = pos + 1} return tokens end --[[ Grammar: ::= ::= "|" | ::= ::= | ::= | | | | | ::= "*" ::= "+" ::= "*?" ::= "+?" ::= "{" "}" ::= "," | "," ::= | | | | ::= "(" ")" ::= "." ::= "$" ::= any non metacharacter | "\" metacharacter ::= | ::= "[" "]" ::= "[^" "]" ::= | ::= | ::= "-" Special Chars: | * + *? +? ( ) . $ \ [ [^ ] - ]] function parser.parse(tokenList) local RE, unionList, simpleRE, basicRE, basicREList, elementaryRE, quantifier, group, set, setItems, setItem local parseTable = { unionList = {["union"] = 1, default = 2}, basicREList = {["union"] = 2, ["r-paren"] = 2, ["eof"] = 2, default = 1}, elementaryRE = {["l-paren"] = 1, ["any"] = 2, ["char"] = 3, ["open-set"] = 4, ["open-negset"] = 4}, setItems = {["close-set"] = 1, default = 2} } local function eat() return table.remove(tokenList, 1) end local function uneat(token) table.insert(tokenList, 1, token) end local function expect(token, source) local tok = eat() if tok.type ~= token then error("Unexpected token '" .. tok.type .. "' at position " .. tok.position, 0) end if source and not tok.source:match(source) then error("Unexpected '" .. tok.source .. "' at position " .. tok.position, 0) end return tok end local function getMyType(name, index) local parseFn = parseTable[name][tokenList[index or 1].type] or parseTable[name].default if not parseFn then error("Unexpected token '" .. tokenList[index or 1].type .. "' at position " .. tokenList[index or 1].position, 0) end return parseFn end local function unrollLoop(container) local list, i = {}, 1 while container do list[i], i = container[1], i + 1 container = container[2] end return unpack(list) end -- ::= function RE() return {type = "RE", simpleRE(), unrollLoop(unionList())} end -- ::= "|" | function unionList() local parseFn = getMyType("unionList") if parseFn == 1 then eat() return {type = "unionList", simpleRE(), unionList()} else return end end -- ::= function simpleRE() return {type = "simpleRE", basicRE(), unrollLoop(basicREList())} end -- ::= | | | | | function basicRE() local atom = elementaryRE() local token = eat() local type = token.type if type == "star" then return {type = "star", atom} elseif type == "plus" then return {type = "plus", atom} elseif type == "ng-star" then return {type = "ng-star", atom} elseif type == "ng-plus" then return {type = "ng-plus", atom} elseif type == "optional" then return {type = "optional", atom} elseif type == "l-bracket" then uneat(token) return {type = "quantifier", atom, quantifier = quantifier()} else uneat(token) return {type = "atom", atom} end end -- ::= "{" "}" -- ::= "," | "," function quantifier() expect("l-bracket") local firstDigit = "" do local nextTok = expect("char", "%d") local src = nextTok.source repeat firstDigit = firstDigit .. src nextTok = eat() src = nextTok.source until src:match("%D") uneat(nextTok) end if tokenList[1].type == "r-bracket" then eat() local count = tonumber(firstDigit) return {type = "count", count = count} end expect("char", ",") local secondDigit = "" if tokenList[1].source:match("%d") then local src, nextTok = "" repeat secondDigit = secondDigit .. src nextTok = eat() src = nextTok.source until src:match("%D") uneat(nextTok) end expect("r-bracket") return {type = "range", min = tonumber(firstDigit), max = tonumber(secondDigit) or math.huge} end -- ::= | function basicREList() local parseFn = getMyType("basicREList") if parseFn == 1 then return {type = "basicREList", basicRE(), basicREList()} else return end end -- ::= | | | function elementaryRE() local parseFn = getMyType("elementaryRE") if parseFn == 1 then return group() elseif parseFn == 2 then eat() return {type = "any"} elseif parseFn == 3 then local token = eat() return {type = "char", value = token.source} elseif parseFn == 4 then return set() end end -- ::= "(" ")" function group() eat() local rexp = RE() eat() return {type = "group", rexp} end -- ::= | function set() local openToken = eat() local ret if openToken.type == "open-set" then ret = {type = "set", unrollLoop(setItems())} else -- open-negset ret = {type = "negset", unrollLoop(setItems())} end eat() return ret end -- ::= | function setItems() local firstItem = setItem() local parseFn = getMyType("setItems") if parseFn == 1 then return {type = "setItems", firstItem} else return {type = "setItems", firstItem, setItems()} end end -- ::= | function setItem() if tokenList[2].type == "range" then return {type = "range", start = eat().source, finish = (eat() and eat()).source} else return {type = "char", value = eat().source} end end local props = { clampStart = false, clampEnd = false } if tokenList[1].type == "start" then props.clampStart = true table.remove(tokenList, 1) end if tokenList[#tokenList - 1].type == "eos" then props.clampEnd = true table.remove(tokenList, #tokenList - 1) end if #tokenList == 1 then error("Empty regex", 0) end local ret = RE() ret.properties = props return ret end return parser end preload["modules.regex.nfactory"] = function(...) --[[ MIT License Copyright (c) 2019 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local nf = {} local util = require("modules.regex.util") nf.epsilon = {type = "epsilon"} -- Special value for epsilon transition local nameCounter = 0 local function genName() nameCounter = nameCounter + 1 return "s" .. nameCounter end local function emptyMachine(noAccept) local sName = genName() local machine = { states = { [sName] = {edges = {}} }, startState = sName, acceptStates = {[sName] = true} } if noAccept then machine.acceptStates = {} end return machine end local function addEnter(state, value) state.enter = state.enter or {} state.enter[#state.enter + 1] = value end function nf.semanticClone(machine) local cmachine = util.deepClone(machine) -- Rename all states so there are no collisions local suffix = genName() cmachine.startState = cmachine.startState .. suffix local astates = {} for k in pairs(cmachine.acceptStates) do astates[#astates + 1] = k end for i = 1, #astates do local k, v = astates[i], cmachine.acceptStates[astates[i]] cmachine.acceptStates[k] = nil cmachine.acceptStates[k .. suffix] = v end local states = {} for k in pairs(cmachine.states) do states[#states + 1] = k end for j = 1, #states do local k, v = states[j], cmachine.states[states[j]] for i = 1, #v.edges do v.edges[i].dest = v.edges[i].dest .. suffix end cmachine.states[k] = nil cmachine.states[k .. suffix] = v end return cmachine end function nf.concatMachines(first, second) local newMachine = util.deepClone(first) for k, v in pairs(second.states) do newMachine.states[k] = v end for k in pairs(first.acceptStates) do local xs = newMachine.states[k].edges xs[#xs + 1] = {condition = nf.epsilon, dest = second.startState} end newMachine.acceptStates = {} for k, v in pairs(second.acceptStates) do newMachine.acceptStates[k] = v end return newMachine end function nf.unionMachines(first, second) local newMachine = util.deepClone(first) for k, v in pairs(second.states) do newMachine.states[k] = v end for k, v in pairs(second.acceptStates) do newMachine.acceptStates[k] = v end -- Link start state local xs = newMachine.states[newMachine.startState].edges xs[#xs + 1] = {condition = nf.epsilon, dest = second.startState} return newMachine end function nf.generateFromCapture(atom) local capture = atom[1] local machine if capture.type == "char" then local sName, cName = genName(), genName() machine = { states = { [sName] = {edges = {{condition = capture.value, dest = cName}}}, [cName] = {edges = {}} }, startState = sName, acceptStates = {[cName] = true} } elseif capture.type == "any" then local sName, cName = genName(), genName() machine = { states = { [sName] = {edges = {}}, [cName] = {edges = {}} }, startState = sName, acceptStates = {[cName] = true} } local sEdges = machine.states[sName].edges for i = 1, 255 do sEdges[#sEdges + 1] = {condition = string.char(i), dest = cName} end elseif capture.type == "set" or capture.type == "negset" then local sName, cName = genName(), genName() machine = { states = { [sName] = {edges = {}}, [cName] = {edges = {}} }, startState = sName, acceptStates = {[cName] = true} } local tState = {} for i = 1, #capture do local match = capture[i] if match.type == "char" then tState[match.value] = true elseif match.type == "range" then local dir = match.finish:byte() - match.start:byte() dir = dir / math.abs(dir) for j = match.start:byte(), match.finish:byte(), dir do tState[string.char(j)] = true end end end local sEdges = machine.states[sName].edges if capture.type == "set" then for k in pairs(tState) do sEdges[#sEdges + 1] = {condition = k, dest = cName} end else for i = 1, 255 do if not tState[string.char(i)] then sEdges[#sEdges + 1] = {condition = string.char(i), dest = cName} end end end elseif capture.type == "group" then machine = nf.generateNFA(capture[1]) local instance = genName() addEnter(machine.states[machine.startState], "begin-group-" .. instance) for k in pairs(machine.acceptStates) do addEnter(machine.states[k], "end-group-" .. instance) end else error("Unimplemented capture: '" .. capture.type .. "'") end if atom.type == "atom" then return machine elseif atom.type == "plus" then local instance = genName() addEnter(machine.states[machine.startState], "begin-sort-" .. instance) for k in pairs(machine.acceptStates) do local es = machine.states[k].edges es[#es + 1] = {condition = nf.epsilon, dest = machine.startState} -- Mark the state for recording, used for path reduction later addEnter(machine.states[k], "maximize-" .. instance) end return machine elseif atom.type == "ng-plus" then local instance = genName() addEnter(machine.states[machine.startState], "begin-sort-" .. instance) for k in pairs(machine.acceptStates) do local es = machine.states[k].edges es[#es + 1] = {condition = nf.epsilon, priority = "low", dest = machine.startState} -- Mark the state for recording addEnter(machine.states[k], "minimize-" .. instance) end return machine elseif atom.type == "star" then local instance = genName() addEnter(machine.states[machine.startState], "begin-sort-" .. instance) local needStart = true for k in pairs(machine.acceptStates) do local es = machine.states[k].edges es[#es + 1] = {condition = nf.epsilon, dest = machine.startState} if k == machine.startState then needStart = false end -- Mark the state for recording addEnter(machine.states[k], "maximize-" .. instance) end if needStart then machine.acceptStates[machine.startState] = true end return machine elseif atom.type == "ng-star" then local instance = genName() addEnter(machine.states[machine.startState], "begin-sort-" .. instance) local needStart = true for k in pairs(machine.acceptStates) do local es = machine.states[k].edges es[#es + 1] = {condition = nf.epsilon, priority = "low", dest = machine.startState} if k == machine.startState then needStart = false end -- Mark the state for recording addEnter(machine.states[k], "minimize-" .. instance) end if needStart then machine.acceptStates[machine.startState] = true end return machine elseif atom.type == "optional" then machine.acceptStates[machine.startState] = true return machine elseif atom.type == "quantifier" then local quantifier = atom.quantifier if quantifier.type == "count" then local single = machine for _ = 2, quantifier.count do machine = nf.concatMachines(single, nf.semanticClone(machine)) end return machine else -- range local single = machine for _ = 2, quantifier.min do machine = nf.concatMachines(single, nf.semanticClone(machine)) end if quantifier.max == math.huge then local prevMachine = nf.semanticClone(machine) machine = nf.concatMachines(prevMachine, util.deepClone(single)) for k, v in pairs(prevMachine.acceptStates) do machine.acceptStates[k] = v end for k in pairs(machine.acceptStates) do local es = machine.states[k].edges es[#es + 1] = {condition = nf.epsilon, dest = single.startState} end else -- All in this range are valid, so setup those links for _ = quantifier.min + 1, quantifier.max do local prevMachine = nf.semanticClone(machine) machine = nf.concatMachines(prevMachine, util.deepClone(single)) for k, v in pairs(prevMachine.acceptStates) do machine.acceptStates[k] = v end end end return machine end else error("Unimplemented atom type: '" .. atom.type .. "'") end end function nf.generateNFA(parsedRegex) local machine = emptyMachine(true) machine.properties = parsedRegex.properties for i = 1, #parsedRegex do -- Different branches local branch = parsedRegex[i] local tempMachine = emptyMachine() for j = 1, #branch do local capture = branch[j] tempMachine = nf.concatMachines(tempMachine, nf.generateFromCapture(capture)) end machine = nf.unionMachines(machine, tempMachine) end return machine end return nf end preload["modules.regex"] = function(...) -- Main utilty file, exposes entire library --[[ MIT License Copyright (c) 2019 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local r2l = {} r2l.parser = require("modules.regex.parser") r2l.nfactory = require("modules.regex.nfactory") r2l.reducer = require("modules.regex.reducer") r2l.emitter = require("modules.regex.emitter") r2l.new = function(regex) local tokens = r2l.parser.lexRegex(regex) local parseSuccess, parsedRegex = pcall(r2l.parser.parse, tokens) if not parseSuccess then error("Failed to parse regex: " .. parsedRegex) end local origNFA = r2l.nfactory.generateNFA(parsedRegex) local origDFA = r2l.reducer.reduceNFA(origNFA) return loadstring(r2l.emitter.generateLua(origDFA))() end return r2l end preload["modules.regex.emitter"] = function(...) --[[ MIT License Copyright (c) 2019 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local util = require("modules.regex.util") local emitter = {} -- local counter = 0 -- local function makeName() -- counter = counter + 1 -- return "n" .. counter -- end --[[ local states = { s1 = function(nextChar) ... end, ... } local acceptStates = {s1 = true, ...} return function match(str) local strlen = #str local state = s1 local allMatches, ai = {}, 1 for startChar = 1, strlen do -- Conditional upon properties.clampStart local ci = 0 while state and ci <= strlen do if acceptStates[state] then if ci == strlen then -- Conditional upon properties.clampEnd allMatches[ai] = {str:sub(1, ci), startChar, ci + startChar - 1} end end state = states[state](str:sub(ci + 1, ci + 1)) ci = ci + 1 end end return unpack(allMatches) end ]] local function generateFunction(state) if #state.edges == 0 then return "function() end" end local output = "function(char)" local dests = {} for i = 1, #state.edges do local edge = state.edges[i] local dest = edge.dest dests[dest] = dests[dest] or {} dests[dest][#dests[dest] + 1] = edge.condition end local prefix = "if" for dest, conds in pairs(dests) do output = output .. "\n " .. prefix table.sort(conds) local ranges = {} local singles = {} while #conds > 0 do if #conds == 1 then singles[#singles + 1] = conds[1] break elseif #conds == 2 then singles[#singles + 1] = conds[1] singles[#singles + 1] = conds[2] break end local val, index = conds[1]:byte(), 2 while conds[index]:byte() - index + 1 == val do index = index + 1 if index > #conds then break end end index = index - 1 if index == 1 then singles[#singles + 1] = table.remove(conds, 1) elseif index == 2 then singles[#singles + 1] = table.remove(conds, 1) singles[#singles + 1] = table.remove(conds, 1) else ranges[#ranges + 1] = {string.char(val), string.char(val + index - 1)} for i = 1, index do table.remove(conds, 1) end end end local first = true for i = 1, #ranges do local range = ranges[i] if first then first = false else output = output .. " or" end output = output .. " (char >= " .. range[1]:byte() .. " and char <= " .. range[2]:byte() .. ")" -- if range[1] == "]" then -- output = output .. "%]" -- elseif range[1]:match("[a-zA-Z]") then -- output = output .. range[1] -- else -- output = output .. "\\" .. range[1]:byte() -- end -- output = output .. "-" -- if range[2] == "]" then -- output = output .. "%]" -- elseif range[2]:match("[a-zA-Z]") then -- output = output .. range[2] -- else -- output = output .. "\\" .. range[2]:byte() -- end end for i = 1, #singles do if first then first = false else output = output .. " or" end output = output .. " char == " .. singles[i]:byte() -- if singles[i]:match("[a-zA-Z]") then -- output = output .. singles[i] -- else -- output = output .. "%\\" .. singles[i]:byte() -- end end output = output .. " then return " .. dest prefix = "elseif" end output = output .. " end\n end" return output end --[[ machine = { states = { [sName] = {edges = {}} }, startState = sName, acceptStates = {[sName] = true} } ]] local function numericize(dfa) local newMachine = util.deepClone(dfa) local oldNames = {} local newNames = {} local counter = 0 for k, v in pairs(dfa.states) do counter = counter + 1 newMachine.states[k] = nil newMachine.states[counter] = v newNames[k] = counter oldNames[counter] = k end for i = 1, counter do local oldEdges = dfa.states[oldNames[i]].edges local newEdges = newMachine.states[i].edges local n = #oldEdges for j = 1, n do newEdges[j].dest = newNames[oldEdges[j].dest] end end newMachine.startState = newNames[dfa.startState] for k, v in pairs(dfa.acceptStates) do newMachine.acceptStates[k] = nil newMachine.acceptStates[newNames[k]] = v end return newMachine end function emitter.generateLua(dfa) dfa = numericize(dfa) local output = [[ local unpack = unpack or table.unpack local states = { ]] for i = 1, #dfa.states do local state = dfa.states[i] output = output .. " " .. generateFunction(state) .. ",\n" end output = output .. [[} local stateEntries = { ]] for i = 1, #dfa.states do output = output .. " {" local state = dfa.states[i] local entries = util.nub(state.enter) for j = 1, #entries do output = output .. "'" .. entries[j] .. "'," end output = output .. "},\n" end output = output .. [[} local acceptStates = {]] for state in pairs(dfa.acceptStates) do output = output .. "[" .. state .."] = true," end output = output .. [[} return function(str) local strlen = #str local allMatches, ai = {}, 1 ]] if dfa.properties.clampStart then output = output .. "local startChar = 1 do\n" else output = output .. "for startChar = 1, strlen do\n" end output = output .. [[ local state = ]] output = output .. dfa.startState output = output .. [[ local ci = startChar - 1 while state and ci <= strlen do if acceptStates[state] then ]] if dfa.properties.clampEnd then output = output .. [[ if ci == strlen then ]] else output = output .. [[ do ]] end output = output .. [[allMatches[ai] = {str:sub(startChar, ci), startChar, ci} ai = ai + 1 end end local char = str:sub(ci + 1, ci + 1):byte() if char then state = states[state](char) end ci = ci + 1 end end local result for i = 1, #allMatches do if (not result) or #allMatches[i][1] > #result[1] then result = allMatches[i] end end if result then return unpack(result) end end ]] return output end return emitter end preload["modules.hooks.textCanvas"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Solyd = require("modules.solyd") local canvases = require("modules.canvas") ---@return TextCanvas local function useTextCanvas(display, w, h) local c = Solyd.useMemo(function() return canvases.TextCanvas(w, h) end, {display, w, h}) return c -- local c = Solyd.useMemo(function() -- return display.textCanvas -- end, {display}) -- return c end return useTextCanvas end preload["modules.hooks.modals"] = function(...) end preload["modules.hooks.input"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Solyd = require("modules.solyd") local function useInput(x, y, width, height, inputState, onChar, onKey, onBlur, onPaste) local input = Solyd.useRef(function() return { x = x, y = y, width = width, height = height, inputState = inputState, onChar = onChar, onKey = onKey, onBlur = onBlur, onPaste = onPaste} end).value input.x = x input.y = y input.width = width input.height = height input.inputState = inputState input.onChar = onChar input.onKey = onKey input.onBlur = onBlur input.onPaste = onPaste return input end local function findActiveInput(inputs) -- x, y = x*2, y*3 for i = #inputs, 1, -1 do local input = inputs[i] if input.__type == "list" then local node = findActiveInput(input) if node then return node end else if input.inputState.active then return input end end end end local function clearActiveInput(inputs, x, y) for i = #inputs, 1, -1 do local input = inputs[i] if input.__type == "list" then clearActiveInput(input) else if input.inputState.active and (x*2 < input.x or x*2-1 >= input.x + input.width or y*3 < input.y or y*3-2 >= input.y + input.height) then input.inputState.active = false return input end end end end return { useInput = useInput, findActiveInput = findActiveInput, clearActiveInput = clearActiveInput, } end preload["modules.hooks"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] return { useCanvas = require("modules.hooks.canvas"), useTextCanvas = require("modules.hooks.textCanvas"), useBoundingBox = require("modules.hooks.aabb").useBoundingBox, findNodeAt = require("modules.hooks.aabb").findNodeAt, useInput = require("modules.hooks.input").useInput, findActiveInput = require("modules.hooks.input").findActiveInput, clearActiveInput = require("modules.hooks.input").clearActiveInput, useAnimation = require("modules.hooks.animation").useAnimation, tickAnimations = require("modules.hooks.animation").tickAnimations, } end preload["modules.hooks.canvas"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Solyd = require("modules.solyd") local canvases = require("modules.canvas") ---@return PixelCanvas local function useCanvas(display, w,h) local c = Solyd.useMemo(function() if w then return canvases.PixelCanvas(w, h) else return display.ccCanvas.pixelCanvas:newFromSize() end end, {display, w, h}) return c end return useCanvas end preload["modules.hooks.animation"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Solyd = require("modules.solyd") local animationRequests = {} local animationFinished = {} ---@return number? local function useAnimation(playing) -- local anim = Solyd.useRef(function() -- return { playing = playing, frame = 0, time = 0 } -- end).value local t, setT = Solyd.useState(0) if playing then -- Request animation frame animationRequests[#animationRequests + 1] = {t, setT} return t elseif t ~= 0 then print("reset") setT(0) return else -- print("ff", t) end end local function tickAnimations(dt) -- Clone the queue to avoid mutating it while iterating local animationQueue = {unpack(animationRequests)} animationRequests = {} for _, v in ipairs(animationQueue) do local aT, setT = v[1], v[2] setT(aT + dt) end end return { useAnimation = useAnimation, tickAnimations = tickAnimations, animationFinished = animationFinished, } end preload["modules.hooks.aabb"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Solyd = require("modules.solyd") local function useBoundingBox(x, y, w, h, onClick, onScroll) local box = Solyd.useRef(function() return { x = x, y = y, w = w, h = h, onClick = onClick, onScroll = onScroll } end).value box.x = x box.y = y box.w = w box.h = h box.onClick = onClick box.onScroll = onScroll return box end local function findNodeAt(boxes, x, y) -- x, y = x*2, y*3 for i = #boxes, 1, -1 do local box = boxes[i] if box.__type == "list" then local node = findNodeAt(box, x, y) if node then return node end else if x*2 >= box.x and x*2-1 < box.x + box.w and y*3 >= box.y and y*3-2 < box.y + box.h then return box end end end end return { useBoundingBox = useBoundingBox, findNodeAt = findNodeAt, } end preload["modules.font"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local canvases = require("modules.canvas") local PixelCanvas = canvases.PixelCanvas ---@class Font ---@field private chars { [string]: FontChar } ---@field height number ---@field kerning number local Font = {} local Font_mt = { __index = Font } ---@alias FontChar PixelCanvas ---@param char PixelCanvas local function fixChar(char) for y = 1, char.height do for x = 1, char.width do if char.canvas[y][x] == colors.black then char.canvas[y][x] = nil end end end return char end ---@param canvas PixelCanvas ---@param text string ---@param x integer ---@param y integer ---@param c integer function Font:write(canvas, text, x, y, c) local cx = x for i = 1, #text do local char = self.chars[text:sub(i, i)] if char then canvas:drawTint(char, cx, y, c) cx = cx + char.width + self.kerning end end end ---@param text string function Font:getWidth(text) local width = 0 for i = 1, #text do local char = self.chars[text:sub(i, i)] if char then width = width + char.width + 1 end end if width == 0 then return 0 end return width - 1 end ---Write right aligned ---@param canvas PixelCanvas ---@param text string ---@param x integer ---@param y integer ---@param c integer function Font:writeRight(canvas, text, x, y, c) local width = self:getWidth(text) self:write(canvas, text, x - width + 1, y, c) end ---@param fontSheet PixelCanvas ---@param mapping string ---@return Font return function(fontSheet, mapping) local self = setmetatable({}, Font_mt) self.height = fontSheet.height-1 self.chars = {} self.kerning = 1 local charStartX = 1 local x = 1 for i = 1, #mapping do local name = mapping:sub(i, i) repeat x = x + 1 local nc = fontSheet.canvas[self.height+1][x] until x > fontSheet.width or (nc and nc ~= colors.black) local char = PixelCanvas(x - charStartX, self.height) char:drawCanvasClip(fontSheet, 1, 1, charStartX, 1, x - 1, self.height) self.chars[name] = fixChar(char) charStartX = x + 1 end return self end end preload["modules.display"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Display = {} local Display_mt = { __index = Display } function Display.new(props) local self = setmetatable({}, Display_mt) local canvases = require("modules.canvas") local PixelCanvas = canvases.PixelCanvas local TeletextCanvas = canvases.TeletextCanvas local TextCanvas = canvases.TextCanvas self.mon = props.monitor if self.mon and self.mon ~= term then self.mon = peripheral.wrap(props.monitor) end if not self.mon and self.mon ~= term then self.mon = peripheral.find("monitor") end if not self.mon then self.mon = term elseif self.mon ~= term then self.mon.setTextScale(0.5) end -- Set Riko Palette if props.theme and props.theme.palette then require("util.setPalette")(self.mon, props.theme.palette) end local bgColor = colors.black if props.theme and props.theme.colors and props.theme.colors.bgColor then bgColor = props.theme.colors.bgColor end self.ccCanvas = TeletextCanvas(bgColor, self.mon.getSize()) self.ccCanvas:outputFlush(self.mon) self.textCanvas = TextCanvas(bgColor, self.mon.getSize()) self.bgCanvas = self.ccCanvas.pixelCanvas:newFromSize() -- for y = 1, self.bgCanvas.height do -- for x = 1, self.bgCanvas.width do -- -- T-Piece -- self.bgCanvas:setPixel(x, y, props.theme.colors.headerBgColor) -- end -- end return self end return Display end preload["modules.canvas"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local floor, ceil, min, max, abs, concat = math.floor, math.ceil, math.min, math.max, math.abs, table.concat local function round(x) return floor(x + 0.5) end local _ = require("util.score") ---@alias Color ---| 1 ---| 2 ---| 4 ---| 8 ---| 16 ---| 32 ---| 64 ---| 128 ---| 256 ---| 512 ---| 1024 ---| 2048 ---| 4096 ---| 8192 ---| 16384 ---| 32768 local _ttxChars = setmetatable({}, {__index = error}) for i = 0, 31 do _ttxChars[i+1] = string.char(128 + i) end local _hex = setmetatable({}, {__index = error}) for i = 0, 15 do _hex[2^i] = string.format("%x", i) end ---@alias Terminal table A computercraft terminal object ---@class PixelCanvas ---@field width integer ---@field height integer ---@field canvas { [integer]: { [integer]: integer } } ---@field dirty { [integer]: { [integer]: boolean } } ---@field allDirty boolean? ---@operator call:PixelCanvas local PixelCanvas = {} local PixelCanvas_mt = { __index = PixelCanvas } setmetatable(PixelCanvas, { __call = function(_, ...) return PixelCanvas.new(...) end }) function PixelCanvas.new(width, height) local self = setmetatable({__opaque = true}, PixelCanvas_mt) self.width = width self.height = height self.canvas = {} for y = 1, height do self.canvas[y] = {} for x = 1, width do self.canvas[y][x] = nil end end self.dirty = {} -- { [y] = { [x] = true } } return self end ---Set the pixel at {x}, {y} to {c}. ---@param x integer ---@param y integer ---@param c Color function PixelCanvas:setPixel(x, y, c) x, y = floor(x), floor(y) -- TODO: remove bounds checking? if x < 1 or x > self.width or y < 1 or y > self.height then return end if self.canvas[y][x] ~= c then self.canvas[y][x] = c self.dirty[y] = self.dirty[y] or {} self.dirty[y][x] = true end end function PixelCanvas:clone() local clone = PixelCanvas.new(self.width, self.height) for y = 1, self.height do clone.dirty[y] = {} for x = 1, self.width do clone.canvas[y][x] = self.canvas[y][x] clone.dirty[y][x] = true end end return clone end function PixelCanvas:mapColors(map) for y = 1, self.height do for x = 1, self.width do self.canvas[y][x] = map[self.canvas[y][x]] or self.canvas[y][x] end end return self end ---Copies the contents of {canvas} into this canvas at {x, y}. ---@param canvas PixelCanvas ---@param x integer ---@param y integer function PixelCanvas:drawCanvas(canvas, x, y, remapFrom, remapTo) for cy = 1, canvas.height do for cx = 1, canvas.width do local c = canvas.canvas[cy][cx] if c == remapFrom then if type(remapTo) == "table" then error() end c = remapTo end if c then -- TODO: document this self:setPixel(x + cx - 1, y + cy - 1, c) end end end end function PixelCanvas:drawCanvas180(canvas, x, y) for cy = 1, canvas.height do for cx = 1, canvas.width do self:setPixel(x + cx - 1, y + canvas.height - cy - 1, canvas.canvas[cy][cx]) end end end function PixelCanvas:drawCanvasRotated(canvas, cx, cy, angle) local sin, cos = math.sin(angle), math.cos(angle) local absSin, absCos = math.abs(sin), math.abs(cos) local newWidth, newHeight = ceil(canvas.width * absCos + canvas.height * absSin), ceil(canvas.width * absSin + canvas.height * absCos) for y = 1, newHeight do for x = 1, newWidth do local px, py = x - newWidth / 2, y - newHeight / 2 local rx, ry = px * cos - py * sin, px * sin + py * cos rx, ry = rx + canvas.width / 2, ry + canvas.height / 2 rx, ry = round(rx), round(ry) if rx >= 1 and rx <= canvas.width and ry >= 1 and ry <= canvas.height then self:setPixel(x + cx - newWidth / 2, y + cy - newHeight / 2, canvas.canvas[ry][rx]) end end end end ---Copies the contents of {canvas} into this canvas at {x, y} with color {c}. ---@param canvas PixelCanvas ---@param x integer ---@param y integer ---@param c integer function PixelCanvas:drawTint(canvas, x, y, c) for cy = 1, canvas.height do for cx = 1, canvas.width do if canvas.canvas[cy][cx] then self:setPixel(x + cx - 1, y + cy - 1, c) end end end end ---Copies the contents of {canvas} into this canvas at {x, y} with the specified bounds. ---@param canvas PixelCanvas ---@param x integer ---@param y integer ---@param startX integer ---@param startY integer ---@param endX integer ---@param endY integer function PixelCanvas:drawCanvasClip(canvas, x, y, startX, startY, endX, endY) for cy = startY, endY do for cx = startX, endX do self:setPixel(x + cx - startX, y + cy - startY, canvas.canvas[cy][cx]) end end end function PixelCanvas:drawRect(c, x, y, w, h) for cy = 1, h do for cx = 1, w do self:setPixel(x + cx - 1, y + cy - 1, c) end end end ---Mark the pixel at {x, y} as dirty, indicating that it should be updated during frame composition. ---@param x integer ---@param y integer function PixelCanvas:mark(x, y) x, y = floor(x), floor(y) -- TODO: remove bounds checking? if x < 1 or x > self.width or y < 1 or y > self.height then return end self.dirty[y] = self.dirty[y] or {} self.dirty[y][x] = true end ---@param x integer ---@param y integer ---@param width integer ---@param height integer function PixelCanvas:markRect(x, y, width, height) x, y = floor(x), floor(y) -- TODO: remove bounds checking? for cy = y, y + height - 1 do if cy < 1 or cy > self.height then -- out of bounds else self.dirty[cy] = self.dirty[cy] or {} for cx = x, x + width - 1 do if cx < 1 or cx > self.width then -- out of bounds else self.canvas[cy][cx] = nil self.dirty[cy][cx] = true end end end end end ---@param x integer ---@param y integer ---@param width integer ---@param height integer function PixelCanvas:dirtyRect(x, y, width, height) x, y = floor(x), floor(y) -- TODO: remove bounds checking? for cy = y, y + height - 1 do if cy < 1 or cy > self.height then -- out of bounds else self.dirty[cy] = self.dirty[cy] or {} for cx = x, x + width - 1 do if cx < 1 or cx > self.width then -- out of bounds else self.dirty[cy][cx] = true end end end end end ---Mark the rectangle formed by the given canvas at {x, y} as dirty. ---@param canvas PixelCanvas ---@param x integer ---@param y integer function PixelCanvas:markCanvas(canvas, x, y) for cy = 1, canvas.height do for cx = 1, canvas.width do self:setPixel(x + cx - 1, y + cy - 1, nil) end end end function PixelCanvas:markCanvasRotated(canvas, cx, cy, angle) local sin, cos = math.sin(angle), math.cos(angle) local absSin, absCos = math.abs(sin), math.abs(cos) local newWidth, newHeight = ceil(canvas.width * absCos + canvas.height * absSin), ceil(canvas.width * absSin + canvas.height * absCos) for y = 1, newHeight do for x = 1, newWidth do local px, py = x - newWidth / 2, y - newHeight / 2 local rx, ry = px * cos - py * sin, px * sin + py * cos rx, ry = rx + canvas.width / 2, ry + canvas.height / 2 rx, ry = round(rx), round(ry) if rx >= 1 and rx <= canvas.width and ry >= 1 and ry <= canvas.height then self:setPixel(x + cx - newWidth / 2, y + cy - newHeight / 2, nil) end end end end ---Creates a new PixelCanvas with the same width and height as this canvas. ---@return PixelCanvas function PixelCanvas:newFromSize() return PixelCanvas.new(self.width, self.height) end function PixelCanvas.is(obj) return getmetatable(obj) == PixelCanvas_mt end ---@param others { [1]: PixelCanvas, [2]: integer, [3]: integer }[] function PixelCanvas:composite(others) for _, other in ipairs(others) do self:drawCanvas(other[1], other[2], other[3]) end end ---@class TextCanvas ---@field width number ---@field height number ---@field canvas { [integer]: { t: { [integer]: string }, c: { [integer]: string }, b: { [integer]: string } } } ---@field dirty { [integer]: { [integer]: boolean } } ---@operator call:PixelCanvas local TextCanvas = {} local TextCanvas_mt = { __index = TextCanvas } setmetatable(TextCanvas, { __call = function(_, ...) return TextCanvas.new(...) end }) function TextCanvas.new(width, height) local self = setmetatable({__opaque = true}, TextCanvas_mt) self.width = width self.height = height self.brand = "TextCanvas" self.canvas = {} for y = 1, height do self.canvas[y] = {} self.canvas[y].t = {} self.canvas[y].c = {} self.canvas[y].b = {} -- for x = 1, width do -- [x] = nil -- end end self.dirty = {} -- { [y] = { [x] = true } } return self end ---Write a string to the canvas at {x, y}. ---@param text string ---@param x integer ---@param y integer ---@param c integer ---@param b integer function TextCanvas:write(text, x, y, c, b) x, y = floor(x), floor(y) if x < 1 or x > self.width or y < 1 or y > self.height then return end c = _hex[c] b = _hex[b] self.dirty[y] = self.dirty[y] or {} for i = 1, #text do if x + i - 1 > self.width then break end self.dirty[y][x + i - 1] = true self.canvas[y].t[x + i - 1] = text:sub(i, i) self.canvas[y].c[x + i - 1] = c self.canvas[y].b[x + i - 1] = b end end ---Mark a string to the canvas at {x, y}. ---@param text string ---@param x integer ---@param y integer function TextCanvas:markText(text, x, y) x, y = floor(x), floor(y) if x < 1 or x > self.width or y < 1 or y > self.height then return end self.dirty[y] = self.dirty[y] or {} for i = 1, #text do if x + i - 1 > self.width then break end self.dirty[y][x + i - 1] = true self.canvas[y].t[x + i - 1] = nil end end function TextCanvas.is(self) return getmetatable(self) == TextCanvas_mt end ---@class TeletextCanvas ---@field width integer ---@field height integer ---@field clear integer The color to use when a pixel is transparent. ---@field pixelCanvas PixelCanvas ---@field canvas { [integer]: { t: { [integer]: string }, c: { [integer]: string }, b: { [integer]: string }, direct: { [integer]: boolean } } } ---@field dirty { [integer]: { [integer]: boolean } } ---@field dirtyRows { [integer]: { [1]: integer, [2]: integer } } ---@operator call:TeletextCanvas local TeletextCanvas = {} local TeletextCanvas_mt = { __index = TeletextCanvas } setmetatable(TeletextCanvas, { __call = function(_, ...) return TeletextCanvas.new(...) end }) function TeletextCanvas.new(clear, width, height) local self = setmetatable({__opaque = true}, TeletextCanvas_mt) self.width = width self.height = height self.clear = clear or colors.black self.pixelCanvas = PixelCanvas(width*2, height*3) self.canvas = {} for y = 1, height do self.canvas[y] = { t = {}, c = {}, b = {}, direct = {} } for x = 1, width do self.canvas[y].t[x] = " " self.canvas[y].c[x] = "0" self.canvas[y].b[x] = _hex[clear] self.canvas[y].direct[x] = false end end self.dirty = {} -- { [y] = { [x] = true } } self.dirtyRows = {} return self end function TeletextCanvas:reset(clear) for y = 1, self.height do self.canvas[y] = { t = {}, c = {}, b = {}, direct = {} } for x = 1, self.width do self.canvas[y].t[x] = " " self.canvas[y].c[x] = "0" self.canvas[y].b[x] = _hex[clear] self.canvas[y].direct[x] = false end end self.dirty = {} -- { [y] = { [x] = true } } self.dirtyRows = {} end ---Composites the given pixel canvases onto the teletext's internal ---pixel canvas and recomputes the teletext's character data. ---@param ... { [1]: PixelCanvas, [2]: integer, [3]: integer } function TeletextCanvas:composite(...) local others = {...} -- Partition the screen based on canvas x/y/w/h -- local t1 = os.epoch("utc") local partitionSize = 20 local partitions = {} for y = 1, self.height*3, partitionSize do partitions[ceil(y/partitionSize)] = {} for x = 1, self.width*2, partitionSize do partitions[ceil(y/partitionSize)][ceil(x/partitionSize)] = {} end end -- local t2 = os.epoch("utc") -- print("Partitioning took " .. (t2-t1) .. "ms") -- t1 = os.epoch("utc") for _, other in ipairs(others) do other[2] = floor(other[2]) other[3] = floor(other[3]) -- if PixelCanvas.is(other) then local otherCanvas, otherX, otherY = other[1], other[2], other[3] local originPartitionX = (ceil(otherX/partitionSize) - 1)*partitionSize + 1 local originPartitionY = (ceil(otherY/partitionSize) - 1)*partitionSize + 1 --TODO: This is a hack, should use -1 instead of +1 for y = originPartitionY, otherY+otherCanvas.height+1, partitionSize do for x = originPartitionX, otherX+otherCanvas.width+1, partitionSize do local px = ceil(x/partitionSize) local py = ceil(y/partitionSize) local partition = (partitions[py] or {})[px] if partition then partition[#partition + 1] = other end end end -- end end -- t2 = os.epoch("utc") -- print("Canvas partition assignment took " .. (t2-t1) .. "ms") -- t1 = os.epoch("utc") local queuedDirty = {} local c = 0 for _, other in ipairs(others) do local ocanvas, ox, oy = other[1], other[2]-1, other[3]-1 local isPixel = ocanvas.brand ~= "TextCanvas" if ocanvas.allDirty then for y = 1, ocanvas.height do for x = 1, ocanvas.width do if isPixel then local tx = x+ox local ty = y+oy if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then queuedDirty[ty] = queuedDirty[ty] or {} queuedDirty[ty][tx] = true c = c + 1 end else local tx = x*2-1 + ox local ty = y*3-2 + oy -- print(ty) if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then queuedDirty[ty] = queuedDirty[ty] or {} queuedDirty[ty][tx] = true c = c + 1 end end end end else for y, row in pairs(ocanvas.dirty) do for x, _ in pairs(row) do if isPixel then local tx = x+ox local ty = y+oy if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then queuedDirty[ty] = queuedDirty[ty] or {} queuedDirty[ty][tx] = true c = c + 1 end else local tx = x*2-1 + ox local ty = y*3-2 + oy -- print(ty) if tx >= 1 and tx <= self.width*2 and ty >= 1 and ty <= self.height*3 then queuedDirty[ty] = queuedDirty[ty] or {} queuedDirty[ty][tx] = true c = c + 1 end -- print(y) -- for gayY = y-10,y+10 do -- if gayY > 1 and gayY <= #ocanvas.dirty then -- for gayX = x-10,x+10 do -- if gayX > 1 and gayX <= #row then -- queuedDirty[y*3] = queuedDirty[y*3] or {} -- queuedDirty[y*3][x*2] = true -- queuedDirty[y*3][x*2-1] = true -- queuedDirty[y*3-1] = queuedDirty[y*3-1] or {} -- queuedDirty[y*3-1][x*2] = true -- queuedDirty[y*3-1][x*2-1] = true -- queuedDirty[y*3-2] = queuedDirty[y*3-2] or {} -- queuedDirty[y*3-2][x*2] = true -- queuedDirty[y*3-2][x*2-1] = true -- end -- end -- end -- end end -- else -- -- TODO: ewwwwwwww -- end end end end ocanvas.dirty = {} -- TODO: is this necessary? end -- t2 = os.epoch("utc") -- t1 = os.epoch("utc") for y, row in pairs(queuedDirty) do local targetY = ceil(y / 3) self.dirty[targetY] = self.dirty[targetY] or {} -- print("row size: " .. #row) for x, _ in pairs(row) do local targetX = ceil(x / 2) local currPixel = self.pixelCanvas.canvas[y][x] partitionY = ceil(y/partitionSize) --math.min(floor(y/partitionSize)+1, #partitions) partitionX = ceil(x/partitionSize) --math.min(floor(x/partitionSize)+1, #partitions[1]) --print("getting partition (partizion size " .. partitionSize .. ") at x: " .. x .. ", y: " .. y .. " -> ".. floor(x/partitionSize)+1 .. ", " .. floor(y/partitionSize)+1) --print("Partitions size: " .. #partitions[1] .. ", " .. #partitions) local partition = partitions[partitionY][partitionX] local found = false -- local foundText = false for i = #partition, 1, -1 do local other = partition[i] local otherCanvas, ox, oy = other[1], other[2], other[3] if PixelCanvas.is(otherCanvas) then ---@cast other PixelCanvas t1 = os.epoch("utc") if otherCanvas.canvas[y-oy+1] then local otherPixel = (otherCanvas.canvas[y-oy+1] or {})[x-ox+1] if otherPixel then found = true if otherPixel ~= currPixel then self.pixelCanvas.canvas[y][x] = otherPixel self.dirty[targetY][targetX] = true end if self.canvas[targetY].direct[targetX] then self.canvas[targetY].direct[targetX] = false self.dirty[targetY][targetX] = true end break end end t2 = os.epoch("utc") else ---@cast other TextCanvas -- print( targetX .. ", " .. targetY .. ": " .. ox .. ", " .. oy .. " -> " .. x .. ", " .. y) local ty = ceil((y-oy+1)/3) --ceil(targetY - oy / 3) local tx = ceil((x-ox+1)/2) --ceil(targetX - ox / 2) -- print(tx .. " " .. ty) -- if ty == 1 then -- print("fuck") -- print(#otherCanvas.canvas) -- print(#otherCanvas.canvas[ty].t) -- sleep(1) -- end if otherCanvas.canvas[ty] and otherCanvas.canvas[ty].c[tx] then -- print("found text") local otherRow = otherCanvas.canvas[ty] local otherT = otherRow.t[tx] local otherC = otherRow.c[tx] local otherB = otherRow.b[tx] if otherT then found = true foundText = true local currRow = self.canvas[targetY] local currT = currRow.t[targetX] local currC = currRow.c[targetX] local currB = currRow.b[targetX] if otherT ~= currT or otherC ~= currC or otherB ~= currB then currRow.t[targetX] = otherT currRow.c[targetX] = otherC currRow.b[targetX] = otherB currRow.direct[targetX] = true self.dirty[targetY][targetX] = false -- Already processed local dirtyRow = self.dirtyRows[targetY] or {} local minX, maxX = dirtyRow[1], dirtyRow[2] minX = minX and min(minX, targetX) or targetX maxX = maxX and max(maxX, targetX) or targetX self.dirtyRows[targetY] = { minX, maxX } end break else local currRow = self.canvas[targetY] currRow.direct[targetX] = false end else -- local currRow = self.canvas[targetY] -- currRow.direct[targetX] = false end end end if not found then self.pixelCanvas.canvas[y][x] = self.clear self.dirty[targetY][targetX] = true self.canvas[targetY].direct[targetX] = false end -- if not foundText then -- if self.canvas[targetY].direct[targetX] then -- self.canvas[targetY].direct[targetX] = false -- self.dirty[targetY][targetX] = true -- end -- end end end -- t2 = os.epoch("utc") -- print("Canvas merging took " .. (t2-t1) .. "ms") -- Recalculate teletext canvas local clear = self.clear for y, row in pairs(self.dirty) do local oy = (y - 1) * 3 local dirtyRow = self.dirtyRows[y] or {} local minX, maxX = dirtyRow[1], dirtyRow[2] for x, _ in pairs(row) do minX = minX and min(minX, x) or x maxX = maxX and max(maxX, x) or x local ox = (x - 1) * 2 local sub, char, c1, c2, c3, c4, c5, c6 = 32768, 1, self.pixelCanvas.canvas[oy + 1][ox + 1] or clear, self.pixelCanvas.canvas[oy + 1][ox + 2] or clear, self.pixelCanvas.canvas[oy + 2][ox + 1] or clear, self.pixelCanvas.canvas[oy + 2][ox + 2] or clear, self.pixelCanvas.canvas[oy + 3][ox + 1] or clear, self.pixelCanvas.canvas[oy + 3][ox + 2] or clear if c1 ~= c6 then sub = c1 char = 2 end if c2 ~= c6 then sub = c2 char = char + 2 end if c3 ~= c6 then sub = c3 char = char + 4 end if c4 ~= c6 then sub = c4 char = char + 8 end if c5 ~= c6 then sub = c5 char = char + 16 end if self.canvas[y].direct[x] == false then self.canvas[y].t[x] = _ttxChars[char] self.canvas[y].c[x] = _hex[sub] self.canvas[y].b[x] = _hex[c6] end end self.dirtyRows[y] = { minX, maxX } end end ---Output any dirty rows to the given {out} terminal. ---@param out Terminal function TeletextCanvas:outputDirty(out) for y, row in pairs(self.dirtyRows) do local minX, maxX = row[1], row[2] if minX then local t,c,b if maxX - minX == 0 then t = self.canvas[y].t[minX] c = self.canvas[y].c[minX] b = self.canvas[y].b[minX] else t = concat(self.canvas[y].t, "", minX, maxX) c = concat(self.canvas[y].c, "", minX, maxX) b = concat(self.canvas[y].b, "", minX, maxX) end out.setCursorPos(minX, y) out.blit(t, c, b) end end self.dirty = {} self.dirtyRows = {} end ---Output the entire canvas to the given {out} terminal. ---@param out Terminal function TeletextCanvas:outputFlush(out) for y = 1, self.height do local t = concat(self.canvas[y].t, "") local c = concat(self.canvas[y].c, "") local b = concat(self.canvas[y].b, "") out.setCursorPos(1, y) out.blit(t, c, b) end end return { PixelCanvas = PixelCanvas, TeletextCanvas = TeletextCanvas, TextCanvas = TextCanvas, } end preload["modules.animation"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Iter = require("util.iter") local list = Iter.list local Ease = require("modules.animation.Ease") local function copy(t) local new = {} for k, v in pairs(t) do new[k] = v end return new end ---@alias Step { duration: number, to: table?, easing: function | table | nil } ---@class AnimationDescriptor ---@field sprite PixelCanvas ---@field initial table ---@field steps Step[]? ---@alias AnimationSets AnimationDescriptor[][] ---Evaluate one animation descriptor and return current animation state. ---@param animation AnimationDescriptor ---@param t number ---@return table animationState, number consumedTime, boolean isFinished local function evaluateSingleAnimation(animation, t) local state = copy(animation.initial) state.sprite = animation.sprite local steps = animation.steps if not steps then return state, 0, true end local stepIndex = 1 local consumedTime = 0 while t > 0 do local step = steps[stepIndex] if not step then break end if t > step.duration then -- If this step is already completed, just skip it and set the to values if step.to then for k, v in pairs(step.to) do state[k] = v end end t = t - step.duration consumedTime = consumedTime + step.duration stepIndex = stepIndex + 1 else -- If this step is not completed, interpolate the values if step.to then local easingFunction = step.easing or Ease.linear for k, v in pairs(step.to) do if type(easingFunction) == "function" then state[k] = easingFunction(t, state[k], v - state[k], step.duration) else ---@cast easingFunction table state[k] = (easingFunction[k] or Ease.linear)(t, state[k], v - state[k], step.duration) end end end consumedTime = consumedTime + t break end end return state, consumedTime, stepIndex > #steps end ---Evaluate all animation descriptors for iterative sets and return current animation state. ---@param animationSets AnimationSets ---@param t number ---@return { sprite: PixelCanvas, [string]: number }[] visibleSprites, boolean isFinished local function evaluateAnimationSets(animationSets, t) local remainingTime = t local setContents = {} for animationSet in list(animationSets) do local setDuration = 0 local allFinished = true setContents = {} for i = 1, #animationSet do local animationState, consumedTime, finished = evaluateSingleAnimation(animationSet[i], remainingTime) setContents[i] = animationState print("c", consumedTime, finished) setDuration = math.max(setDuration, consumedTime) if not finished then allFinished = false end end if allFinished then remainingTime = remainingTime - setDuration else return setContents, false -- If we have no time left, return the current set end end return setContents, true -- All sets are finished end ---@param animationSets AnimationSets ---@return { sprite: PixelCanvas, [string]: number }[] visibleSprites local function skipAnimation(animationSets) local setContents = {} local animationSet = animationSets[#animationSets] for i = 1, #animationSet do setContents[i] = evaluateSingleAnimation(animationSet[i], math.huge) end return setContents end return { evaluateSingleAnimation = evaluateSingleAnimation, evaluateAnimationSets = evaluateAnimationSets, skipAnimation = skipAnimation, } end preload["modules.animation.Ease"] = function(...) --[[ MIT License Copyright (c) 2022 emmachase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ]] local Ease = {} function Ease.linear(t, b, c, d) return c * t / d + b end function Ease.inQuad(t, b, c, d) t = t / d return c * t * t + b end function Ease.outQuad(t, b, c, d) t = t / d return -c * t * (t - 2) + b end function Ease.inOutQuad(t, b, c, d) t = t / d * 2 if t < 1 then return c / 2 * t * t + b else t = t - 1 return -c / 2 * (t * (t - 2) - 1) + b end end function Ease.outInQuad(t, b, c, d) if t < d / 2 then return Ease.outQuad(t * 2, b, c / 2, d) else return Ease.inQuad((t * 2) - d, b + c / 2, c / 2, d) end end function Ease.inCubic(t, b, c, d) t = t / d return c * t * t * t + b end function Ease.outCubic(t, b, c, d) t = t / d - 1 return c * (t * t * t + 1) + b end function Ease.inOutCubic(t, b, c, d) t = t / d * 2 if t < 1 then return c / 2 * t * t * t + b else t = t - 2 return c / 2 * (t * t * t + 2) + b end end function Ease.outInCubic(t, b, c, d) if t < d / 2 then return Ease.outCubic(t * 2, b, c / 2, d) else return Ease.inCubic((t * 2) - d, b + c / 2, c / 2, d) end end function Ease.inQuart(t, b, c, d) t = t / d return c * t * t * t * t + b end function Ease.outQuart(t, b, c, d) t = t / d - 1 return -c * (t * t * t * t - 1) + b end function Ease.inOutQuart(t, b, c, d) t = t / d * 2 if t < 1 then return c / 2 * t * t * t * t + b else t = t - 2 return -c / 2 * (t * t * t * t - 2) + b end end function Ease.outInQuart(t, b, c, d) if t < d / 2 then return Ease.outQuart(t * 2, b, c / 2, d) else return Ease.inQuart((t * 2) - d, b + c / 2, c / 2, d) end end return Ease end preload["fonts.smolfont"] = function(...) local loadRIF = require("modules.rif") local createFont = require("modules.font") local smolFontSheet = loadRIF("res.smolfont") local smolFont = createFont(smolFontSheet, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:;<=>?[\\]^_{|}~\128` !\"#$%&'()*+,-./@\164") return smolFont end preload["fonts.bigfont"] = function(...) local loadRIF = require("modules.rif") local createFont = require("modules.font") local bigFontSheet = loadRIF("res.cfont") local bigFont = createFont(bigFontSheet, " abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/-,.\164!:\6@'<>") return bigFont end preload["core.schemas"] = function(...) local configSchema = { branding = { title = "string", subtitle = "string?", }, settings = { hideUnavailableProducts = "boolean", hideNegativePrices = "boolean", pollFrequency = "number", categoryCycleFrequency = "number", activityTimeout = "number", dropDirection = "enum<'forward' | 'up' | 'down' | 'north' | 'south' | 'east' | 'west'>: direction", smallTextKristPayCompatability = "boolean", playSounds = "boolean", showFooter = "boolean", refundInvalidMetaname = "boolean", refundMissingMetaname = "boolean", refundInsufficentFunds = "boolean" }, lang = { footer = "string", footerNoName = "string?", refundRemaining = "string", refundOutOfStock = "string", refundAtLeastOne = "string", refundInvalidProduct = "string", refundNoProduct = "string", refundError = "string" }, theme = { formatting = { headerAlign = "enum<'left' | 'center' | 'right'>: alignment", subtitleAlign = "enum<'left' | 'center' | 'right'>: alignment", footerAlign = "enum<'left' | 'center' | 'right'>: alignment", footerSize = "enum<'small' | 'medium' | 'large' | 'auto'>: size", productNameAlign = "enum<'left' | 'center' | 'right'>: alignment", layout = "enum<'small' | 'medium' | 'large' | 'auto' | 'custom'>: layout", layoutFile = "file?" }, colors = { bgColor = "color", headerBgColor = "color", headerColor = "color", subtitleBgColor = "color", subtitleColor = "color", footerBgColor = "color", footerColor = "color", productBgColors = { __type = "array", __min = 1, __entry = "color" }, outOfStockQtyColor = "color", lowQtyColor = "color", warningQtyColor = "color", normalQtyColor = "color", productNameColor = "color", outOfStockNameColor = "color", priceColor = "color", addressColor = "color", currencyTextColor = "color", currencyBgColors = { __type = "array", __min = 1, __entry = "color" }, catagoryTextColor = "color", categoryBgColors = { __type = "array", __min = 1, __entry = "color" }, activeCategoryColor = "color", }, palette = { [colors.black] = "number", [colors.blue] = "number", [colors.purple] = "number", [colors.green] = "number", [colors.brown] = "number", [colors.gray] = "number", [colors.lightGray] = "number", [colors.red] = "number", [colors.orange] = "number", [colors.yellow] = "number", [colors.lime] = "number", [colors.cyan] = "number", [colors.magenta] = "number", [colors.pink] = "number", [colors.lightBlue] = "number", [colors.white] = "number" } }, terminalTheme = { colors = { titleTextColor = "color", titleBgColor = "color", bgColor = "color", catagoryTextColor = "color", catagoryBgColor = "color", activeCatagoryBgColor = "color", logTextColor = "color", configEditor = { bgColor = "color", textColor = "color", buttonColor = "color", buttonTextColor = "color", inactiveButtonColor = "color", inactiveButtonTextColor = "color", scrollbarBgColor = "color", scrollbarColor = "color", inputBgColor = "color", inputTextColor = "color", errorBgColor = "color", errorTextColor = "color", toggleColor = "color", toggleBgColor = "color", toggleOnColor = "color", toggleOffColor = "color", unsavedChangesColor = "color", unsavedChangesTextColor = "color", modalBgColor = "color", modalTextColor = "color", modalBorderColor = "color", }, productsEditor = { bgColor = "color", textColor = "color", buttonColor = "color", buttonTextColor = "color", inactiveButtonColor = "color", inactiveButtonTextColor = "color", scrollbarBgColor = "color", scrollbarColor = "color", inputBgColor = "color", inputTextColor = "color", errorBgColor = "color", errorTextColor = "color", toggleColor = "color", toggleBgColor = "color", toggleOnColor = "color", toggleOffColor = "color", unsavedChangesColor = "color", unsavedChangesTextColor = "color", modalBgColor = "color", modalTextColor = "color", modalBorderColor = "color", } }, palette = { [colors.black] = "number", [colors.blue] = "number", [colors.purple] = "number", [colors.green] = "number", [colors.brown] = "number", [colors.gray] = "number", [colors.lightGray] = "number", [colors.red] = "number", [colors.orange] = "number", [colors.yellow] = "number", [colors.lime] = "number", [colors.cyan] = "number", [colors.magenta] = "number", [colors.pink] = "number", [colors.lightBlue] = "number", [colors.white] = "number" } }, sounds = { button = "sound", purchase = "sound", }, currencies = { __type = "array", __label = "id", __min = 1, __entry = { id = "string", node = "string?", name = "regex<^[a-z0-9]{1,64}(\\.[a-z0-9]{1,64})?$>?: name", pkey = "string", pkeyFormat = "enum<'raw' | 'kristwallet'>: pkey format", value = "number?" } }, peripherals = { monitor = "string?", speaker = "speaker?", modem = "modem?", shopSyncModem = "modem?", blinker = "enum<'left' | 'right' | 'front' | 'back' | 'top' | 'bottom'>?: side", exchangeChest = "chest?", outputChest = "chest", }, shopSync = { enabled = "boolean?", name = "string?", description = "string?", owner = "string?", location = { description = "string?", dimension = "enum<'overworld' | 'nether' | 'end'>?: dimension" } }, exchange = { enabled = "boolean", node = "string" } } local productsSchema = { __type = "array", __label = "name", __entry = { modid = "string?", productId = "string?", name = "string", address = "string", category = "string?", hidden = "boolean?", maxQuantity = "number?", price = "number", priceOverrides = { __type = "array?", __label = "currency", __entry = { currency = "string", price = "number" } }, bundle = { __type = "array?", __label = "product", __entry = { product = "string", quantity = "number" } }, predicate = "table?" } } local hooksSchema = { start = "function?", prePurchase = "function?", purchase = "function?", failedPurchase = "function?", programError = "function?", blink = "function?", configSaved = "function?", productsSaved = "function?", } local soundSchema = { name = "string", volume = "number", pitch = "number" } return { configSchema = configSchema, productsSchema = productsSchema, hooksSchema = hooksSchema, soundSchema = soundSchema } end preload["core.inventory.ScanInventory"] = function(...) local eventHook = require("util.eventHook") local score = require("util.score") local itemCache = {} local nbtHashCache = {} local partialObjectMatches local function partialArrayMatches(partialArray, array) for i = 1, #partialArray do local found = false for j = 1, #array do if type(array[j]) == "table" then if partialObjectMatches(partialArray[i], array[j]) then found = true break end elseif partialArray[i] == array[j] then found = true break end end if not found then return false end end return true end function partialObjectMatches(partialObject, object) if type(object) ~= "table" then return false end if object[1] then return partialArrayMatches(partialObject, object) end for k,v in pairs(partialObject) do if type(v) == "table" then if not partialObjectMatches(v, object[k]) then return false end elseif object[k] == nil or object[k] ~= v then return false end end return true end local function predicateMatches(predicates, item, allowCached) if not allowCached or not item.cachedMeta then local meta = peripheral.call(item.inventory, "getItemDetail", item.slot) item.cachedMeta = meta end return partialObjectMatches(predicates, item.cachedMeta) end local function findMatchingProducts(products, item) local matchingProducts = {} for i = 1, #products do local product = products[i] if product.predicates and not product.predicatesString then product.predicatesString = textutils.serialize(product.predicates) end if item.name == product.modid then if product.predicates and item.nbt and nbtHashCache[item.name .. "." .. item.nbt] then for j = 1, #nbtHashCache[item.name .. "." .. item.nbt] do if nbtHashCache[item.name .. "." .. item.nbt][j] == product.predicatesString then table.insert(matchingProducts, product) break end end else if not product.predicates or predicateMatches(product.predicates, item, true) then table.insert(matchingProducts, product) end end end end if item.nbt and not nbtHashCache[item.name .. "." .. item.nbt] then nbtHashCache[item.name .. "." .. item.nbt] = {} for i = 1, #matchingProducts do table.insert(nbtHashCache[item.name .. "." .. item.nbt], matchingProducts[i].predicatesString) end end return matchingProducts end local function getInventories() peripherals = peripheral.getNames() inventories = {} for i = 1, #peripherals do name = peripherals[i] local methods = peripheral.getMethods(name) local hasListItems = false local hasPushItems = false if methods then for j = 1, #methods do if methods[j] == "list" then hasListItems = true end if methods[j] == "pushItems" then hasPushItems = true end if hasListItems and hasPushItems then break end end end if hasListItems and hasPushItems then table.insert(inventories, peripheral.wrap(name)) end end return inventories end local function getInventoryItems(inventory, products) local inventoryName = peripheral.getName(inventory) local items = {} local slots = inventory.list() for slot, item in pairs(slots) do if item then item.inventory = inventoryName item.slot = slot table.insert(items, item) local matchingProducts = findMatchingProducts(products, item) for j = 1, #matchingProducts do local product = matchingProducts[j] if not product.newQty then product.newQty = 0 end product.newQty = product.newQty + item.count end end end return items end local function getAllInventoryItems(inventories, products) local items = {} local inventoryThreads = {} for i = 1, #inventories do local inventory = inventories[i] table.insert(inventoryThreads, function() local inventoryItems = getInventoryItems(inventory, products) for j = 1, #inventoryItems do table.insert(items, inventoryItems[j]) end end) end parallel.waitForAll(unpack(inventoryThreads)) return items end local function updateProductInventory(products, onInventoryRefresh) local quantitiesChanged = false for i = 1, #products do products[i].newQty = 0 end local inventories = getInventories() local originalProducts = products products = score.copyDeep(originalProducts) local items = getAllInventoryItems(inventories, products) itemCache = items if onInventoryRefresh then eventHook.execute(onInventoryRefresh, products, items) end for i = 1, #products do local product = products[i] product.predicatesString = nil local oldQty = product.quantity product.quantity = product.newQty if product.bundle and #product.bundle > 0 then if not product.modid then product.quantity = nil end for _, bundledProduct in ipairs(product.bundle) do for _, searchProduct in ipairs(products) do if searchProduct.address:lower() == bundledProduct.product:lower() or searchProduct.name:lower() == bundledProduct.product:lower() or (searchProduct.productId and searchProduct.productId:lower() == bundledProduct.product:lower()) then local searchQty = searchProduct.newQty if not searchQty then searchQty = searchProduct.quantity end if not product.quantity then product.quantity = searchQty end product.quantity = math.min(product.quantity, math.min(searchQty / bundledProduct.quantity)) end end end end if product.quantity ~= oldQty then quantitiesChanged = true end product.newQty = nil product.__opaque = true originalProducts[i] = product end return quantitiesChanged end local function getItemCache() return itemCache end local function findProductItemsFrom(product, quantity, items, cached) local sources = {} local remaining = quantity if product.predicates and not product.predicatesString then product.predicatesString = textutils.serialize(product.predicates) end for i = 1, #items do local item = items[i] local inventory = item.inventory local slot = item.slot local cacheHit = false if item.nbt and nbtHashCache[item.name .. "." .. item.nbt] then for j = 1, #nbtHashCache[item.name .. "." .. item.nbt] do if nbtHashCache[item.name .. "." .. item.nbt][j] == product.predicatesString then cacheHit = true break end end end if item.name == product.modid and (not cached or not product.predicates or cacheHit or (item.cachedMeta and partialObjectMatches(product.predicates, item.cachedMeta))) then if cached or product.predicates then item = peripheral.call(inventory, "getItemDetail", slot) end if item then if item.name ~= product.modid or (product.predicates and not partialObjectMatches(product.predicates, item)) then item = nil else item.inventory = inventory item.slot = slot end end if item and item.count > 0 then local amount = math.min(item.count, remaining) table.insert(sources, { inventory = item.inventory, slot = item.slot, amount = amount }) remaining = remaining - amount end end if remaining <= 0 then break end end return sources, quantity - remaining end local function findProductItems(products, product, quantity, onInventoryRefresh) local sources = nil local amount = 0 local items = getItemCache() sources, amount = findProductItemsFrom(product, quantity, items, true) if amount == 0 then updateProductInventory(products, onInventoryRefresh) items = getItemCache() sources, amount = findProductItemsFrom(product, quantity, items) end for i = 1, #products do products[i].predicatesString = nil end return sources, amount end function clearNbtCache() nbtHashCache = {} end return { updateProductInventory = updateProductInventory, getItemCache = getItemCache, findProductItems = findProductItems, clearNbtCache = clearNbtCache } end preload["core.ShopState"] = function(...) local Krypton = require("Krypton") local ScanInventory = require("core.inventory.ScanInventory") local Pricing = require("core.Pricing") local sound = require("util.sound") local score = require("util.score") local eventHook = require("util.eventHook") local blinkFrequency = 3 local shopSyncFrequency = 30 local shopSyncChannel = 9773 ---@class ShopState ---@field running boolean local ShopState = {} local ShopState_mt = { __index = ShopState } function ShopState.new(config, products, peripherals, version, logs, eventHooks) local self = setmetatable({}, ShopState_mt) self.running = false self.config = config self.peripherals = peripherals self.products = products self.version = version if config.currencies and config.currencies[1] then self.selectedCurrency = config.currencies[1] else self.selectedCurrency = nil end self.selectedCategory = 1 self.numCategories = 1 self.productsChanged = false self.logs = logs self.eventHooks = eventHooks self.lastTouched = os.epoch("utc") return self end local function waitForAnimation(uid) coroutine.yield("animationFinished", uid) end local function parseMeta(transactionMeta) local meta = {} local i = 1 if transactionMeta and #transactionMeta > 0 then for metaEntry in transactionMeta:gmatch("([^;]+)") do if metaEntry:find("=") then local key, value = metaEntry:match("([^=]+)=([^=]+)") meta[key] = value else meta[metaEntry] = true meta[i] = metaEntry i = i + 1 end end end return meta end local function validateReturnAddress(address) -- Primitive validation, will accept all valid addresses at the expense of some false positives if address:find("@") then local metaname, name = address:match("([^@]+)@([^@]+).%w+") if not metaname or not name then return false end if #name > 64 or #metaname > 32 then return false end else if #address > 64 then return false end end return true end local function isRelative(name) return name == "top" or name == "bottom" or name == "left" or name == "right" or name == "front" or name == "back" end local function refund(currency, address, meta, value, message, error) message = message or "Here is your refund!" local returnTo = address if meta and meta["return"] then if validateReturnAddress(meta["return"]) then returnTo = meta["return"] end end if not error then currency.krypton.ws:makeTransaction(returnTo, value, "message=" .. message) else currency.krypton.ws:makeTransaction(returnTo, value, "error=" .. message) end end local function hasProductsChanged(original, new) if type(original) ~= type(new) then return true end if type(original) ~= "table" then return original ~= new end if #original ~= #new then return true end for k, v in pairs(original) do if k ~= "quantity" then if new[k] == nil then return true end if hasProductsChanged(v, new[k]) then return true end end end for k, v in pairs(new) do if original[k] == nil then return true end end return false end function ShopState:handlePurchase(transaction, meta, sentMetaname, transactionCurrency) local purchasedProduct = nil if self.eventHooks and self.eventHooks.preProduct then purchasedProduct, err, errorMessage = eventHook.execute(self.eventHooks.preProduct, transaction, transactionCurrency, meta, sentMetaname, self.products) if err then refund(transactionCurrency, transaction.from, meta, transaction.value, errorMessage or self.config.lang.refundDenied, true) if self.eventHooks and self.eventHooks.failedPurchase then eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, errorMessage or self.config.lang.refundDenied) end return end end if purchasedProduct == nil then for _, product in ipairs(self.products) do if product.address:lower() == sentMetaname:lower() or product.name:gsub(" ", ""):lower() == sentMetaname:lower() then purchasedProduct = product break end end end if not purchasedProduct then if self.config.settings.refundInvalidMetaname then refund(transactionCurrency, transaction.from, meta, transaction.value, self.config.lang.refundInvalidProduct, true) end if self.eventHooks and self.eventHooks.failedPurchase then eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, nil, self.config.lang.refundInvalidProduct) end return end local productPrice = Pricing.getProductPrice(purchasedProduct, transactionCurrency) local amountPurchased = math.floor(transaction.value / productPrice) if productPrice == 0 then amountPurchased = math.max(transaction.value, 1) end if purchasedProduct.maxQuantity then amountPurchased = math.min(amountPurchased, purchasedProduct.maxQuantity) end if amountPurchased <= 0 then if self.config.settings.refundInsufficentFunds then refund(transactionCurrency, transaction.from, meta, transaction.value, self.config.lang.refundAtLeastOne, true) end if self.eventHooks and self.eventHooks.failedPurchase then eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, self.config.lang.refundAtLeastOne) end return end local productsPurchased = {} if purchasedProduct.modid then table.insert(productsPurchased, { product = purchasedProduct, quantity = 1 }) end if purchasedProduct.bundle and #purchasedProduct.bundle > 0 then for _, bundleProduct in ipairs(purchasedProduct.bundle) do for _, product in ipairs(self.products) do if product.address:lower() == bundleProduct.product:lower() or product.name:lower() == bundleProduct.product:lower() or (product.productId and product.productId:lower() == bundleProduct.product:lower()) then local productFound = false for _, productPurchased in ipairs(productsPurchased) do if productPurchased.product == product then productPurchased.quantity = productPurchased.quantity + bundleProduct.quantity productFound = true break end end if not productFound then table.insert(productsPurchased, { product = product, quantity = bundleProduct.quantity }) end break end end end end if self.eventHooks and self.eventHooks.preStockCheck then local allowPurchase, err, errMessage, invisible = eventHook.execute(self.eventHooks.preStockCheck, transaction, productsPurchased, self.products, amountPurchased) if allowPurchase == false then if not invisible then refund(transactionCurrency, transaction.from, meta, transaction.value, errMessage or self.config.lang.refundDenied, err) if self.eventHooks and self.eventHooks.failedPurchase then eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, errMessage or self.config.lang.refundDenied, err) end end return end end local available = amountPurchased for _, productPurchased in ipairs(productsPurchased) do local onInventoryRefresh if self.eventHooks and self.eventHooks.onInventoryRefresh then onInventoryRefresh = self.eventHooks.onInventoryRefresh end local productSources, productAvailable = ScanInventory.findProductItems(self.products, productPurchased.product, productPurchased.quantity * amountPurchased, onInventoryRefresh) available = math.min(available, math.floor(productAvailable / productPurchased.quantity)) productPurchased.sources = productSources if available == 0 then break end end if available <= 0 then refund(transactionCurrency, transaction.from, meta, transaction.value, self.config.lang.refundOutOfStock) if self.eventHooks and self.eventHooks.failedPurchase then eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, self.config.lang.refundOutOfStock) end return end local currencyMinimumIncrement = transactionCurrency.minimumIncrement or 1 local refundAmountRaw = transaction.value - (available * productPrice) local refundAmount = math.floor(refundAmountRaw / currencyMinimumIncrement) * currencyMinimumIncrement local allowPurchase = true local err local errMessage if self.eventHooks and self.eventHooks.prePurchase then allowPurchase, err, errMessage, invisible = eventHook.execute(self.eventHooks.prePurchase, purchasedProduct, available, refundAmount, transaction, transactionCurrency) end if allowPurchase == false then if not invisible then refund(transactionCurrency, transaction.from, meta, transaction.value, errMessage or self.config.lang.refundDenied, err) if self.eventHooks and self.eventHooks.failedPurchase then eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, purchasedProduct, errMessage or self.config.lang.refundDenied, err) end end return end print("Purchased " .. available .. " of " .. purchasedProduct.name .. " for " .. transaction.from .. " for " .. transaction.value .. " " .. transactionCurrency.id .. " (refund " .. refundAmount .. ")") for _, productPurchased in ipairs(productsPurchased) do for _, productSource in ipairs(productPurchased.sources) do if self.config.peripherals.outputChest == "self" then if not turtle then error("Self output but not a turtle!") end if not self.peripherals.modem.getNameLocal() then error("Modem is not connected! Try right clicking it") end if turtle.getSelectedSlot() ~= 1 then turtle.select(1) end peripheral.call(productSource.inventory, "pushItems", self.peripherals.modem.getNameLocal(), productSource.slot, productSource.amount, 1) if self.config.settings.dropDirection == "forward" then turtle.drop(productSource.amount) elseif self.config.settings.dropDirection == "up" then turtle.dropUp(productSource.amount) elseif self.config.settings.dropDirection == "down" then turtle.dropDown(productSource.amount) else error("Invalid drop direction: " .. self.config.settings.dropDirection) end else peripheral.call(productSource.inventory, "pushItems", self.config.peripherals.outputChest, productSource.slot, productSource.amount, 1) --peripheral.call(state.config.peripherals.outputChest, "drop", 1, productSource.amount, state.config.settings.dropDirection) end end productPurchased.product.quantity = productPurchased.product.quantity - (productPurchased.quantity * available) end if not purchasedProduct.modid then purchasedProduct.quantity = math.max(0, purchasedProduct.quantity - available) end if refundAmount > 0 then refund(transactionCurrency, transaction.from, meta, refundAmount, self.config.lang.refundRemaining) end if self.config.settings.playSounds then sound.playSound(self.peripherals.speaker, self.config.sounds.purchase) end if self.eventHooks and self.eventHooks.purchase then eventHook.execute(self.eventHooks.purchase, purchasedProduct, available, refundAmount, transaction, transactionCurrency) end os.queueEvent("radon_shopsync_update") end function ShopState:resolveCarrotPayName(name) -- Hardcoded endpoint as any future alternative name system is recommended to use the krist name API schema local node = "https://carrotpay.herrkatze.com/v2/" local endpoint = "names/" .. textutils.urlEncode(name) .. ".crt" local url = node .. endpoint local response, err = http.get(url) if not response then error(err, 3) end local body = response.readAll() response.close() local data = textutils.unserializeJSON(body) if not data.ok then if (data.error ~= "invalid_parameter") then error(data.error, 3) else error({data.error, data.parameter}, 3) end end return data end function ShopState:setupKrypton() self.selectedCurrency = self.config.currencies[1] self.currencies = {} self.kryptonListeners = {} for _, currency in ipairs(self.config.currencies) do if currency.name == "" then currency.name = nil end local node = currency.node if currency.id == "krist" or currency.id == "carrotpay" then node = node or "https://krist.dev/" currency.minimumIncrement = currency.minimumIncrement or 1 elseif currency.id == "tenebra" then node = node or "https://tenebra.lil.gay/" currency.minimumIncrement = currency.minimumIncrement or 1 elseif currency.id == "kromer" then node = node or "https://kromer.reconnected.cc/api/krist/" currency.minimumIncrement = currency.minimumIncrement or 0.01 end currency.krypton = Krypton.new({ privateKey = currency.pkey, node = node, id = currency.id, }) local pkey = currency.pkey if currency.pkeyFormat == "kristwallet" then pkey = currency.krypton:toKristWalletFormat(currency.pkey) end currency.host = currency.krypton:makev2address(pkey) currency.krypton.privateKey = pkey table.insert(self.currencies, currency) local kryptonWs = currency.krypton:connect() kryptonWs:subscribe("ownTransactions") kryptonWs:getSelf() if currency.name then local name = currency.name if name:find("%.") then name = name:sub(1, name:find("%.") - 1) end local nameInfo if currency.id ~= "carrotpay" then nameInfo = currency.krypton:getName(name) else nameInfo = self:resolveCarrotPayName(name) end if not nameInfo then error("Name " .. currency.name .. " does not exist!") end if nameInfo.name.owner:lower() ~= currency.host:lower() then error("Name " .. currency.name .. " is not owned by " .. currency.host .. "!") end end table.insert(self.kryptonListeners, function() kryptonWs:listen() end) self.kryptonReady = true end end -- Anytime the shop state is resumed, animation should be finished instantly. (call animation finish hooks) ---@param self ShopState function ShopState:runShop() -- Shop is starting -- Wait for config ready while not self.config.ready do sleep(0.5) end self.running = true self.currencies = {} self.kryptonListeners = {} self:setupKrypton() ScanInventory.clearNbtCache() local transactions = {} parallel.waitForAny(function() while true do local event, transactionEvent = os.pullEvent("transaction") if event == "transaction" then local transactionCurrency = nil for _, currency in ipairs(self.currencies) do if currency.krypton.id == transactionEvent.source then transactionCurrency = currency break end end if transactionCurrency then local transaction = transactionEvent.transaction local sentName = transaction.sent_name local sentMetaname = transaction.sent_metaname local nameSuffix = transactionCurrency.krypton.currency.name_suffix if sentName and transactionCurrency.name and transactionCurrency.name:find(".") then sentName = sentName .. "." .. nameSuffix end local meta = parseMeta(transaction.metadata) if transactionCurrency.id == "carrotpay" then -- Manually extract from meta for i = 1, #meta do local metaEntry = meta[i] sentMetaname, sentName, carrotPaySuffix = metaEntry:match("(.+)@(.+)%.(%w+)") if sentMetaname and sentName and carrotPaySuffix == "crt" then sentName = sentName .. "." .. carrotPaySuffix end end end if transaction.from ~= transactionCurrency.host and (not transactionCurrency.name and not sentName) or (transactionCurrency.name and sentName and sentName:lower() == transactionCurrency.name:lower()) then if transaction.to == transactionCurrency.host and not transactionCurrency.name and not sentMetaname then sentMetaname = meta[1] end if sentMetaname then local purchaseData = { transaction = transaction, meta = meta, sentMetaname = sentMetaname, transactionCurrency = transactionCurrency } transactions[#transactions + 1] = purchaseData os.queueEvent("radon_purchase", purchaseData) -- for hooks that might be able to catch it (parallel). elseif self.config.settings.refundMissingMetaname then if self.config.settings.refundInvalidMetaname then refund(transactionCurrency, transaction.from, meta, transaction.value, self.config.lang.refundNoProduct, true) end if self.eventHooks and self.eventHooks.failedPurchase then eventHook.execute(self.eventHooks.failedPurchase, transaction, transactionCurrency, nil, self.config.lang.refundNoProduct) end end end end end end end, function() while self.running do -- Run event hook for the parallel constant running task -- This can do things like listen to events or host applications if self.eventHooks and self.eventHooks.parallel then eventHook.execute(self.eventHooks.parallel) end sleep(blinkFrequency) end end, function() while self.running do os.pullEvent("radon_purchase") while #transactions > 0 do local purchaseData = table.remove(transactions, 1) local success, err = pcall(ShopState.handlePurchase, self, purchaseData.transaction, purchaseData.meta, purchaseData.sentMetaname, purchaseData.transactionCurrency) if success then -- Success :D else refund(purchaseData.transactionCurrency, purchaseData.transaction.from, purchaseData.meta, purchaseData.transaction.value, self.config.lang.refundError, true) if self.eventHooks and self.eventHooks.failedPurchase then eventHook.execute(self.eventHooks.failedPurchase, purchaseData.transaction, purchaseData.transactionCurrency, nil, self.config.lang.refundError) end error(err) end end end end, function() local originalProducts = {} for i = 1, #self.products do originalProducts[i] = score.copyDeep(self.products[i]) end while self.running do local onInventoryRefresh = nil if self.eventHooks and self.eventHooks.onInventoryRefresh then onInventoryRefresh = self.eventHooks.onInventoryRefresh end local quantitiesChanged = ScanInventory.updateProductInventory(self.products, onInventoryRefresh) if self.config.settings.hideUnavailableProducts then self.productsChanged = true end if quantitiesChanged then os.queueEvent("radon_shopsync_update") end sleep(self.config.settings.pollFrequency) local productsChanged = hasProductsChanged(originalProducts, self.products) if productsChanged then os.queueEvent("radon_shopsync_update") end originalProducts = {} for i = 1, #self.products do originalProducts[i] = score.copyDeep(self.products[i]) end end end, function() while self.running do if self.config.settings.categoryCycleFrequency > 0 and os.epoch("utc") > self.lastTouched + (self.config.settings.activityTimeout * 1000) then self.selectedCategory = self.selectedCategory + 1 if self.selectedCategory > self.numCategories then self.selectedCategory = 1 end self.productsChanged = true end sleep(math.min(1, self.config.settings.categoryCycleFrequency)) end end, function() local blinkState = false while self.running do blinkState = not blinkState if self.config.peripherals.blinker then redstone.setOutput(self.config.peripherals.blinker, blinkState) end if self.eventHooks and self.eventHooks.blink then eventHook.execute(self.eventHooks.blink, blinkState) end sleep(blinkFrequency) end end, function() local x, y, z = self.config.shopSync.location.x, self.config.shopSync.location.y, self.config.shopSync.location.z local gpsX, gpsY, gpsZ local lastShopSync = os.epoch("utc") sleep(math.random() * 15 + 15) os.queueEvent("radon_shopsync_update") while self.running do os.pullEvent("radon_shopsync_update") if self.config.shopSync and self.config.shopSync.enabled and self.peripherals.shopSyncModem then if ((not x or not y or not z) or (not self.config.shopSync.location.disableGPSPosVerification)) and (not gpsX or not gpsY or not gpsZ) then gpsX, gpsY, gpsZ = gps.locate(5) end if (not x or not y or not z) and (gpsX and gpsY and gpsZ) then x, y, z = gpsX, gpsY, gpsZ end if not self.config.shopSync.location.disableGPSPosVerification and (gpsX and gpsY and gpsZ and x and y and z and (gpsX ~= x or gpsY ~= y or gpsZ ~= z)) then print(string.format("Shopsync: Warning: GPS position doesn't match configured position, using configured position anyways (conf: %d,%d,%d; GPS: %d,%d,%d)", x, y, z, gpsX, gpsY, gpsZ)) end local now = os.epoch("utc") if (now - lastShopSync) < (shopSyncFrequency * 1000) then local timeToSleep = shopSyncFrequency - ((now - lastShopSync) / 1000) sleep(timeToSleep) end lastShopSync = os.epoch("utc") local items = {} for i = 1, #self.products do local product = self.products[i] local prices = {} local nbt = nil local predicates = nil if not product.bundle and not product.hidden and product.modid then if product.predicates then predicates = product.predicates if predicates.nbt then nbt = predicates.nbt -- TODO: Multiple nbt hashes for all matched predicates? end end for j = 1, #self.config.currencies do local currency = self.config.currencies[j] local currencyName = "KST" if currency.krypton and currency.krypton.currency and currency.krypton.currency.currency_symbol then currencyName = currency.krypton.currency.currency_symbol end local address = currency.host local requiredMeta = nil if currency.name then address = product.address .. "@" .. currency.name else requiredMeta = product.address end local price = product.price / currency.value if product.priceOverrides then for _, override in pairs(product.priceOverrides) do if override.currency == currency.id then price = override.price end end end if price >= 0 then table.insert(prices, { value = price, currency = currencyName, address = address, requiredMeta = requiredMeta }) end end table.insert(items, { prices = prices, item = { name = product.modid, displayName = product.name, nbt = nbt, --predicates = predicates }, dynamicPrice = false, stock = product.quantity, madeOnDemand = false, }) end end self.peripherals.shopSyncModem.transmit(shopSyncChannel, os.getComputerID() % 65536, { type = "ShopSync", info = { name = self.config.shopSync.name, description = self.config.shopSync.description, owner = self.config.shopSync.owner, computerID = os.getComputerID(), software = { name = "Radon", version = self.version }, location = { coordinates = {x, y, z}, description = self.config.shopSync.location.description, dimension = self.config.shopSync.location.dimension }, }, items = items }) end end end, function() while self.running do if self.changedCurrencies and self.oldConfig then self.changedCurrencies = false self.kryptonReady = false for i = 1, #self.oldConfig.currencies do local currency = self.oldConfig.currencies[i] if (currency.krypton and currency.krypton.ws) then currency.krypton.ws:disconnect() currency.krypton = nil end end for i = 1, #self.currencies do local currency = self.currencies[i] if (currency.krypton and currency.krypton.ws) then currency.krypton.ws:disconnect() currency.krypton = nil end end self:setupKrypton() self.kryptonReady = true self.oldConfig = nil end sleep(0.5) end end, function() while self.running do if self.kryptonReady then parallel.waitForAny(function() while self.kryptonReady do sleep(0.5) end end, unpack(self.kryptonListeners)) end sleep(0.5) end end) end return { ShopState = ShopState } end preload["core.ShopRunner"] = function(...) local ShopState = require("core.ShopState") local Animations = require("modules.hooks.animation") local function areAnimationsFinished(uid) local finished = Animations.animationFinished[uid] if finished then Animations.animationFinished[uid] = nil return true end return false end local function launchShop(shopState, mainFunction) parallel.waitForAny(function() shopState:runShop() end, mainFunction) -- local shopCoroutine = coroutine.create(function() ShopState.runShop(shopState) end) -- local mainCoroutine = coroutine.create(mainFunction) -- local stateFilter ---@type "animationFinished" | "waitForPlayerInput" -- local uidFilter -- local eventFilter -- local eventBacklog = {} -- while true do -- local e = (eventFilter == nil and #eventBacklog > 0) and table.remove(eventBacklog, 1) or { os.pullEvent() } -- if eventFilter and e[1] ~= eventFilter then -- eventBacklog[#eventBacklog+1] = e -- else -- local status, result = coroutine.resume(mainCoroutine, unpack(e)) -- eventFilter = result -- if not status then -- error(result) -- end -- end -- if coroutine.status(mainCoroutine) == "dead" then -- break -- end -- local canResume = coroutine.status(shopCoroutine) ~= "dead" -- true -- if stateFilter == "animationFinished" then -- canResume = areAnimationsFinished(uidFilter) -- elseif stateFilter == "waitForPlayerInput" then -- --canResume = isPlayerInputReady() -- elseif stateFilter == "timer" then -- canResume = e[1] == "timer" and e[2] == uidFilter -- end -- if canResume then -- -- print("resuming...") -- status, stateFilter, uidFilter = coroutine.resume(shopCoroutine) -- if stateFilter then -- print("new filter:", stateFilter) -- end -- if not status then -- error(stateFilter) -- end -- if coroutine.status(shopCoroutine) == "dead" then -- -- TODO: Reset game state -- -- gameCoroutine = coroutine.create(GameState.runGame) -- -- error("oops") -- end -- end -- end end return { launchShop = launchShop, } end preload["core.Pricing"] = function(...) local function getProductPrice(product, currency) local price = product.price / currency.value if product.priceOverrides then for i = 1, #product.priceOverrides do local override = product.priceOverrides[i] if override.currency == currency.id then price = override.price break end end end return price end return { getProductPrice = getProductPrice } end preload["core.ConfigValidator"] = function(...) local r2l = require("modules.regex") local schemas = require("core.schemas") local regexCache = {} local function typeCheck(entryType, typeName, value, path) if value then if entryType == "table" and type(value) ~= "table" then return { path = subpath, error = "Must be a table" } end if entryType == "string" and type(value) ~= "string" then return { path = subpath, error = "Must be a string" } end if entryType == "number" and type(value) ~= "number" then return { path = subpath, error = "Must be a number" } end if entryType == "function" and type(value) ~= "function" then return { path = subpath, error = "Must be a function" } end if entryType == "file" then if type(value) ~= "string" then return { path = subpath, error = "Must be a file path" } end if not fs.exists(value) or fs.isDir(value) then return { path = subpath, error = "File must exist" } end end if entryType == "color" then if type(value) ~= "number" then return { path = subpath, error = "Must be a color" } end m,n = math.frexp(value) if m ~= 0.5 or n < 1 or n > 16 then return { path = subpath, error = "Must be a color" } end end if entryType == "modem" then if type(value) ~= "string" then return { path = subpath, error = "Must be a modem name" } end if peripheral.getType(value) ~= "modem" then return { path = subpath, error = "Must refer to a modem" } end end if entryType == "speaker" then if type(value) ~= "string" then return { path = subpath, error = "Must be a speaker name" } end if peripheral.getType(value) ~= "speaker" then return { path = subpath, error = "Must refer to a speaker" } end end if entryType == "chest" then if type(value) ~= "string" then return { path = subpath, error = "Must be a chest name" } end -- If relative paths are fixed, add not turtle and if value == "left" or value == "right" or value == "front" or value == "back" or value == "top" or value == "bottom" then return { path = subpath, error = "Must be a network name" } end if not turtle and value == "self" then return { path = subpath, error = "Can only be self for turtles" } end if value ~= "self" then local chestMethods = peripheral.getMethods(value) if not chestMethods then return { path = subpath, error = "Must refer to a valid peripheral" } end local hasDropMethod = false for i = 1, #chestMethods do if chestMethods[i] == "drop" then hasDropMethod = true break end end if not hasDropMethod then return { path = subpath, error = "Must refer to an inventory" } end end end if entryType == "sound" then if type(value) ~= "table" then return { path = subpath, error = "Must be a sound" } end if not value.name or type(value.name) ~= "string" then return { path = subpath, error = "Sound must have a name" } end if not value.volume or type(value.volume) ~= "number" then return { path = subpath, error = "Sound must have a volume" } end if not value.pitch or type(value.pitch) ~= "number" then return { path = subpath, error = "Sound must have a pitch" } end end if entryType == "boolean" and type(value) ~= "boolean" then return { path = subpath, error = "Must be a boolean" } end if entryType:sub(1, 5) == "enum<" and entryType:sub(-1) == ">" then local enum = entryType:sub(6, -2) local found = false for enumValue in enum:gmatch("[^|]+") do enumValue = enumValue:sub(enumValue:find("'(.*)'")):sub(2, -2) if value == enumValue then found = true break end end if not found then if typeName then return { path = subpath, error = "Must be entryType " .. typeName .. " matching " .. enum } else return { path = subpath, error = "Must match " .. enum } end end end if entryType:sub(1, 6) == "regex<" and entryType:sub(-1) == ">" then local regexString = entryType:sub(7, -2) if not regexCache[regexString] then regexCache[regexString] = r2l.new(regexString) end local regex = regexCache[regexString] if not regex(value) then if typeName then return { path = subpath, error = "Must be entryType " .. typeName .. " matching " .. regexString } else return { path = subpath, error = "Must match " .. regexString } end end end end return nil end local function validate(config, schema, path) if not path then path = "" end if schema.__type then if schema.__type:sub(1, 5) == "array" then if schema.__type:sub(6, 6) == "?" and config == nil then return end if type(config) ~= "table" then return { path = path, error = "Must be an array" } end if schema.__min and #config < schema.__min then return { path = path, error = "Must have at least " .. schema.__min .. " entries" } end if schema.__max and #config > schema.__max then return { path = path, error = "Must have at most " .. schema.__max .. " entries" } end if schema.__entry then local validationErrors = {} for i = 1, #config do if type(config[i]) ~= "table" or type(config[i]) ~= "table" then local err = typeCheck(schema.__entry, schema.__entry, config[i], path .. "[" .. i .. "]") if err then table.insert(validationErrors, err) end else local errs = validate(config[i], schema.__entry, path .. "[" .. i .. "]") if errs and type(errs) == "table" and errs[1] then for _, err in ipairs(errs) do table.insert(validationErrors, err) end else table.insert(validationErrors, errs) end end end if #validationErrors > 0 then return validationErrors end end end else if not config then config = {} end local validationErrors = {} for k,v in pairs(schema) do subpath = path .. "." .. k if type(v) == "table" then local errs = validate(config[k], v, subpath) if errs and type(errs) == "table" and errs[1] then for _, err in ipairs(errs) do table.insert(validationErrors, err) end else table.insert(validationErrors, errs) end else -- If regex or enum, get type name -- E.g. regex<\w{10}>: address -> address local typeDef, typeName _, _, typeDef, typeName = v:find("^(%w+<.+>%??): (.+)$") if typeDef then v = typeDef end if v:sub(-1) ~= "?" and config[k] == nil then table.insert(validationErrors, { path = subpath, error = "Missing required config value" }) end if v:sub(-1) == "?" then v = v:sub(1, -2) end local err = typeCheck(v, typeName, config[k], subpath) if err then table.insert(validationErrors, err) end end end if #validationErrors > 0 then return validationErrors end end end function validationArrayToMap(validationErrors) local map = {} if not validationErrors then return map end for _, err in ipairs(validationErrors) do if err.path then map[err.path:gsub("%[(%d+)%]", "%.%1")] = err.error end end return map end local function validateConfig(config) return validate(config, schemas.configSchema, "config") end local function validateProducts(products) return validate(products, schemas.productsSchema, "products") end local function validateHooks(hooks) return validate(hooks, schemas.hooksSchema, "hooks") end return { typeCheck = typeCheck, validate = validate, validationArrayToMap = validationArrayToMap, validateConfig = validateConfig, validateProducts = validateProducts, validateHooks = validateHooks, } end preload["configDefaults"] = function(...) return { branding = { title = nil }, settings = { hideUnavailableProducts = false, hideNegativePrices = true, pollFrequency = 30, categoryCycleFrequency = -1, activityTimeout = 60, dropDirection = "forward", smallTextKristPayCompatability = true, playSounds = true, showFooter = true, refundInvalidMetaname = true, refundMissingMetaname = true, refundInsufficentFunds = true }, lang = { footer = "/pay @%name% ", footerNoName = "/pay %addr% ", refundRemaining = "Here is the funds remaining after your purchase!", refundOutOfStock = "Sorry, that item is out of stock!", refundAtLeastOne = "You must purchase at least one of this product!", refundInvalidProduct = "You must supply a valid product to purchase!", refundNoProduct = "You must supply a product to purchase!", refundError = "An error occurred while processing your purchase!", refundDenied = "This purchase has been denied" }, theme = { formatting = { headerAlign = "center", subtitleAlign = "center", footerAlign = "center", footerSize = "auto", productNameAlign = "center", layout = "auto", -- "auto" automatically picks from "small", "medium", or "large" -- based on the size of the screen -- "custom" allows you to specify a custom layout file --layoutFile = "CardLayout.lua" }, colors = { bgColor = colors.lightGray, headerBgColor = colors.red, headerColor = colors.white, subtitleBgColor = colors.red, subtitleColor = colors.white, footerBgColor = colors.red, footerColor = colors.white, productBgColors = { colors.blue, }, outOfStockQtyColor = colors.red, lowQtyColor = colors.orange, warningQtyColor = colors.yellow, normalQtyColor = colors.white, productNameColor = colors.white, outOfStockNameColor = colors.lightGray, priceColor = colors.lime, addressColor = colors.white, currencyTextColor = colors.white, currencyBgColors = { colors.green, colors.pink, colors.lightBlue, colors.yellow, }, catagoryTextColor = colors.white, categoryBgColors = { colors.pink, colors.orange, colors.lime, colors.lightBlue, }, activeCategoryColor = colors.black, }, palette = { [colors.black] = 0x181818, [colors.blue] = 0x182B52, [colors.purple] = 0x7E2553, [colors.green] = 0x008751, [colors.brown] = 0xAB5136, [colors.gray] = 0x565656, [colors.lightGray] = 0x9D9D9D, [colors.red] = 0xFF004C, [colors.orange] = 0xFFA300, [colors.yellow] = 0xFFEC23, [colors.lime] = 0x00A23C, [colors.cyan] = 0x29ADFF, [colors.magenta] = 0x82769C, [colors.pink] = 0xFF77A9, [colors.lightBlue] = 0x3D7EDB, [colors.white] = 0xECECEC }, }, terminalTheme = { colors = { titleTextColor = colors.white, titleBgColor = colors.blue, bgColor = colors.black, catagoryTextColor = colors.black, catagoryBgColor = colors.white, activeCatagoryBgColor = colors.lightGray, logTextColor = colors.white, configEditor = { bgColor = colors.lime, textColor = colors.black, buttonColor = colors.green, buttonTextColor = colors.white, inactiveButtonColor = colors.gray, inactiveButtonTextColor = colors.white, scrollbarBgColor = colors.white, scrollbarColor = colors.lightGray, inputBgColor = colors.white, inputTextColor = colors.black, errorBgColor = colors.red, errorTextColor = colors.white, toggleColor = colors.lightGray, toggleBgColor = colors.gray, toggleOnColor = colors.lime, toggleOffColor = colors.red, unsavedChangesColor = colors.blue, unsavedChangesTextColor = colors.white, modalBgColor = colors.white, modalTextColor = colors.black, modalBorderColor = colors.lightGray, }, productsEditor = { bgColor = colors.lightBlue, textColor = colors.black, buttonColor = colors.blue, buttonTextColor = colors.white, inactiveButtonColor = colors.gray, inactiveButtonTextColor = colors.white, scrollbarBgColor = colors.white, scrollbarColor = colors.lightGray, inputBgColor = colors.white, inputTextColor = colors.black, errorBgColor = colors.red, errorTextColor = colors.white, toggleColor = colors.lightGray, toggleBgColor = colors.gray, toggleOnColor = colors.lime, toggleOffColor = colors.red, unsavedChangesColor = colors.green, unsavedChangesTextColor = colors.white, modalBgColor = colors.white, modalTextColor = colors.black, modalBorderColor = colors.lightGray, } }, palette = { [colors.black] = 0x111111, [colors.blue] = 0x3366cc, [colors.purple] = 0xb266e5, [colors.green] = 0x57a64e, [colors.brown] = 0x7f664c, [colors.gray] = 0x4c4c4c, [colors.lightGray] = 0x999999, [colors.red] = 0xcc4c4c, [colors.orange] = 0xf2b233, [colors.yellow] = 0xdede6c, [colors.lime] = 0x7fcc19, [colors.cyan] = 0x4c99b2, [colors.magenta] = 0xe57fd8, [colors.pink] = 0xf2b2cc, [colors.lightBlue] = 0x99b2f2, [colors.white] = 0xf0f0f0 } }, sounds = { button = { name = "minecraft:block.note_block.hat", volume = 0.5, pitch = 1.1 }, purchase = { name = "minecraft:block.note_block.pling", volume = 0.5, pitch = 2 }, }, shopSync = { enabled = false, name = "Radon Shop", description = "A radon Shop", owner = nil, location = { x = nil, -- Coordinates are determined using GPS if none are set, but I'd recommend setting them y = nil, -- Make sure to use the exact position of the turtle/computer z = nil, disableGPSPosVerification = false, -- Complains in logs if the set position and GPS position doesn't match, this prevents people from setting the incorrect coordinates if GPS is available description = nil, dimension = "overworld" } }, peripherals = { monitor = nil, -- Monitor to display on, if not specified, will use the first monitor found modem = nil, -- Modem for inventories, if not specified, will use the first wired modem found speaker = nil, -- Speaker to play sounds on, if not specified, will use the first speaker found shopSyncModem = nil, -- Modem for ShopSync, if not specified, will use the first wireless modem found blinker = nil, -- Side that a redstone lamp or other redstone device is on -- Will be toggled on and off every 3 seconds to indicate that the shop is online exchangeChest = nil, outputChest = "self", -- Chest peripheral or self -- NOTE: Chest dropping is NYI in plethora 1.19, so do not use unless -- the output chest can be accessed }, hooks = { start = nil, -- function(version, config, products) prePurchase = nil, -- function(product, amount, refundAmount, transaction, transactionCurrency) returns continueTransaction, error, errorMessage purchase = nil, -- function(product, amount, refundAmount, transaction, transactionCurrency) failedPurchase = nil, -- function(transaction, transactionCurrency, product, errorMessage) programError = nil, -- function(err) blink = nil, -- function(blinkState) called every 3 seconds while shop is running }, exchange = { -- Not yet implemented enabled = true, node = "https://localhost:8000/" } } end preload["components.Toggle"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useBoundingBox = hooks.useBoundingBox local useInput = hooks.useInput local Rect = require("components.Rect") return Solyd.wrapComponent("Toggle", function(props) --print("Test") -- local canvas = Solyd.useContext("canvas") -- local canvas = useCanvas() if not props.inputState.value then props.inputState.value = false end local inputState, setInputState = Solyd.useState(props.inputState) local offColor = props.offColor local onColor = props.bg if inputState.value then offColor = props.bg onColor = props.onColor end local stateWidth = math.floor(props.width / 3) local stateMiddle = props.width - (stateWidth * 2) return { Rect { key = "toggle-off-" .. props.key, display = props.display, x = props.x, y = props.y, width = stateWidth, height = props.height, color = offColor }, Rect { key = "toggle-middle-" .. props.key, display = props.display, x = props.x + stateWidth, y = props.y, width = stateMiddle, height = props.height, color = props.color }, Rect { key = "toggle-on-" .. props.key, display = props.display, x = props.x + stateWidth + stateMiddle, y = props.y, width = stateWidth, height = props.height, color = onColor }, }, { -- canvas = canvas, aabb = useBoundingBox(props.x, props.y, props.width, props.height, function() inputState.value = not inputState.value setInputState(inputState) if props.onChange then props.onChange(inputState.value) end end), } end) end preload["components.TextInput"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useBoundingBox = hooks.useBoundingBox local useInput = hooks.useInput local BasicText = require("components.BasicText") local function numToHex(num) return "#" .. string.format("%06x", num) end return Solyd.wrapComponent("TextInput", function(props) --print("Test") -- local canvas = Solyd.useContext("canvas") -- local canvas = useCanvas() if props.inputState.value and type(props.inputState.value) == "number" then if props.type == "number" then props.inputState.value = tostring(props.inputState.value) elseif props.type == "colorpicker" then props.inputState.value = numToHex(props.inputState.value) end end if not props.inputState.value then props.inputState.value = "" end local inputState, setInputState = Solyd.useState(props.inputState) if not inputState.prevValue then inputState.prevValue = inputState.value --setInputState(inputState) end if not inputState.active then inputState.active = false --setInputState(inputState) end inputState.cursorY = props.y if not inputState.cursorPos then inputState.cursorPos = #inputState.value + 1 inputState.viewPort = 1 inputState.cursorX = props.x + inputState.cursorPos - 1 --setInputState(inputState) end function addChar(char) if props.type == "number" then if char == "." then if inputState.value:find("%.") then return end elseif char == "-" then if inputState.cursorPos ~= 1 or inputState.value:find("%-") then return end elseif char:match("%D") then return end elseif props.type == "colorpicker" then if char == "x" then if inputState.cursorPos == 1 and inputState.value:find("x") then return elseif inputState.cursorPos == 2 and (inputState.value:sub(1, 1) ~= "0" or inputState.value:find("x")) then return elseif inputState.cursorPos >= 3 then return end elseif char == "#" then if inputState.cursorPos == 1 and inputState.value:find("#") then return elseif inputState.cursorPos >= 2 then return end elseif char:match("%X") then return else if inputState.cursorPos == 1 and (inputState.value:find("x") or inputState.value:find("#")) then return elseif inputState.cursorPos == 2 and (inputState.value:sub(2, 2) == "x") then return end end end inputState.value = inputState.value:sub(1, inputState.cursorPos-1) .. char .. inputState.value:sub(inputState.cursorPos) inputState.cursorPos = inputState.cursorPos + 1 inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort if inputState.cursorPos > inputState.viewPort + props.width - 1 then inputState.viewPort = inputState.viewPort + 1 end setInputState(inputState) end return BasicText { display = props.display, align = props.align, text = inputState.value:sub(inputState.viewPort, inputState.viewPort + props.width - 1), x = props.x, y = props.y, bg = props.bg, color = props.color, width = props.width, }, ---@diagnostic disable-next-line: redundant-return-value { -- canvas = canvas, aabb = useBoundingBox((props.x*2)-1, (props.y*3)-2, (props.width)*2, (props.height)*3, function() -- onClick inputState.active = true inputState.viewPort = math.max(1, inputState.cursorPos - props.width + 1) inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort setInputState(inputState) end, function(dir) -- onScroll if props.type == "number" and inputState.value and inputState.value ~= "" then inputState.value = tostring(tonumber(inputState.value) - dir) setInputState(inputState) if props.onChange then if props.type == "number" and inputState.value ~= nil then props.onChange(tonumber(inputState.value)) else props.onChange(inputState.value) end end return true end end), input = useInput(props.x, props.y, props.width, props.height, inputState, addChar, function(key, held) if key == keys.backspace then if inputState.cursorPos > 1 then inputState.value = inputState.value:sub(1, inputState.cursorPos-2) .. inputState.value:sub(inputState.cursorPos) inputState.cursorPos = inputState.cursorPos - 1 inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort if inputState.cursorPos < inputState.viewPort then inputState.viewPort = inputState.viewPort - 1 end setInputState(inputState) end elseif key == keys.delete then if inputState.cursorPos < #inputState.value + 1 then inputState.value = inputState.value:sub(1, inputState.cursorPos-1) .. inputState.value:sub(inputState.cursorPos+1) setInputState(inputState) end elseif key == keys.enter then inputState.active = false inputState.viewPort = 1 if inputState.value ~= inputState.prevValue then if props.onChange then if props.type == "number" and inputState.value ~= nil then props.onChange(tonumber(inputState.value)) elseif props.type == "colorpicker" and inputState.value ~= nil then -- Convert hex to number local hex = inputState.value hex = hex:gsub("#", "") hex = hex:gsub("x", "") props.onChange(tonumber(hex, 16)) else props.onChange(inputState.value) end end inputState.prevValue = inputState.value end setInputState(inputState) elseif key == keys.left then if inputState.cursorPos > 1 then inputState.cursorPos = inputState.cursorPos - 1 if inputState.cursorPos < inputState.viewPort then inputState.viewPort = inputState.viewPort - 1 end inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort setInputState(inputState) end elseif key == keys.right then if inputState.cursorPos < #inputState.value + 1 then inputState.cursorPos = inputState.cursorPos + 1 if inputState.cursorPos > inputState.viewPort + props.width - 1 then inputState.viewPort = inputState.viewPort + 1 end inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort setInputState(inputState) end elseif key == keys.home then inputState.cursorPos = 1 inputState.viewPort = 1 inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort setInputState(inputState) elseif key == keys["end"] then inputState.cursorPos = #inputState.value + 1 inputState.viewPort = math.max(1, inputState.cursorPos - props.width + 1) inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort setInputState(inputState) end end, function() -- On blur if inputState.value ~= inputState.prevValue then if props.onChange then if props.type == "number" and inputState.value ~= nil then props.onChange(tonumber(inputState.value)) else props.onChange(inputState.value) end end inputState.prevValue = inputState.value end setInputState(inputState) end, function(contents) -- On paste if props.type == "number" then if contents:sub(1, 1) == "-" then contents = "-" .. contents:gsub("[^%d]", "") else contents = contents:gsub("[^%d]", "") end elseif props.type == "colorpicker" then if contents:sub(1, 1) ~= "#" or contents:sub(1,2):find("x") then contents = "#" .. contents:gsub("[^%x]", "") else contents:gsub("[^%x]", "") end end inputState.value = inputState.value:sub(1, inputState.cursorPos-1) .. contents .. inputState.value:sub(inputState.cursorPos) inputState.cursorPos = inputState.cursorPos + #contents if inputState.cursorPos > inputState.viewPort + props.width - 1 then inputState.viewPort = inputState.cursorPos - props.width + 2 end inputState.cursorX = props.x + inputState.cursorPos - inputState.viewPort setInputState(inputState) end ), } end) end preload["components.Sprite"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useCanvas = hooks.useCanvas return Solyd.wrapComponent("Sprite", function(props) local canvas = useCanvas(props.display, props.sprite.width, props.sprite.height) Solyd.useEffect(function() -- local s, x, y = props.sprite, props.x, props.y canvas:drawCanvas(props.sprite, props.x, props.y, props.remapFrom, props.remapTo) -- canvas:drawCanvasRotated(props.sprite, props.x, props.y, props.angle or 0) return function() canvas:markCanvas(props.sprite, props.x, props.y) -- canvas:markCanvasRotated(props.sprite, props.x, props.y, props.angle or 0) end end, { props.sprite, props.x, props.y, props.remapFrom, props.remapTo }) end) end preload["components.SmolText"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useCanvas = hooks.useCanvas local smolFont = require("fonts.smolfont") return Solyd.wrapComponent("SmolText", function(props) local fw = props.width or smolFont:getWidth(props.text)+2 local bgHeight = 3 local canvas = useCanvas(props.display, fw, smolFont.height+bgHeight)--Solyd.useContext("canvas") Solyd.useEffect(function() if props.bg then for x = 1, fw do for y = 1, smolFont.height+bgHeight do canvas:setPixel(x, y, props.bg) end end end local cx = 0 if props.width then if props.align == "center" then cx = math.floor((props.width - smolFont:getWidth(props.text)) / 2) elseif props.align == "right" then cx = props.width - smolFont:getWidth(props.text) - 2 end end smolFont:write(canvas, props.text, 2 + cx, bgHeight+1, props.color or colors.white) return function() canvas:markRect(1, 1, fw, smolFont.height+bgHeight) end end, { canvas, props.display, props.align, props.text, props.color, props.bg, fw }) local x = props.right and props.x-canvas.width+1 or props.x return nil, { canvas = { canvas, x, props.y } } end) end preload["components.SmolButton"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useBoundingBox = hooks.useBoundingBox local SmolText = require("components.SmolText") local smolFont = require("fonts.smolfont") return Solyd.wrapComponent("SmolButton", function(props) -- local canvas = Solyd.useContext("canvas") -- local canvas = useCanvas() return SmolText { display = props.display, align = props.align, text = props.text, x = props.x, y = props.y, bg = props.bg, color = props.color, width = props.width, }, { -- canvas = canvas, aabb = useBoundingBox(props.x, props.y, props.width or smolFont:getWidth(props.text), smolFont.height+3, props.onClick), } end) end preload["components.Select"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useBoundingBox = hooks.useBoundingBox local useInput = hooks.useInput local Rect = require("components.Rect") local BasicText = require("components.BasicText") local BasicButton = require("components.BasicButton") local Scrollbar = require("components.Scrollbar") return Solyd.wrapComponent("Select", function(props) --print("Test") -- local canvas = Solyd.useContext("canvas") -- local canvas = useCanvas() local modal = Solyd.useContext("modal") local modalElements = modal[0] local setModalElements = modal[1] -- if not props.inputState.value then -- props.inputState.value = props.options[1].value -- end if not props.inputState.active then props.inputState.active = false end if not props.inputState.scroll then props.inputState.scroll = 0 end if not props.inputState.maxScroll then props.inputState.maxScroll = math.max(#props.options - props.height, 0) end local inputState, setInputState = Solyd.useState(props.inputState) local newMaxScroll = math.max(#props.options - props.height, 0) if newMaxScroll ~= inputState.maxScroll then inputState.maxScroll = newMaxScroll setInputState(inputState) end local elements = {} local elementHeight = 3 local xOffset = -1 if inputState.active and modalElements then xOffset = 3 for i = inputState.scroll+1, inputState.scroll + math.min(#props.options - inputState.scroll, props.height) do table.insert(modalElements, BasicButton { key = "select-option-" .. props.key .. "-" .. i, display = props.display, text = props.options[i].text, x = props.x + 2, y = props.y + (i - 1 - inputState.scroll), width = props.width - 3, height = props.height, bg = props.bg, color = props.color, onClick = function() inputState.value = props.options[i].value inputState.active = false setInputState(inputState) for j = 1, #modalElements do table.remove(modalElements, 1) end setModalElements(modalElements) if props.onChange then props.onChange(inputState.value) end end, onScroll = function(dir) if dir <= -1 then inputState.scroll = math.max(inputState.scroll + dir, 0) setInputState(inputState) elseif dir >= 1 then inputState.scroll = math.min(inputState.scroll + dir, inputState.maxScroll) setInputState(inputState) end return true end }) end elementHeight = math.min(#props.options - inputState.scroll, props.height) * 3 if #props.options > props.height then table.insert(modalElements, Scrollbar { key = "select-scrollbar-" .. props.key, display = props.display, x = (props.x + props.width - 1)*2 - 1, y = (props.y*3)-2, width = 2, height = props.height * 3, areaHeight = props.height * 3, scroll = inputState.scroll * 3, maxScroll = inputState.maxScroll * 3, color = props.scrollbarColor, bg = props.bg, }) end setModalElements(modalElements) else local valueText = inputState.value for i = 1, #props.options do if props.options[i].value == inputState.value then valueText = props.options[i].text end end table.insert(elements, BasicText { key = "select-value-" .. props.key, display = props.display, text = valueText or "", x = props.x+2, y = props.y, width = props.width-2, height = 1, color = props.color, bg = props.bg, }) if setModalElements then setModalElements({}) end end local arrow = "> " if inputState.active then arrow = "v " end table.insert(elements, BasicText { key = "select-arrow-" .. arrow .. "-" .. props.key, display = props.display, text = arrow, x = props.x, y = props.y, width = 2, height = 1, color = props.toggleColor, bg = props.bg, }) return elements, { -- canvas = canvas, aabb = useBoundingBox((props.x*2)+xOffset, (props.y*3)-1, props.width*2, elementHeight, function() inputState.active = true setInputState(inputState) end, function(dir) -- onScroll if inputState.active then if dir <= -1 then inputState.scroll = math.max(inputState.scroll + dir, 0) setInputState(inputState) elseif dir >= 1 then inputState.scroll = math.min(inputState.scroll + dir, inputState.maxScroll) setInputState(inputState) end return true end end), input = useInput((props.x*2)+xOffset, (props.y*3)-1, props.width*2, elementHeight, inputState, function(char) -- Select based on first letter setInputState(inputState) end, function(key, held) if key == keys.backspace then inputState.value = nil setInputState(inputState) elseif key == keys.delete then inputState.value = nil setInputState(inputState) elseif key == keys.enter then inputState.active = false if props.onChange then props.onChange(inputState.value) end setInputState(inputState) for i = 1, #modalElements do table.remove(modalElements, 1) end setModalElements(modalElements) elseif key == keys.up then inputState.scroll = math.max(inputState.scroll - 1, 0) setInputState(inputState) elseif key == keys.down then inputState.scroll = math.min(inputState.scroll + 1, inputState.maxScroll) setInputState(inputState) end end, function() -- On blur inputState.active = false setInputState(inputState) for i = 1, #modalElements do table.remove(modalElements, 1) end setModalElements(modalElements) end ), } end) end preload["components.Scrollbar"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local Rect = require("components.Rect") local useCanvas = hooks.useCanvas return Solyd.wrapComponent("Scrollbar", function(props) local canvas = useCanvas(props.display, props.width, props.height)--Solyd.useContext("canvas") local elements = {} local scrollBarSize = math.max( 0, math.floor(props.height * (props.areaHeight / (props.maxScroll + props.areaHeight))) ) local scrollProgress = props.scroll / props.maxScroll local scrollSpace = props.height - scrollBarSize local scrollBarPos = math.max(0, math.min( math.floor(props.height - scrollBarSize), math.floor(scrollSpace * scrollProgress) ) ) table.insert(elements, Rect { key = "scrollbar-bg-" .. props.key, display = props.display, x = props.x, y = props.y, width = props.width, height = props.height, color = props.bg, }) table.insert(elements, Rect { key = "scrollbar-" .. props.key, display = props.display, x = props.x, y = props.y + scrollBarPos, width = props.width, height = scrollBarSize, color = props.color, }) return elements, { canvas = { canvas, props.x, props.y } } end) end preload["components.RenderCanvas"] = function(...) local Solyd = require("modules.solyd") local Util = require("util.misc") ---@param props { canvas: PixelCanvas, x: integer, y: integer, remap: table } return Solyd.wrapComponent("RenderCanvas", function(props) local remapped = Solyd.useMemo(function() if props.remap then return props.canvas:clone():mapColors(props.remap) else return props.canvas end end, {props.canvas, props.remap}) return {}, { canvas = { remapped, props.x, props.y } } end) end preload["components.Rect"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useCanvas = hooks.useCanvas return Solyd.wrapComponent("Rect", function(props) local canvas = useCanvas(props.display, props.width, props.height)--Solyd.useContext("canvas") Solyd.useEffect(function() if props.color then for x = 1, props.width do for y = 1, props.height do canvas:setPixel(x, y, props.color) end end end return function() canvas:markRect(1, 1, props.width, props.height) end end, { canvas, props.display, props.x, props.y, props.width, props.height, props.color }) return nil, { canvas = { canvas, props.x, props.y } } end) end preload["components.Modal"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useBoundingBox = hooks.useBoundingBox local useInput = hooks.useInput local Rect = require("components.Rect") local BasicText = require("components.BasicText") local BasicButton = require("components.BasicButton") local Scrollbar = require("components.Scrollbar") return Solyd.wrapComponent("Modal", function(props) --print("Test") -- local canvas = Solyd.useContext("canvas") -- local canvas = useCanvas() local modal = Solyd.useContext("modal") if not modal[0] then modal[0], modal[1] = Solyd.useState({}) end local modalElements = modal[0] local setModalElements = modal[1] local elements = {} for i = 1, #modalElements do table.insert(elements, modalElements[i]) end return elements, {} end) end preload["components.Logs"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local BasicText = require("components.BasicText") local useTextCanvas = hooks.useTextCanvas return Solyd.wrapComponent("Logs", function(props) local canvas = useTextCanvas(props.display, props.width*2, props.height*3) local texts = {} local logMessageY = props.height for i = 1, math.min(#props.logs, props.height) do local logMessage = "[" .. textutils.formatTime(props.logs[i].time, true) .. "] " .. props.logs[i].text local numLines = math.ceil(#logMessage / props.width) if logMessageY - numLines + 1 < 0 then break end for j = 1, numLines do local line = logMessage:sub((j - 1) * props.width + 1, j * props.width) table.insert(texts, BasicText { key = "logs-"..tostring(i).."-"..tostring(j), display = props.display, align = "left", text = line, x = 1, y = logMessageY - numLines + j + 1, color = props.color, bg = props.bg, }) end logMessageY = logMessageY - numLines end return texts, { canvas = { canvas, props.x*2-1, props.y*3-2 } } end) end preload["components.Flex"] = function(...) local _ = require("util.score") local Solyd = require("modules.solyd") ---@param props { x: integer, y: integer, width: integer, children: SolydElement[] } return Solyd.wrapComponent("Flex", function(props) local remainingWidth = props.width local children, flexElCount, flexCount = {}, 0, 0 for i, child in ipairs(props.children) do if type(child) == "table" then if child.props.width then table.insert(children, { element = child, width = child.props.width }) remainingWidth = remainingWidth - child.props.width - (i > 1 and 1 or 0) else local flex = child.props.flex or 1 table.insert(children, { element = child, flex = flex }) flexCount = flexCount + flex flexElCount = flexElCount + 1 end end end local x = props.x local flexWidth = math.ceil((remainingWidth - flexElCount) / flexCount) remainingWidth = props.width for i, child in ipairs(children) do local width = math.min(remainingWidth, child.width or flexWidth*child.flex) child.element.props.width = width child.element.props.x = x child.element.props.y = props.y x = x + width + 1 remainingWidth = remainingWidth - width - (i > 1 and 1 or 0) end return _.map(children, function(el) return el.element end) end) end preload["components.ConfigEditor"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local Alert = require("components.Alert") local Scrollbar = require("components.Scrollbar") local BasicText = require("components.BasicText") local BasicButton = require("components.BasicButton") local TextInput = require("components.TextInput") local Select = require("components.Select") local Toggle = require("components.Toggle") local Rect = require("components.Rect") local configHelpers = require("util.configHelpers") local ConfigValidator = require("core.ConfigValidator") local useTextCanvas = hooks.useTextCanvas local schemas = require("core.schemas") local score = require("util.score") return Solyd.wrapComponent("ConfigEditor", function(props) local canvas = useTextCanvas(props.display, props.width*2, props.height*3) local theme = props.theme local modal = Solyd.useContext("modal") local modalElements = modal[0] local setModalElements = modal[1] local configDiffs, setConfigDiffs = Solyd.useState({}) local arrayAdds, setArrayAdds = Solyd.useState({}) local arrayRemoves, setArrayRemoves = Solyd.useState({}) local saveModalOpen, setSaveModalOpen = Solyd.useState(false) local updates, setUpdates = Solyd.useState(0) local errors, setErrors = Solyd.useState(props.errors or {}) if not props.terminalState.configPath then props.terminalState.configPath = "" end local subConfig = props.config local subSchema = props.schema local paths = {} local unsavedChanges = false for k,v in pairs(configDiffs) do unsavedChanges = true break end for k,v in pairs(arrayAdds) do unsavedChanges = true break end for k,v in pairs(arrayRemoves) do unsavedChanges = true break end if arrayAdds[props.terminalState.configPath] or arrayAdds["." .. props.terminalState.configPath] then subConfig = {} end for path in props.terminalState.configPath:gmatch("([^%[?%]?%.?]+)") do if path:match("%d+") then path = tonumber(path) or path end if subConfig[path] then subConfig = subConfig[path] else --subConfig[path] = {} --subConfig = subConfig[path] subConfig = {} end if subSchema[path] then subSchema = subSchema[path] if subSchema == "sound" or subSchema == "sound?" then subSchema = schemas.soundSchema end elseif subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then subSchema = subSchema.__entry else --subSchema[path] = {} --subSchema = subSchema[path] subSchema = {} end table.insert(paths, path) end local elements = {} table.insert(elements, Rect { key = "bg", display = props.display, x = (props.x*2)-1, y = (props.y*3)-2, width = props.width*2, height = props.height*3, color = theme.bgColor, }) if errors and #errors > 0 then table.insert(elements, BasicButton { key = "save-error", display = props.display, x = props.x, y = props.y, text = "Save(!)", onClick = function() -- Open modal to confirm setSaveModalOpen(true) end, bg = theme.errorBgColor, color = theme.errorTextColor, }) elseif unsavedChanges then table.insert(elements, BasicButton { key = "save-unsaved", display = props.display, x = props.x, y = props.y, text = " Save ", onClick = function() -- Open modal to confirm setSaveModalOpen(true) end, bg = theme.unsavedChangesColor, color = theme.unsavedChangesTextColor, }) else table.insert(elements, BasicButton { key = "save-disabled", display = props.display, x = props.x, y = props.y, text = " Save ", onClick = function() -- Do nothing, nothing to save end, bg = theme.inactiveButtonColor, color = theme.inactiveButtonTextColor, }) end if #paths > 0 then table.insert(elements, BasicButton { key = "back", display = props.display, x = props.x + 8, y = props.y, text = " Back ", onClick = function() if #paths > 0 then props.terminalState.scroll = 0 table.remove(paths) props.terminalState.configPath = table.concat(paths, ".") end end, bg = theme.buttonColor, color = theme.buttonTextColor, }) table.insert(elements, BasicText { key = "path", display = props.display, x = props.x + 15, y = props.y, text = props.terminalState.configPath, bg = theme.buttonColor, color = theme.buttonTextColor, }) end local elementY = 0 local numKeys = 0 if type(subSchema) == "table" then if not subSchema.__type or true then local fields = subSchema local xOffset = 0 local isArray = false local arrayLabel = nil if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then fields = score.copyDeep(subConfig) numKeys = numKeys + 1 xOffset = 1 isArray = true if subSchema.__label then arrayLabel = subSchema.__label end end for k, _ in pairs(fields) do numKeys = numKeys + 1 end if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then while not fields[numKeys] and arrayAdds[props.terminalState.configPath .. "." .. tostring(numKeys)] do fields[numKeys] = {} numKeys = numKeys + 1 end end props.terminalState.maxScroll = math.max(0, (numKeys*3) - props.height) local lastSelect = false local editorFields = {} for k, _ in pairs(fields) do table.insert(editorFields, k) end -- Sort editorFields alphabetically table.sort(editorFields, function(a, b) -- Return whether the string a should come before b alphabetically if type(a) == "number" and type(b) == "number" then return a < b elseif type(a) == "number" then return true elseif type(b) == "number" then return false else return a < b end end) for _, k in pairs(editorFields) do v = fields[k] lastSelect = false local textY = props.y + 1 + elementY - props.terminalState.scroll local fullPath = props.terminalState.configPath .. "." .. tostring(k) local buttonColor = theme.buttonColor local buttonTextColor = theme.buttonTextColor if arrayRemoves[fullPath] then buttonColor = theme.inactiveButtonColor buttonTextColor = theme.inactiveButtonTextColor if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then v = subSchema.__entry k = tostring(k) if textY >= props.y + 1 and textY <= props.y + props.height then table.insert(elements, BasicButton { key = "restore-" .. k, display = props.display, x = props.x, y = textY, text = "o", color = buttonColor, bg = buttonTextColor, onClick = function() arrayRemoves[fullPath] = nil setArrayRemoves(arrayRemoves) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end }) end if textY + 1 >= props.y + 1 and textY + 1 <= props.y + props.height then table.insert(elements, BasicButton { key = "restore-spacer-" .. k, display = props.display, x = props.x, y = textY+1, text = " ", color = buttonColor, bg = buttonTextColor, onClick = function() arrayRemoves[fullPath] = nil setArrayRemoves(arrayRemoves) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end }) end end else if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then v = subSchema.__entry k = tostring(k) if textY >= props.y + 1 and textY <= props.y + props.height then table.insert(elements, BasicButton { key = "delete-" .. k, display = props.display, x = props.x, y = textY, text = "x", color = theme.errorTextColor, bg = theme.errorBgColor, onClick = function() arrayRemoves[fullPath] = true setArrayRemoves(arrayRemoves) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end }) end if textY + 1 >= props.y + 1 and textY + 1 <= props.y + props.height then table.insert(elements, BasicButton { key = "delete-spacer-" .. k, display = props.display, x = props.x, y = textY+1, text = " ", color = theme.errorTextColor, bg = theme.errorBgColor, onClick = function() arrayRemoves[fullPath] = true setArrayRemoves(arrayRemoves) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end }) end end end if errors and #errors > 0 then for i = 1, #errors do -- error on config.branding.title will trigger for config.branding local pathMatch = props.errorPrefix .. "." .. fullPath if fullPath:sub(1,1) == "." then pathMatch = props.errorPrefix .. fullPath end local errorPath = errors[i].path:gsub("%[(%d+)%]", "%.%1") if errorPath == pathMatch or errorPath:sub(1, #pathMatch + 1) == pathMatch .. "." then buttonColor = theme.errorBgColor buttonTextColor = theme.errorTextColor end end end if type(v) == "table" or type(v) == "string" and v:sub(1,5) == "sound" then local label = k if isArray and arrayLabel then local labelPath = fullPath .. "." .. arrayLabel if labelPath:sub(1,1) == "." then labelPath = labelPath:sub(2) end if type(v) == "table" and configDiffs[labelPath] then label = configDiffs[labelPath] elseif type(v) == "table" and subConfig[tonumber(k)] and subConfig[tonumber(k)][arrayLabel] then label = subConfig[tonumber(k)][arrayLabel] end end --print(textutils.serialize(configDiffs)) if textY >= props.y + 1 and textY <= props.y + props.height then table.insert(elements, BasicButton { key = "config-"..k, display = props.display, align = "left", text = " " .. label .. " ", x = props.x + xOffset, y = textY, color = buttonTextColor, bg = buttonColor, width = math.min(#label+2, props.width - 2) - xOffset, onClick = function() props.terminalState.scroll = 0 table.insert(paths, k) props.terminalState.configPath = table.concat(paths, ".") end, }) end textY = textY + 1 if textY >= props.y + 1 and textY <= props.y + props.height then table.insert(elements, BasicButton { key = "configarrow-"..k, display = props.display, align = "center", text = "-->", x = props.x + xOffset, y = textY, color = buttonTextColor, bg = buttonColor, width = math.min(#label+2, props.width - 2) - xOffset, onClick = function() props.terminalState.scroll = 0 table.insert(paths, k) props.terminalState.configPath = table.concat(paths, ".") end, }) end elseif type(v) == "string" then _, _, typeDef, typeName = v:find("^(%w+<.+>)%??: (.+)$") if textY >= props.y + 1 and textY <= props.y + props.height then local nameText = k .. ": " .. (typeName or v) if fullPath:find("palette") then local field = configHelpers.getColorName(k) nameText = field .. ": color code" end table.insert(elements, BasicButton { key = "config-key-"..k, display = props.display, align = "left", text = nameText, x = props.x + xOffset, y = textY, color = buttonTextColor, bg = buttonColor, width = props.width - 1 - xOffset, onClick = function() -- props.terminalState.scroll = 0 -- table.insert(paths, k) -- props.terminalState.configPath = table.concat(paths, ".") end, }) end textY = textY + 1 if textY >= props.y + 1 and textY <= props.y + props.height then if v:sub(1, 6) == "string" or v:sub(1,5) == "regex" or v:sub(1,4) == "file" or v:sub(1,5) == "modem" or v:sub(1,7) == "speaker" or v:sub(1,5) == "chest" then local inputStateValue = configDiffs[fullPath] or subConfig[k] or subConfig[tonumber(k)] if inputStateValue == "%nil%" then inputStateValue = nil end table.insert(elements, TextInput { key = "config-value-"..fullPath, display = props.display, align = "left", x = props.x + xOffset, y = textY, color = theme.inputTextColor, bg = theme.inputBgColor, height = 1, width = props.width - 1 - xOffset, inputState = { value = configDiffs[fullPath] or subConfig[k] }, onChange = function(value) if value == "" or value == nil then value = "%nil%" end configDiffs[fullPath] = value setConfigDiffs(configDiffs) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end, }) elseif v:sub(1, 7) == "boolean" then local toggleStartValue = configDiffs[fullPath] if toggleStartValue == nil then toggleStartValue = subConfig[k] end table.insert(elements, Rect { key = "config-value-" .. fullPath .. "-bg", display = props.display, x = (props.x*2)-1 + xOffset*2, y = (textY*3)-2, color = buttonColor, width = (props.width * 2) - 2, height = 3, }) table.insert(elements, Toggle { key = "config-value-"..k, display = props.display, x = (props.x*2)-1 + xOffset, y = (textY*3)-2, color = theme.toggleColor, bg = theme.toggleBgColor, onColor = theme.toggleOnColor, offColor = theme.toggleOffColor, width = 2 * 6, height = 2, inputState = { value = toggleStartValue }, onChange = function(value) configDiffs[fullPath] = value setConfigDiffs(configDiffs) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end, }) elseif v:sub(1, 6) == "number" then table.insert(elements, Rect { key = "config-value-" .. fullPath .. "-bg", display = props.display, x = (props.x*2)-1 + 12*2 + xOffset*2, y = (textY*3)-2, color = buttonColor, width = (props.width * 2) - 2 - 12*2 - xOffset*2, height = 3, }) local inputType = "number" if fullPath:find("palette") then inputType = "colorpicker" end table.insert(elements, TextInput { key = "config-value-"..k, display = props.display, type = inputType, align = "left", x = props.x + xOffset, y = textY, color = theme.inputTextColor, bg = theme.inputBgColor, height = 1, width = 12, inputState = { value = configDiffs[fullPath] or subConfig[k] or subConfig[tonumber(k)] }, onChange = function(value) configDiffs[fullPath] = value setConfigDiffs(configDiffs) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end, }) elseif v:sub(1, 5) == "color" then lastSelect = true table.insert(elements, Select { key = "config-value-"..fullPath, display = props.display, x = props.x + xOffset, y = textY, color = theme.inputTextColor, bg = theme.inputBgColor, scrollbarColor = theme.scrollbarColor, toggleColor = theme.toggleColor, height = props.y + props.height - textY, width = props.width - 1 - xOffset, inputState = { value = configDiffs[fullPath] or subConfig[k] }, options = { { value = colors.black, text = "Black" }, { value = colors.blue, text = "Blue" }, { value = colors.purple, text = "Purple" }, { value = colors.green, text = "Green" }, { value = colors.brown, text = "Brown" }, { value = colors.gray, text = "Gray" }, { value = colors.lightGray, text = "Light Gray" }, { value = colors.red, text = "Red" }, { value = colors.orange, text = "Orange" }, { value = colors.yellow, text = "Yellow" }, { value = colors.lime, text = "Lime" }, { value = colors.cyan, text = "Cyan" }, { value = colors.magenta, text = "Magenta" }, { value = colors.pink, text = "Pink" }, { value = colors.lightBlue, text = "Light Blue" }, { value = colors.white, text = "White" }, }, onChange = function(value) configDiffs[fullPath] = value setConfigDiffs(configDiffs) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end, }) elseif typeDef and typeDef:sub(1, 5) == "enum<" and typeDef:sub(-1) == ">" then lastSelect = true local enum = typeDef:sub(6, -2) local options = {} for enumValue in enum:gmatch("[^|]+") do enumValue = enumValue:sub(enumValue:find("'(.*)'")):sub(2, -2) table.insert(options, { value = enumValue, text = enumValue }) end table.insert(elements, Select { key = "config-value-"..fullPath, display = props.display, x = props.x + xOffset, y = textY, color = theme.inputTextColor, bg = theme.inputBgColor, scrollbarColor = theme.scrollbarColor, toggleColor = theme.toggleColor, height = props.y + props.height - textY, width = props.width - 1 - xOffset, inputState = { value = configDiffs[fullPath] or subConfig[k] }, options = options, onChange = function(value) configDiffs[fullPath] = value setConfigDiffs(configDiffs) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end, }) end end end elementY = elementY + 3 if textY + 1 > props.y + props.height then break end end if subSchema.__type and subSchema.__type:sub(1,5) == "array" and subSchema.__entry then if not subSchema.__max or (numKeys-1) < subSchema.__max then -- Show add new button -- (With red exclamation if min not met) local minMet = not subSchema.__min or (numKeys-1) >= subSchema.__min local buttonText = "Add New" if not minMet then buttonText = buttonText .. " (Needs " .. tostring(subSchema.__min - numKeys + 1) .. " more)" end local buttonColor = minMet and theme.buttonColor or theme.errorBgColor local buttonTextColor = minMet and theme.buttonTextColor or theme.errorTextColor textY = props.y + 1 + elementY - props.terminalState.scroll if textY >= props.y + 1 and textY <= props.y + props.height then table.insert(elements, BasicButton { key = "config-add-"..props.terminalState.configPath, display = props.display, x = props.x, y = textY, align = "center", color = buttonTextColor, bg = buttonColor, height = 1, width = #buttonText + 2, text = buttonText, onClick = function() arrayAdds[props.terminalState.configPath .. "." .. tostring(numKeys)] = subSchema.__entry setArrayAdds(arrayAdds) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end, }) end if textY+1 >= props.y + 1 and textY+1 <= props.y + props.height then table.insert(elements, BasicButton { key = "config-add2-"..props.terminalState.configPath, display = props.display, x = props.x, y = textY+1, align = "center", color = buttonTextColor, bg = buttonColor, height = 1, width = #buttonText + 2, text = "", onClick = function() arrayAdds[props.terminalState.configPath .. "." .. tostring(numKeys)] = true setArrayAdds(arrayAdds) setUpdates(updates + 1) local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) setErrors(ConfigValidator.validate(newConfig, props.schema, props.errorPrefix)) end, }) end end end if lastSelect then props.terminalState.maxScroll = props.terminalState.maxScroll + 3 end if props.terminalState.maxScroll > 0 then table.insert(elements, Scrollbar { key = "sb", display = props.display, x = (props.x + props.width - 1)*2 - 1, y = (props.y*3)-2, width = 2, height = props.height * 3, areaHeight = props.height * 3, scroll = props.terminalState.scroll * 3, maxScroll = props.terminalState.maxScroll * 3, color = theme.scrollbarColor, bg = theme.scrollbarBgColor, }) end elseif subSchema.__type == "array" then -- Dealing with an array end end if saveModalOpen then local modalWidth = math.min(props.width, 30) local modalHeight = 6 local modalText = "Are you sure\nyou want to save?" if errors and #errors > 0 then modalText = "Your config is incomplete,\nare you sure you want to\nsave?" end table.insert(elements, Alert { key = "save-modal", display = props.display, x = math.floor(props.x + (props.width/2) - (modalWidth/2)), y = math.floor(props.y + (props.height/2) - (modalHeight/2)), align = "center", width = modalWidth, height = modalHeight, text = modalText, bg = theme.modalBgColor, color = theme.modalTextColor, buttonColor = theme.inactiveButtonColor, buttonTextColor = theme.inactiveButtonTextColor, borderColor = theme.modalBorderColor, onConfirm = function() local newConfig = configHelpers.getNewConfig(props.config, configDiffs, arrayAdds, arrayRemoves) local newEditConfig = configHelpers.getNewConfig(props.editConfig, configDiffs, arrayAdds, arrayRemoves) setSaveModalOpen(false) setArrayAdds({}) setArrayRemoves({}) setConfigDiffs({}) setUpdates(updates + 1) if props.onSave then props.onSave(newConfig, newEditConfig) end end, onCancel = function() setSaveModalOpen(false) end, }) end -- local logMessageY = props.height -- for i = 1, math.min(#props.errors, props.height) do -- local logMessage = "[" .. props.errors[i].path .. "] " .. props.errors[i].error -- local numLines = math.ceil(#logMessage / props.width) -- if logMessageY - numLines + 1 < 0 then -- break -- end -- for j = 1, numLines do -- local line = logMessage:sub((j - 1) * props.width + 1, j * props.width) -- table.insert(elements, BasicText { -- key = "errors-"..tostring(i).."-"..tostring(j), -- display = props.display, -- align = "left", -- text = line, -- x = 1, -- y = logMessageY - numLines + j + 1, -- color = props.buttonTextColor, -- bg = props.buttonBgColor, -- }) -- end -- logMessageY = logMessageY - numLines -- end return elements, { canvas = { canvas, props.x*2-1, props.y*3-2 } } end) end preload["components.Button"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useBoundingBox = hooks.useBoundingBox local BigText = require("components.BigText") local bigFont = require("fonts.bigfont") return Solyd.wrapComponent("Button", function(props) -- local canvas = Solyd.useContext("canvas") -- local canvas = useCanvas() return BigText { display = props.display, align = props.align, text = props.text, x = props.x, y = props.y, bg = props.bg, color = props.color, width = props.width, }, { -- canvas = canvas, aabb = useBoundingBox(props.x, props.y, props.width or bigFont:getWidth(props.text), bigFont.height+6, props.onClick), } end) end preload["components.BigText"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useCanvas = hooks.useCanvas local bigFont = require("fonts.bigfont") return Solyd.wrapComponent("BigText", function(props) local fw = props.width or bigFont:getWidth(props.text)+2 local bgHeight = 6 local canvas = useCanvas(props.display, fw, bigFont.height+bgHeight)--Solyd.useContext("canvas") Solyd.useEffect(function() if props.bg then for x = 1, fw do for y = 1, bigFont.height+bgHeight do canvas:setPixel(x, y, props.bg) end end end local cx = 0 if props.width then if props.align == "center" then cx = math.floor((props.width - bigFont:getWidth(props.text)) / 2) elseif props.align == "right" then cx = props.width - bigFont:getWidth(props.text) - 2 end end bigFont:write(canvas, props.text, math.max(2, cx), bgHeight-3, props.color or colors.white) return function() canvas:markRect(1, 1, fw, bigFont.height+bgHeight) end end, { canvas, props.display, props.align, props.text, props.color, props.bg, fw }) local x = props.right and props.x-canvas.width+1 or props.x return nil, { canvas = { canvas, x, props.y } } end) end preload["components.BasicText"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useTextCanvas = hooks.useTextCanvas return Solyd.wrapComponent("BasicText", function(props) local fw = props.width or #props.text local canvas = useTextCanvas(props.display, fw*2, 1) Solyd.useEffect(function() local text = props.text if props.width then if props.align == "center" then local leftPad = math.floor((props.width - #text) / 2) local rightPad = props.width - #text - leftPad text = string.rep(" ", leftPad) .. text .. string.rep(" ", rightPad) elseif props.align == "right" then text = string.rep(" ", props.width - #text) .. text else text = text .. string.rep(" ", props.width - #text) end end if props.width and #text > props.width then text = text:sub(1, props.width) end canvas:write(text, 1, 1, props.color or colors.white, props.bg or colors.black) return function() canvas:markText(text, 1, 1) end end, { canvas, props.display, props.align, props.text, props.color, props.bg, fw }) local x = props.right and props.x-canvas.width+1 or props.x return nil, { canvas = { canvas, x*2-1, props.y*3-2 } } end) end preload["components.BasicButton"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useBoundingBox = hooks.useBoundingBox local BasicText = require("components.BasicText") return Solyd.wrapComponent("BasicButton", function(props) -- local canvas = Solyd.useContext("canvas") -- local canvas = useCanvas() return BasicText { display = props.display, align = props.align, text = props.text, x = props.x, y = props.y, bg = props.bg, color = props.color, width = props.width, }, { -- canvas = canvas, aabb = useBoundingBox((props.x*2)-1, (props.y*3)-2, (props.width or #props.text)*2, 3, props.onClick, props.onScroll), } end) end preload["components.Alert"] = function(...) local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useBoundingBox = hooks.useBoundingBox local BasicText = require("components.BasicText") local BasicButton = require("components.BasicButton") return Solyd.wrapComponent("Alert", function(props) local text = props.text local lines = {} local textWidth = props.width - 2 for line in text:gmatch("[^\n]+") do for j = 1, math.ceil(#line / textWidth) do table.insert(lines, line:sub((j-1)*textWidth, (j)*textWidth - 1)) end end local elements = {} table.insert(elements, BasicText { key = props.key .. "-header", display = props.display, align = "left", text = "", x = props.x, y = props.y, bg = props.borderColor, color = props.color, width = props.width, }) local lineY = 0 for i = 1, #lines do if (i+1) >= props.height then break end lineY = lineY + 1 table.insert(elements, BasicText { key = props.key .. "-line-" .. i, display = props.display, align = props.align, text = lines[i], x = props.x + 1, y = props.y + i, bg = props.bg, color = props.color, width = props.width - 2, }) table.insert(elements, BasicText { key = props.key .. "-filler-bl-" .. i, display = props.display, align = "left", text = " ", x = props.x, y = props.y + i, bg = props.borderColor, color = props.color, width = 1, }) table.insert(elements, BasicText { key = props.key .. "-filler-br-" .. i, display = props.display, align = "left", text = " ", x = props.x + props.width - 1, y = props.y + i, bg = props.borderColor, color = props.color, width = 1, }) end for i = lineY + 1, props.height - 2 do table.insert(elements, BasicText { key = props.key .. "-filler-" .. i, display = props.display, align = "left", text = "", x = props.x + 1, y = props.y + i, bg = props.bg, color = props.color, width = props.width - 2, }) table.insert(elements, BasicText { key = props.key .. "-filler-bl-" .. i, display = props.display, align = "left", text = " ", x = props.x, y = props.y + i, bg = props.borderColor, color = props.color, width = 1, }) table.insert(elements, BasicText { key = props.key .. "-filler-br-" .. i, display = props.display, align = "left", text = " ", x = props.x + props.width - 1, y = props.y + i, bg = props.borderColor, color = props.color, width = 1, }) end table.insert(elements, BasicText { key = props.key .. "-footer", display = props.display, align = "left", text = "", x = props.x, y = props.y + props.height - 1, bg = props.borderColor, color = props.color, width = props.width, }) local cancelText = props.cancelText or "Cancel" local confirmText = props.confirmText or "Confirm" local buttonsWidth = #cancelText + #confirmText + 2 local buttonsX = math.floor(props.x + (props.width - buttonsWidth) / 2) table.insert(elements, BasicButton { key = props.key .. "-cancel", display = props.display, align = "left", text = cancelText, x = buttonsX, y = props.y + props.height - 2, bg = props.buttonColor, color = props.buttonTextColor, width = props.buttonsWidth, onClick = props.onCancel, }) table.insert(elements, BasicButton { key = props.key .. "-confirm", display = props.display, align = "left", text = confirmText, x = buttonsX + #cancelText + 2, y = props.y + props.height - 2, bg = props.buttonColor, color = props.buttonTextColor, width = props.buttonsWidth, onClick = props.onConfirm, }) return elements end) end preload["Krypton"] = function(...) local Krypton = { __opaque = true, node = "https://krist.dev/" } local Krypton_mt = { __index = Krypton } --[[ Make addresses ]] local g = string.gsub sha256 = loadstring(g(g(g(g(g(g(g(g('Sa=XbandSb=XbxWSc=XlshiftSd=unpackSe=2^32SYf(g,h)Si=g/2^hSj=i%1Ui-j+j*eVSYk(l,m)Sn=l/2^mUn-n%1VSo={0x6a09e667Tbb67ae85T3c6ef372Ta54ff53aT510e527fT9b05688cT1f83d9abT5be0cd19}Sp={0x428a2f98T71374491Tb5c0fbcfTe9b5dba5T3956c25bT59f111f1T923f82a4Tab1c5ed5Td807aa98T12835b01T243185beT550c7dc3T72be5d74T80deb1feT9bdc06a7Tc19bf174Te49b69c1Tefbe4786T0fc19dc6T240ca1ccT2de92c6fT4a7484aaT5cb0a9dcT76f988daT983e5152Ta831c66dTb00327c8Tbf597fc7Tc6e00bf3Td5a79147T06ca6351T14292967T27b70a85T2e1b2138T4d2c6dfcT53380d13T650a7354T766a0abbT81c2c92eT92722c85Ta2bfe8a1Ta81a664bTc24b8b70Tc76c51a3Td192e819Td6990624Tf40e3585T106aa070T19a4c116T1e376c08T2748774cT34b0bcb5T391c0cb3T4ed8aa4aT5b9cca4fT682e6ff3T748f82eeT78a5636fT84c87814T8cc70208T90befffaTa4506cebTbef9a3f7Tc67178f2}SYq(r,q)if e-1-r[1] 122 and 101 or byte > 57 and byte + 39 or byte) end function Krypton:makev2address(key) local protein = {} local stick = sha256(sha256(key)) local n = 0 local link = 0 local v2 = "k" if self.currency and self.currency.address_prefix then v2 = self.currency.address_prefix end repeat if n < 9 then protein[n] = string.sub(stick,0,2) stick = sha256(sha256(stick)) end n = n + 1 until n == 9 n = 0 repeat link = tonumber(string.sub(stick,1+(2*n),2+(2*n)),16) % 9 if string.len(protein[link]) ~= 0 then v2 = v2 .. makeaddressbyte(tonumber(protein[link],16)) protein[link] = '' n = n + 1 else stick = sha256(stick) end until n == 9 return v2 end function Krypton:toKristWalletFormat(passphrase) return sha256("KRISTWALLET"..passphrase).."-000" end --[[ Basic GET and POST requests ]] function Krypton:get(endpoint) local url = self.node .. endpoint local response, err = http.get(url) if not response then error(err, 3) end local body = response.readAll() response.close() local data = textutils.unserializeJSON(body) if not data.ok then if (data.error ~= "invalid_parameter") then error(data.error, 3) else error({data.error, data.parameter}, 3) end end return data end function Krypton:post(endpoint, data) local url = self.node .. endpoint local response, err = http.post(url, textutils.serializeJSON(data),{["content-type"]="application/json"}) if not response then error(err, 3) end local body = response.readAll() response.close() local data = textutils.unserializeJSON(body) if not data.ok then if (data.error ~= "invalid_parameter") then error(data.error, 3) else error({data.error, data.parameter}, 3) end end return data end --[[ Krist API Endpoints ]] -- Miscellaneous endpoints function Krypton:getInfo() return self:get("motd") end function Krypton:getName(name) return self:get("names/" .. textutils.urlEncode(name)) end -- Websocket Endpoints function Krypton:startWs(privateKey) local data = self:post("ws/start", {privatekey = privateKey}) if not data.url then error("Failed to get websocket URL", 2) end return data.url end --[[ Constructor ]] function Krypton.new(props) props = props or {} local self = setmetatable(props, Krypton_mt) local info = self:getInfo() self.currency = info.currency return self end --[[ Websockets ]] local KryptonWS = {} local KryptonWS_mt = { __index = KryptonWS } function Krypton:connect() if self.ws then return self.ws end self.ws = KryptonWS.new({ krypton = self }) return self.ws end function KryptonWS:connect() local url = self.krypton:startWs(self.krypton.privateKey) self.ws = http.websocket(url) self.id = 1 end function KryptonWS:reconnect() self.ws.close() self:connect() end function KryptonWS:disconnect() self.ws.close() self.ws = nil end function KryptonWS:listen() while true do if self.ws then local success, err = pcall(function() local response, binary = self.ws.receive(15) if not self.ws then -- We've been disconnected, stop listening return "break" end if not response then -- Didn't get keepalive, reconnect self:reconnect() end --print((self.krypton.id or self.krypton.node) .. " <- " .. response) local data = textutils.unserializeJSON(response) local eventType = data.type if eventType and eventType == "event" then local event = data event.source = self.krypton.id or self.krypton.node os.queueEvent(event.event, event) elseif not eventType then local event = data event.source = self.krypton.id or self.krypton.node if data.ok then os.queueEvent("krypton_response", event) else os.queueEvent("krypton_error", event) end end end) if success and err == "break" then break elseif err then if err == "Terminated" then break end print("Got error receiving response from " .. (self.krypton.id or self.krypton.node) .. " reconnecting") self:reconnect() end else sleep(0.1) end end end function KryptonWS:send(type, data) data = data or {} data.id = self.id data.type = type self.ws.send(textutils.serializeJSON(data)) self.id = self.id + 1 return self.id - 1 end -- Requires authed websocket function KryptonWS:makeTransaction(to, amount, metadata) return self:send("make_transaction", {to = to, amount = amount, metadata = metadata}) end function KryptonWS:getAddress(address, fetchNames) return self:send("address", {address = address, fetch_names = fetchNames}) end function KryptonWS:getSelf() return self:send("me") end function KryptonWS:getSubscriptions() return self:send("get_subscription_level") end function KryptonWS:logout() return self:send("logout") end function KryptonWS:login(privateKey) privateKey = privateKey or self.krypton.privateKey return self:send("login", {privatekey = privateKey}) end function KryptonWS:subscribe(event) return self:send("subscribe", {event = event}) end function KryptonWS:unsubscribe(event) return self:send("unsubscribe", {event = event}) end function KryptonWS.new(props) local self = setmetatable({}, KryptonWS_mt) self.krypton = props.krypton self:connect() return self end return Krypton end preload["DefaultLayout"] = function(...) --- Imports local _ = require("util.score") local sound = require("util.sound") local eventHook = require("util.eventHook") local renderHelpers = require("util.renderHelpers") local Display = require("modules.display") local Solyd = require("modules.solyd") local hooks = require("modules.hooks") local useCanvas = hooks.useCanvas local Button = require("components.Button") local BasicButton = require("components.BasicButton") local SmolButton = require("components.SmolButton") local BigText = require("components.BigText") local bigFont = require("fonts.bigfont") local SmolText = require("components.SmolText") local smolFont = require("fonts.smolfont") local BasicText = require("components.BasicText") local Rect = require("components.Rect") local RenderCanvas = require("components.RenderCanvas") local Core = require("core.ShopState") local Pricing = require("core.Pricing") local ShopRunner = require("core.ShopRunner") local ConfigValidator = require("core.ConfigValidator") local loadRIF = require("modules.rif") local function render(canvas, display, props, theme, version) local elements = {} local selectedProduct, setSelectedProduct = Solyd.useState("") local categories = renderHelpers.getCategories(props.shopState.products) local selectedCategory = props.shopState.selectedCategory local currencyEndX = 3 if #props.configState.config.currencies > 1 then for i = 1, #props.configState.config.currencies do local symbol = renderHelpers.getCurrencySymbol(props.configState.config.currencies[i], "large") local symbolSize = bigFont:getWidth(symbol)+6 currencyEndX = currencyEndX + symbolSize + 2 end end local categoryX = display.bgCanvas.width - 2 if #categories > 1 then for i = #categories, 1, -1 do local category = categories[i] local categoryName = category.name if i == selectedCategory then categoryName = "[" .. categoryName .. "]" end local categoryWidth = smolFont:getWidth(categoryName)+6 categoryX = categoryX - categoryWidth - 2 end end local headerCx = math.floor((display.bgCanvas.width - bigFont:getWidth(props.configState.config.branding.title)) / 2) local header -- TODO: Change header font size based on width local headerHeight = 15 if theme.formatting.headerAlign == "center" and headerCx < currencyEndX and #categories == 1 then table.insert(elements, Rect { display=display, x=1, y=1, width=currencyEndX, height=bigFont.height+6, color=theme.colors.headerBgColor }) header = BigText { display=display, text=props.configState.config.branding.title, x=currencyEndX, y=1, align="left", bg=theme.colors.headerBgColor, color = theme.colors.headerColor, width=display.bgCanvas.width } elseif theme.formatting.headerAlign == "center" and headerCx+bigFont:getWidth(props.configState.config.branding.title) > categoryX and #categories > 1 then table.insert(elements, Rect { display=display, x=categoryX, y=1, width=display.bgCanvas.width-categoryX+1, height=bigFont.height+6, color=theme.colors.headerBgColor }) header = BigText { display=display, text=props.configState.config.branding.title, x=1, y=1, align="right", bg=theme.colors.headerBgColor, color = theme.colors.headerColor, width=categoryX-1 } else header = BigText { display=display, text=props.configState.config.branding.title, x=1, y=1, align=theme.formatting.headerAlign, bg=theme.colors.headerBgColor, color = theme.colors.headerColor, width=display.bgCanvas.width } end if props.configState.config.branding.subtitle then table.insert(elements, BasicText { display = display, text = props.configState.config.branding.subtitle, x = 1, y = 6, align = theme.formatting.subtitleAlign, bg = theme.colors.subtitleBgColor, color = theme.colors.subtitleColor, width = math.floor(display.bgCanvas.width / 2) }) headerHeight = headerHeight + 3 end table.insert(elements, header) local footerHeight = 0 if props.configState.config.settings.showFooter then local footerMessage if props.shopState.selectedCurrency.name or not props.configState.config.lang.footerNoName then footerMessage = props.configState.config.lang.footer else footerMessage = props.configState.config.lang.footerNoName end if props.shopState.selectedCurrency.name and footerMessage:find("%%name%%") then footerMessage = footerMessage:gsub("%%name%%", props.shopState.selectedCurrency.name) end if footerMessage:find("%%addr%%") and props.shopState.selectedCurrency.host then footerMessage = footerMessage:gsub("%%addr%%", props.shopState.selectedCurrency.host) end if footerMessage:find("%%version%%") then footerMessage = footerMessage:gsub("%%version%%", version) end if selectedProduct and #selectedProduct > 0 and footerMessage:find("") then footerMessage = footerMessage:gsub("", selectedProduct) end if props.shopState.selectedCurrency then local footer local footerSize = theme.formatting.footerSize if footerSize == "auto" and bigFont:getWidth(footerMessage) < display.bgCanvas.width then footerSize = "large" elseif footerSize == "auto" and smolFont:getWidth(footerMessage) < display.bgCanvas.width then footerSize = "medium" elseif footerSize == "auto" then footerSize = "small" end if footerSize == "large" then footer = BigText { display=display, text=footerMessage, x=1, y=display.bgCanvas.height-bigFont.height-5, align=theme.formatting.footerAlign, bg=theme.colors.footerBgColor, color = theme.colors.footerColor, width=display.bgCanvas.width } footerHeight = smolFont.height + 6 elseif footerSize == "medium" then footer = SmolText { display=display, text=footerMessage, x=1, y=display.bgCanvas.height-smolFont.height-2, align=theme.formatting.footerAlign, bg=theme.colors.footerBgColor, color = theme.colors.footerColor, width=display.bgCanvas.width } footerHeight = smolFont.height + 4 else footer = BasicText { display=display, text=footerMessage, x=1, y=math.floor(display.bgCanvas.height/3), align=theme.formatting.footerAlign, bg=theme.colors.footerBgColor, color = theme.colors.footerColor, width=math.ceil(display.bgCanvas.width/2) } footerHeight = smolFont.height + 4 end table.insert(elements, footer) end end local maxAddrWidth = 0 local maxQtyWidth = 0 local maxPriceWidth = 0 local maxNameWidth = 0 props.shopState.numCategories = #categories local catName = "*" local shopProducts = {} if categories[selectedCategory] then catName = categories[selectedCategory].name shopProducts = renderHelpers.getDisplayedProducts(categories[selectedCategory].products, props.configState.config.settings, props.shopState.selectedCurrency) end local productsHeight = display.bgCanvas.height - headerHeight - footerHeight - 2 local heightPerProduct = math.floor(productsHeight / #shopProducts) local layout if theme.formatting.layout == "auto" then if heightPerProduct >= 15 then layout = "large" elseif heightPerProduct >= 9 then layout = "medium" else layout = "small" end else layout = theme.formatting.layout end local currency = props.shopState.selectedCurrency local currencySymbol = renderHelpers.getCurrencySymbol(currency, layout) while #shopProducts > 0 and (maxAddrWidth == 0 or maxAddrWidth + maxQtyWidth + maxPriceWidth + maxNameWidth > display.bgCanvas.width - 3) do if props.configState.config.theme.formatting.layout == "auto" and (maxAddrWidth + maxQtyWidth + maxPriceWidth + maxNameWidth > display.bgCanvas.width - 3) then if layout == "large" then layout = "medium" maxAddrWidth = 0 maxQtyWidth = 0 maxPriceWidth = 0 maxNameWidth = 0 elseif layout == "medium" then layout = "small" maxAddrWidth = 0 maxQtyWidth = 0 maxPriceWidth = 0 maxNameWidth = 0 end end currencySymbol = renderHelpers.getCurrencySymbol(currency, layout) for i = 1, #shopProducts do local product = shopProducts[i] local productAddr = product.address .. "@" if props.shopState.selectedCurrency.name then if layout == "small" then if props.configState.config.settings.smallTextKristPayCompatability then productAddr = product.address .. "@" .. props.shopState.selectedCurrency.name else productAddr = product.address .. "@ " end end else productAddr = product.address end product.quantity = product.quantity or 0 local productPrice = Pricing.getProductPrice(product, props.shopState.selectedCurrency) if layout == "large" then maxAddrWidth = math.max(maxAddrWidth, renderHelpers.getWidth(productAddr, layout)+2) maxQtyWidth = math.max(maxQtyWidth, renderHelpers.getWidth(tostring(product.quantity), layout)+4+2) maxPriceWidth = math.max(maxPriceWidth, renderHelpers.getWidth(tostring(productPrice) .. currencySymbol, layout)+2) maxNameWidth = math.max(maxNameWidth, renderHelpers.getWidth(product.name, layout)+2) elseif layout == "medium" then maxAddrWidth = math.max(maxAddrWidth, renderHelpers.getWidth(productAddr, layout)+2) maxQtyWidth = math.max(maxQtyWidth, renderHelpers.getWidth(tostring(product.quantity), layout)+4+2) maxPriceWidth = math.max(maxPriceWidth, renderHelpers.getWidth(tostring(productPrice) .. currencySymbol, layout)+2) maxNameWidth = math.max(maxNameWidth, renderHelpers.getWidth(product.name, layout)+2) else maxAddrWidth = math.max(maxAddrWidth, renderHelpers.getWidth(productAddr, layout)+1) maxQtyWidth = math.max(maxQtyWidth, renderHelpers.getWidth(tostring(product.quantity), layout)+2) maxPriceWidth = math.max(maxPriceWidth, renderHelpers.getWidth(tostring(productPrice) .. currencySymbol, layout)+1) maxNameWidth = math.max(maxNameWidth, renderHelpers.getWidth(product.name, layout)+1) end end if props.configState.config.theme.formatting.layout ~= "auto" or layout == "small" then break end end local startY = headerHeight + 1 local startTextY = math.ceil(headerHeight / 3) + 1 for i = 1, #shopProducts do local product = shopProducts[i] -- Display products in format: --
product.quantity = product.quantity or 0 local productPrice = Pricing.getProductPrice(product, props.shopState.selectedCurrency) local qtyColor = theme.colors.normalQtyColor if product.quantity == 0 then qtyColor = theme.colors.outOfStockQtyColor elseif product.quantity < 10 then qtyColor = theme.colors.lowQtyColor elseif product.quantity < 64 then qtyColor = theme.colors.warningQtyColor end local productNameColor = theme.colors.productNameColor if product.quantity == 0 then productNameColor = theme.colors.outOfStockNameColor end local productAddr = product.address .. "@" if props.shopState.selectedCurrency.name then if layout == "small" then if props.configState.config.settings.smallTextKristPayCompatability then productAddr = product.address .. "@" .. props.shopState.selectedCurrency.name else productAddr = product.address .. "@ " end end else productAddr = product.address end local kristpayHelperText = props.shopState.selectedCurrency.host or "" if props.shopState.selectedCurrency.name then kristpayHelperText = product.address .. "@" .. props.shopState.selectedCurrency.name end local productBgColor = theme.colors.productBgColors[((i-1) % #theme.colors.productBgColors) + 1] local productClickFunction = function() setSelectedProduct(product.address) if props.configState.eventHooks and props.configState.eventHooks.onProductSelected then eventHook.execute(props.configState.eventHooks.onProductSelected, product, currency) end if props.configState.config.settings.playSounds then sound.playSound(props.peripherals.speaker, props.configState.config.sounds.button) end end if layout == "large" then table.insert(elements, Button { key="qty-"..catName..tostring(product.id), display=display, text=tostring(product.quantity), x=1, y=startY+((i-1)*15), align="center", bg=productBgColor, color=qtyColor, width=maxQtyWidth, onClick=productClickFunction }) table.insert(elements, Button { key="name-"..catName..tostring(product.id), display=display, text=product.name, x=maxQtyWidth+1, y=startY+((i-1)*15), align=theme.formatting.productNameAlign, bg=productBgColor, color=productNameColor, width=display.bgCanvas.width-3-maxAddrWidth-maxPriceWidth-maxQtyWidth, onClick=productClickFunction }) table.insert(elements, Button { key="price-"..catName..tostring(product.id), display=display, text=tostring(productPrice) .. currencySymbol, x=display.bgCanvas.width-3-maxAddrWidth-maxPriceWidth, y=startY+((i-1)*15), align="right", bg=productBgColor, color=theme.colors.priceColor, width=maxPriceWidth, onClick=productClickFunction }) table.insert(elements, Button { key="addr-"..catName..tostring(product.id), display=display, text=productAddr, x=display.bgCanvas.width-3-maxAddrWidth, y=startY+((i-1)*15), align="right", bg=productBgColor, color=theme.colors.addressColor, width=maxAddrWidth+4, onClick=productClickFunction }) table.insert(elements, BasicText { key="invis-" .. catName .. tostring(product.id), display=display, text=kristpayHelperText, x=1, y=startTextY+((i-1)*5), align="center", bg=productBgColor, color=productBgColor, width=#(kristpayHelperText) }) elseif layout == "medium" then table.insert(elements, SmolButton { key="qty-"..catName..tostring(product.id), display=display, text=tostring(product.quantity), x=1, y=startY+((i-1)*9), align="center", bg=productBgColor, color=qtyColor, width=maxQtyWidth, onClick=productClickFunction }) table.insert(elements, SmolButton { key="name-"..catName..tostring(product.id), display=display, text=product.name, x=maxQtyWidth+1, y=startY+((i-1)*9), align=theme.formatting.productNameAlign, bg=productBgColor, color=productNameColor, width=display.bgCanvas.width-3-maxAddrWidth-maxPriceWidth-maxQtyWidth, onClick=productClickFunction }) table.insert(elements, SmolButton { key="price-"..catName..tostring(product.id), display=display, text=tostring(productPrice) .. currencySymbol, x=display.bgCanvas.width-3-maxAddrWidth-maxPriceWidth, y=startY+((i-1)*9), align="right", bg=productBgColor, color=theme.colors.priceColor, width=maxPriceWidth, onClick=productClickFunction }) table.insert(elements, SmolButton { ey="addr-"..catName..tostring(product.id), display=display, text=productAddr, x=display.bgCanvas.width-3-maxAddrWidth, y=startY+((i-1)*9), align="right", bg=productBgColor, color=theme.colors.addressColor, width=maxAddrWidth+4, onClick=productClickFunction }) table.insert(elements, BasicText { key="invis-" .. catName .. tostring(product.id), display=display, text=kristpayHelperText, x=1, y=startTextY+((i-1)*3), align="center", bg=productBgColor, color=productBgColor, width=#(kristpayHelperText) }) else table.insert(elements, BasicButton { key="qty-"..catName..tostring(product.id), display=display, text=tostring(product.quantity), x=1, y=startTextY+((i-1)*1), align="center", bg=productBgColor, color=qtyColor, width=maxQtyWidth, onClick=productClickFunction }) table.insert(elements, BasicButton { key="name-"..catName..tostring(product.id), display=display, text=product.name, x=maxQtyWidth+1, y=startTextY+((i-1)*1), align=theme.formatting.productNameAlign, bg=productBgColor, color=productNameColor, width=(display.bgCanvas.width/2)-1-maxAddrWidth-maxPriceWidth-maxQtyWidth, onClick=productClickFunction }) table.insert(elements, BasicButton { key="price-"..catName..tostring(product.id), display=display, text=tostring(productPrice) .. currencySymbol, x=(display.bgCanvas.width/2)-1-maxAddrWidth-maxPriceWidth, y=startTextY+((i-1)*1), align="right", bg=productBgColor, color=theme.colors.priceColor, width=maxPriceWidth, onClick=productClickFunction }) table.insert(elements, BasicButton { key="addr-"..catName..tostring(product.id), display=display, text=productAddr, x=(display.bgCanvas.width/2)-1-maxAddrWidth, y=startTextY+((i-1)*1), align="right", bg=productBgColor, color=theme.colors.addressColor, width=maxAddrWidth+2, onClick=productClickFunction }) end end local currencyX = 3 if #props.configState.config.currencies > 1 then for i = 1, #props.configState.config.currencies do local symbol = renderHelpers.getCurrencySymbol(props.configState.config.currencies[i], "large") local symbolSize = bigFont:getWidth(symbol)+6+1 local bgColor = theme.colors.currencyBgColors[((i-1) % #theme.colors.currencyBgColors) + 1] table.insert(elements, Button { display = display, align = "center", text = symbol, x = currencyX, y = 1, bg = bgColor, color = theme.colors.currencyTextColor, width = symbolSize, onClick = function() props.shopState.selectedCurrency = props.configState.config.currencies[i] props.shopState.lastTouched = os.epoch("utc") if props.configState.config.settings.playSounds then sound.playSound(props.peripherals.speaker, props.configState.config.sounds.button) end end }) currencyX = currencyX + symbolSize + 2 end end local categoryX = display.bgCanvas.width - 2 if #categories > 1 then for i = #categories, 1, -1 do local category = categories[i] local categoryName = category.name local categoryColor if i == selectedCategory then categoryColor = theme.colors.activeCategoryColor categoryName = "[" .. categoryName .. "]" else categoryColor = theme.colors.categoryBgColors[((i-1) % #theme.colors.categoryBgColors) + 1] end local categoryWidth = smolFont:getWidth(categoryName)+6 categoryX = categoryX - categoryWidth - 2 table.insert(elements, SmolButton { display = display, align = "center", text = categoryName, x = categoryX, y = 4, bg = categoryColor, color = theme.colors.categoryTextColor, width = categoryWidth, onClick = function() props.shopState.selectedCategory = i setSelectedProduct("") props.shopState.lastTouched = os.epoch("utc") if props.configState.config.settings.playSounds then sound.playSound(props.peripherals.speaker, props.configState.config.sounds.button) end -- canvas:markRect(1, 16, canvas.width, canvas.height-16) end }) end end return elements end return render end return preload["radon"](...)