-- BIMG Board written by ShreksHellraiser -- This is software which will display a grid of BIMGs on a monitor. -- Users can pay to upload BIMGs to the monitor and the images will be cycled out on a FIFO basis. -- Users may pay more to change the palette of the monitor. -- Supports animations and custom palettes -- To easily delete something someone posts on the board run `board admin`. -- While this is active tapping an image on the board instantly deletes it -- BE CAREFUL! -- You have permission to modify and redistribute this -- I ask that you do not run another main instance on SC3 -- and instead just let all boards communicate with my main instance. -- You are free to modify, but please keep your modifications compatible. -- Any added / changed features can be noted in the instance_string variable -- as this is displayed upon \bimgboard [id] settings.define("board.privatekey", { description = "Krist private key", type = "string" }) settings.define("board.price", { description = "Base image price", type = "number" }) settings.define("board.pal_price", { description = "Palette applied price", type = "number" }) settings.define("board.address", { description = "Krist address/name", type = "string" }) settings.define("board.chatbox", { description = "Chatbox command", type = "string", default = "bimgboard" }) settings.define("board.width", { description = "Image cells wide", type = "number" }) settings.define("board.height", { description = "Image cells tall", type = 'number' }) settings.define("board.side", { description = "Monitor side", type = "string" }) settings.define("board.cache", { description = "Folder for bimg cache", type = "string", default = "disk" }) settings.define("board.main", { description = "If this board should take control of the chatbox interface", type = "boolean", default = false }) settings.define("board.id", { description = "This board's specific ID", type = "string" }) settings.define("board.location", { description = "This board's location description", type = "string" }) settings.define("board.cut", { description = "Automatically provide this percentage [0,1] of earnings to board.donate_address", type = "number", default = 0.05 }) settings.define("board.donate_address", { description = "Address to send board.cut percentage of earnings to", type = "string", default = "donate@alt.kst" }) settings.define("board.donate_minimum", { description = "Minimum donation due before a transaction will be made.", type = "number", default = 10 }) settings.define("board.enable_donations", { description = "Enable donations", type = "boolean", default = true }) settings.define("board.first_run", { type = "boolean", default = true }) settings.save() if not fs.exists("ktwsl.lua") then print("Missing ktwsl, installing...") local h = assert(http.get("https://raw.githubusercontent.com/MasonGulu/msks/main/ktwsl.lua")) local f = assert(fs.open("ktwsl.lua", "w")) f.write(h.readAll()) f.close() h.close() end local function assert_get(setting) local value = settings.get(setting) if not value then local details = settings.getDetails(setting) print(("Setting %s is not set."):format(setting)) print(("Description: %s"):format(details.description)) print(("Type %s"):format(details.type)) while true do print("Please enter a value:") local input = read() if details.type == "number" then if tonumber(input) then value = tonumber(input) break end elseif input ~= "" then value = input break end end settings.set(setting, value) settings.save() end return value end local modem = assert(peripheral.find("modem", function(name, wrapped) return wrapped.isWireless() end), "Attach a wireless modem!") --[[@as Modem]] local privatekey = assert_get("board.privatekey") local krist = require("ktwsl")("https://krist.dev", privatekey) local price = assert_get("board.price") local pal_price = assert_get("board.pal_price") local address = assert_get("board.address") local chatbox_cmd = assert_get("board.chatbox") local donate_cut = settings.get("board.cut", 0.05) local donation_address = settings.get("board.donation_address", "donate@alt.kst") local donate_minimum = settings.get("board.donate_minimum", 10) local enable_donations = settings.get("board.enable_donations", true) local id = assert_get("board.id") local x, y, z while not x do print("Locating..") x, y, z = gps.locate() end local coords = ("{%d,%d,%d}"):format(x, y, z) local location = assert_get("board.location") local main = settings.get("board.main", false) local cache = settings.get("board.cache") if settings.get("board.first_run") then print(("Would you like to donate %.2f%% of revenue from this board to %s? [Y/n]"):format(donate_cut * 100, donation_address)) enable_donations = read():lower():sub(1, 1) ~= "n" settings.set("board.enable_donations", enable_donations) settings.set("board.first_run", false) settings.save() end local strings = require "cc.strings" local admin = arg[1] == "admin" if admin then print("Running in admin mode.") print("Tap on the screen to remove a listing.") end krist.subscribeAddress(address) ---@type Monitor local device = peripheral.wrap(assert_get("board.side")) --[[@as Monitor]] device.setTextScale(0.5) device.clear() local w, h = device.getSize() local slot_rows = assert_get("board.height") local slot_columns = assert_get("board.width") local foot_str = "Run \\%s for info. ID %s." local footer_height = #strings.wrap(foot_str, w) local slot_h = math.floor((h - footer_height) / slot_rows) local slot_w = math.floor(w / slot_columns) local display_names = false local slot_hp = slot_h * 3 local slot_wp = slot_w * 2 local file_size_limit = math.floor((fs.getCapacity(cache) / (slot_rows * slot_columns)) * 0.9) ---@type table[] local bimgs = {} ---@type Window[] local windows = {} local campaigns = {} ---@type integer[] local frames = {} -- This data is sent to the "main" bimgboard server local board_data = { location = location, coords = coords, id = id, address = address, slot_w = slot_w, slot_h = slot_h, slot_wp = slot_wp, slot_hp = slot_hp, slot_rows = slot_rows, slot_columns = slot_columns, price = price, pal_price = pal_price, -- This field is for anything you desire and is displayed to the user. -- Though, I recommend using this in cases when modifications to the software is made -- for example, listing a format you added support for. extra = "" } local instance_string = ("&6&lCycling Board %s\n"):format(id) .. ("&fFor more information on how to use this board, run \\%s.\n"):format(chatbox_cmd) .. ("Resolution: &9%u&fx&9%u&f pixels, &9%u&fx&9%u&f characters\n"):format(slot_wp, slot_hp, slot_w, slot_h) .. ("Price: &9%u&f|&9%uKST &fto %s\n"):format(price, pal_price, address) .. "&cSee something that shouldn't be here? Click the screen to show who purchased each slot." local main_string = "**=== Cycling Board ===**\n" .. "This is a system of boards which can display user provided [BIMG](https://github.com/SkyTheCodeMaster/bimg/blob/master/spec.md) files. " .. ("Run `\\%s bimg` for information on BIMG.\n"):format(chatbox_cmd) .. ("Each board has varying size, prices, and amount of slots, these slots are cycled out on a FIFO basis. To view a specific board's information run `\\%s [id]`.\n") :format(chatbox_cmd) .. ("For information on uploading an image to a board, run `\\%s usage`.\n"):format(chatbox_cmd) .. ("To see all currently running boards, run `\\%s list`.\n"):format(chatbox_cmd) .. ("For palette information run `\\%s palette`.\n"):format(chatbox_cmd) local main_palette_string = "**=== Cycling Board Palette Info ===**\n" .. "A monitor can display multiple images, but only 16 colors. Those 16 colors are assignable, but at the cost of invalidating all images.\n" .. "For this reason there are 2 prices. The first indicates adding an image, the second indicates adding an image and changing the palette to match.\n" .. "Since it is cheaper not to change the palette, you can opt to make your image fit the current palette.\n" .. ("To take advantage of the current palette you can get a monitor's current palette by running `\\%s [id] palette`. ") :format(chatbox_cmd) .. ("The string provided can be placed directly into the BIMG Generator's custom palette field.\n"):format(chatbox_cmd) local main_usage_string = "**=== Cycling Board Usage ===**\n" .. "To upload an image to the board you will first need to host your image somewhere. " .. "This board supports downloading a URL's contents directly, or using a `p.sc3.io` paste code. Downloads are done in binary mode.\n" .. "Once your image is uploaded somewhere, you may send a payment to the board. There are two metadata fields that are relavant\n" .. "* url - Set this to your raw URL, or your p.sc3.io paste ID.\n" .. "* pal - Set this to true if you want your image's palette to be applied, the price will be higher.\n" .. "For example, for an image hosted at paste ID `44wbJ6h33y` one could run `/pay example@name.kst 10 url=44wbJ6h33y;pal=true`." local main_bimg_string = "**=== Cycling Board BIMG Info ===**\n" .. "There exist several tools for converting images to [BIMG](https://github.com/SkyTheCodeMaster/bimg/blob/master/spec.md).\n" .. "* [BIMG Generator](https://github.com/MasonGulu/BIMG-Generator) Java; Supports GIFs, high quality dithering, user-defined palettes, text/binary exporting.\n" .. "* [Sanjuuni](https://github.com/MCJack123/sanjuuni) C++; Supports images, videos, high quality dithering, text exporting.\n" .. "* [Sanjuuni UI](https://github.com/MCJack123/sanjuuni-ui) Sanjuuni, but with QT GUI.\n" .. "* [Online BIMG Generator](https://masongulu.github.io/js-bimg-generator/) JS; Supports user-defined palettes, low quality dithering, text exporting.\n" .. "BIMG supports animations and custom palettes. Since a monitor can only display one palette changing the palette is destructive to existing images. For this reason custom palettes cost more.\n" .. "Some of these converters may export a binary file. This is okay, as long as you upload to a host that supports binary files. Pastebin and p.sc3.io do not." local pow_2 = {} for i = 0, 15 do pow_2[i] = 2 ^ i end local function get_palette_string(palette) local str = "The current palette is `[" for i = 0, 15 do local pstr = palette[i][1] if palette[i][2] then pstr = colors.packRGB(palette[i][1], palette[i][2], palette[i][3]) end str = str .. pstr if i ~= 15 then str = str .. "," end end str = str .. "]`" return str end -- "Pay \164%u to %s with url= to add your image " .. -- "or \164%u and add pal=true to apply the palette from your image (example: /pay user 10 url=123;pal=true).\n" .. -- "Accepts BIMGs using default palette, %ux%u pixels, %ux%u chars.\n" .. -- "Touch monitor to toggle names." local footer = strings.wrap(foot_str:format(chatbox_cmd, id), w) ---@param filename string ---@return table? local function load_file(filename) local f = fs.open(filename, "r") if not f then return end local data = textutils.unserialise(f.readAll() --[[@as string]]) f.close() return data --[[@as table]] end local function save_file(filename, data) local f = assert(fs.open(filename, "w")) f.write(textutils.serialise(data, { compact = true })) f.close() end ---@type integer local undonated_earnings = load_file("undonated_earnings.txt") --[[@as integer]] or 0 ---@type {name: string, url: string, palette: boolean?}[] campaigns = load_file("campaigns.txt") or {} ---@param url string ---@return string|nil local function download_url(url) local f = http.get(url, nil, true) if not f then return end local data = f.readAll() f.close() return data end ---@return table? local function download_image(url) -- todo check for p.sc3.io codes if string.match(url, "^[%a%d]+$") then -- p.sc3.io local f = http.get("https://p.sc3.io/api/v1/pastes/" .. url) if not f then print("not a valid p.sc3.io code") return end local info = textutils.unserialiseJSON(f.readAll() --[[@as string]]) f.close() if not info then print("invalid response") return end url = info.url -- fall through print("found p.sc3.io url", url) end local d = download_url(url) if not d then print("not a valid url") return end return textutils.unserialise(d) --[[@as table?]] end local current_palette local function apply_palette(palette, dev) dev = dev or device for k, v in pairs(palette) do dev.setPaletteColor(pow_2[k], table.unpack(v)) end end local function get_high_contrast() local brightest, darkest = 0, 255 local brightest_color, darkest_color local function _ca(r, g, b) return 255 * ((r + g + b) / 3) end for i = 0, 15 do local color_average if current_palette[i][2] then color_average = _ca(table.unpack(current_palette[i])) else color_average = _ca(colors.unpackRGB(current_palette[i][1])) end if color_average < darkest then darkest = color_average darkest_color = 2 ^ i end if color_average > brightest then brightest = color_average brightest_color = 2 ^ i end end return brightest_color, darkest_color end local fg, bg ---@param i integer local function render_bimg(i) local win, bimg, name = windows[i], bimgs[i], campaigns[i].name win.setVisible(false) apply_palette(current_palette, win) win.clear() frames[i] = (frames[i] or 0) + 1 if frames[i] > #bimg then frames[i] = 1 end for yPos, v in pairs(bimg[frames[i]]) do if type(yPos) == "number" then win.setCursorPos(1, yPos) win.blit(table.unpack(v)) end end if display_names then win.setTextColor(fg) win.setBackgroundColor(bg) local sname = strings.wrap(name, slot_w) for line, s in ipairs(sname) do win.setCursorPos(1, line) win.write(s) end end win.setVisible(true) end for column = 1, slot_columns do for row = 1, slot_rows do local i = column + ((row - 1) * slot_columns) local x = 1 + ((column - 1) * slot_w) local y = 1 + ((row - 1) * slot_h) windows[i] = window.create(device, x, y, slot_w, slot_h) end end local function load_bimgs() for i = 1, slot_rows * slot_columns do if campaigns[i] then local fn = fs.combine(cache, tostring(i)) if fs.exists(fn) then bimgs[i] = load_file(fn) else bimgs[i] = download_image(campaigns[i].url) end frames[i] = 1 end end end load_bimgs() local function save_bimgs() for k, v in pairs(fs.list(cache)) do if not fs.isDir(v) then fs.delete(v) end end for i = 1, slot_rows * slot_columns do local fn = fs.combine(cache, tostring(i)) if bimgs[i] then save_file(fn, bimgs[i]) end end end save_bimgs() local function get_palette() if fs.exists("palette") then return load_file("palette") else for i, v in ipairs(campaigns) do if v.palette then local bimg = bimgs[i] or download_image(v.url) bimgs[i] = bimg print("got palette", i) local palette = bimg.palette or bimg[1].palette save_file("palette", palette) return palette end end end local palette = {} -- default palette print("default palette") for i, v in pairs(pow_2) do palette[i] = { term.nativePaletteColor(v) } end save_file("palette", palette) return palette end local function draw_screen() current_palette = get_palette() apply_palette(current_palette) fg, bg = get_high_contrast() device.setTextColor(fg) device.setBackgroundColor(bg) device.clear() for i = 1, slot_rows * slot_columns do if bimgs[i] then render_bimg(i) end end device.setTextColor(fg) device.setBackgroundColor(bg) for l = 1, #footer do device.setCursorPos(1, h - #footer + (l - 1)) device.write(footer[l]) end if admin then device.setCursorPos(1, h - #footer) device.clearLine() device.write("ADMIN MODE") end device.setCursorPos(1, 1) end draw_screen() local function add_campaign(name, url, data, use_palette) table.insert(campaigns, 1, { name = name, url = url, palette = use_palette }) table.insert(bimgs, 1, data) if use_palette then fs.delete("palette") end save_bimgs() draw_screen() save_file("campaigns.txt", campaigns) end local function remove_campaign(index) table.remove(campaigns, index) table.remove(bimgs, index) save_bimgs() load_bimgs() draw_screen() save_file("campaigns.txt", campaigns) end local function validate_bimg(bimg) if type(bimg) ~= "table" then print("not a table") return false, "Malformed file" end if not type(bimg[1] == "table") then print("no first frame") return false, "No first frame" end for yPos, v in pairs(bimg[1]) do if type(yPos) == "number" then if #v < 3 then print("invalid frame contents") return false, "Invalid BIMG frame contents" end end end -- file size validation local s = textutils.serialise(bimg, { compact = true }) if #s > file_size_limit then print("BIMG too large") return false, ("BIMG is too large. Maximum file size is %u."):format(file_size_limit) end print("valid bimg") return true end local function check_donation() local donation = undonated_earnings * donate_cut if math.floor(donation) == math.ceil(donation) and donation > donate_minimum then -- send a transaction krist.makeTransaction(donation_address, donation, ("message=Donation cut from bimgboard %s"):format(id)) undonated_earnings = 0 save_file("undonated_earnings.txt", undonated_earnings) end end local function handle_transactions() while true do local e = table.pack(os.pullEventRaw()) if e[1] == "krist_transaction" and e[2] == address then print("got transaction") local metadata = krist.parseMetadata(e[5].metadata) local url_data = metadata.url and download_image(metadata.url) print(metadata.url, url_data) local price_owed = price if metadata.pal == "true" then price_owed = pal_price end local valid, reason if url_data and e[4] >= price_owed then valid, reason = validate_bimg(url_data) end if valid then if e[4] > price_owed then -- refund krist.makeTransaction(e[3], e[4] - price_owed, ("return=%s;message=%s"):format(address, "You paid too much")) end if enable_donations then undonated_earnings = undonated_earnings + price_owed save_file("undonated_earnings.txt", undonated_earnings) check_donation() end add_campaign(e[3], metadata.url, url_data, metadata.pal == "true") else local fail_str = ("Invalid BIMG file: %s"):format(reason) if not url_data then fail_str = "Please provide a valid url or p.sc3.io code in the url field" end if e[4] < price_owed then fail_str = ("You paid too little, owed %u"):format(price_owed) end -- invalid url krist.makeTransaction(e[3], e[4], ("return=%s;message=%s"):format(address, fail_str)) end elseif e[1] == "krist_stop" then for l = 1, #footer do device.setCursorPos(1, h - #footer + (l - 1)) device.clearLine() device.write(("Krist Handler Errored: %s"):format(e[2])) end error(e[2]) elseif e[1] == "monitor_touch" then if admin then -- delete a campaign local campaign = math.floor(e[3] / slot_w) + 1 + (math.floor(e[4] / slot_h) * slot_columns) remove_campaign(campaign) end display_names = not display_names draw_screen() elseif e[1] == "terminate" then krist.stop() error("Terminated") end end end local port = 25799 modem.open(port) local function handle_modem() while true do local _, _, channel, reply, message, dist = os.pullEvent("modem_message") if message == "list" then modem.transmit(reply, port, board_data) elseif type(message) == "table" and message[1] == "palette" and message[2] == id then modem.transmit(reply, port, { palette = get_palette_string(current_palette), id = id }) elseif type(message) == "table" and message[1] == "instance_string" and message[2] == id then modem.transmit(reply, port, { instance_string = instance_string, id = id }) end end end local function get_all_monitors() print("getting monitors") modem.transmit(port, port, "list") local timer = os.startTimer(2) local monitors = {} monitors[id] = board_data while true do local event, t, channel, reply, message, dist = os.pullEvent() if event == "modem_message" and type(message) == "table" and message.id then print("got monitor", message.id) monitors[message.id] = message elseif event == "timer" and t == timer then print("done getting monitors") return monitors -- exit condition end end end local function get_palette_of(tid) if tid == id then return get_palette_string(current_palette) end modem.transmit(port, port, { "palette", tid }) local timer = os.startTimer(2) while true do local event, t, channel, reply, message, dist = os.pullEvent() if event == "modem_message" and type(message) == "table" and message.id == tid and message.palette then return message.palette elseif event == "timer" and t == timer then return nil end end end local function get_instance_string_of(tid) if tid == id then return instance_string end modem.transmit(port, port, { "instance_string", tid }) local timer = os.startTimer(2) while true do local event, t, channel, reply, message, dist = os.pullEvent() if event == "modem_message" and type(message) == "table" and message.id == tid and message.instance_string then return message.instance_string elseif event == "timer" and t == timer then return nil end end end local function handle_commands() assert(main, "Attempt to handle chatbox commands when not main bimgboard!") assert(chatbox.isConnected(), "Chatbox isn't connected!") while true do local e = { os.pullEventRaw("command") } if e[3] == chatbox_cmd then local user = e[2] local args = e[4] if not args[1] then print("general help") chatbox.tell(user, main_string, chatbox_cmd, nil, "markdown") elseif args[1] == "bimg" then print("bimg help") chatbox.tell(user, main_bimg_string, chatbox_cmd, nil, "markdown") elseif args[1] == "usage" then print("usage help") chatbox.tell(user, main_usage_string, chatbox_cmd, nil, "markdown") elseif args[1] == "palette" then print("palette help") chatbox.tell(user, main_palette_string, chatbox_cmd, nil, "markdown") else local monitors = get_all_monitors() print(textutils.serialise(monitors)) if monitors[args[1]] then print("listing monitor information") if args[2] == "palette" then local palette = get_palette_of(args[1]) if palette then chatbox.tell(user, palette, chatbox_cmd, nil, "markdown") end else local instance = get_instance_string_of(args[1]) if instance then chatbox.tell(user, instance, chatbox_cmd, nil, "format") end end elseif args[1] == "list" then print("listing") local response = "&9=== BIMG Monitor Listing ===&f\n" for _, v in pairs(monitors) do response = response .. ("ID %s @ %s %s. Resolution &9%u&fx&9%u&f pixels. Price &9%u&f|&9%uKST&f.\n"):format(v.id, v.coords, v.location, v.slot_wp, v.slot_hp, v.price, v.pal_price) end chatbox.tell(user, response, chatbox_cmd, nil, "format") end end end end end local function handle_frames() while true do sleep(0.2) draw_screen() end end local to_execute = { handle_transactions, krist.start, handle_frames } if main then to_execute[#to_execute + 1] = handle_commands else to_execute[#to_execute + 1] = handle_modem end parallel.waitForAny(table.unpack(to_execute))