local VERSION = "v1.5.7" local MACHINE_SIDE = "bottom" local PAYMENT_SIDE = "right" local PRESET_AMOUNTS = { 5, 10, 15, 20 } local DEFAULT_API_BASE = "http://ticket.fse-media.group/api" local termDev = term.current() local w, h = termDev.getSize() local Buttons = {} local UI = { bg = colors.black, panel = colors.gray, panelDark = colors.black, text = colors.white, muted = colors.lightGray, accent = colors.cyan, success = colors.lime, warning = colors.yellow, danger = colors.red, info = colors.lightBlue, action = colors.green, neutral = colors.gray, } local state = { page = "home", amount = 0, amountInput = "", paid = 0, finalBalance = nil, cardInfo = nil, } local function trim(value) return tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "") end local function readApiEndpointFile(path) if not fs.exists(path) then return nil end local f = fs.open(path, "r") if not f then return nil end local text = f.readAll() f.close() return trim(text) end local function resolveApiBase() local base = readApiEndpointFile("API_ENDPOINT_REFILL.txt") or readApiEndpointFile("API_ENDPOINT.txt") or DEFAULT_API_BASE base = trim(base) if base:match("/api$") then return base end return base:gsub("/+$", "") .. "/api" end local API_BASE = resolveApiBase() local function urlEncodeComponent(value) local s = tostring(value or "") return (s:gsub("([^%w%-_%.~])", function(c) return string.format("%%%02X", string.byte(c)) end)) end local function postJSON(url, payload) if not http then return false, "HTTP API disabled" end local okBody, body = pcall(textutils.serializeJSON, payload or {}) if not okBody or type(body) ~= "string" then return false, "serializeJSON failed" end local headers = { ["Content-Type"] = "application/json" } if http.post then local ok, res, err = pcall(http.post, url, body, headers) if not ok or not res then return false, tostring(err or res or "http.post failed") end local data = res.readAll and res.readAll() or "" res.close() local okJson, parsed = pcall(textutils.unserializeJSON, data or "") return true, okJson and parsed or data end local okReq, reqErr = pcall(http.request, { url = url, method = "POST", headers = headers, body = body }) if not okReq or not reqErr then return false, tostring(reqErr or "http.request failed") end while true do local ev, p1, p2, p3 = os.pullEvent() if ev == "http_success" and p1 == url then local res = p2 local data = res.readAll and res.readAll() or "" res.close() local okJson, parsed = pcall(textutils.unserializeJSON, data or "") return true, okJson and parsed or data end if ev == "http_failure" and p1 == url then local err = p2 local res = p3 if type(p2) == "table" and type(p2.readAll) == "function" then res = p2 err = p3 end if type(res) == "table" and type(res.readAll) == "function" then local data = res.readAll() res.close() return false, data end return false, tostring(err or "http_failure") end os.queueEvent(ev, p1, p2, p3) sleep(0) end end local function syncRefillResult(cardInfo, amount, balance) local cardId = trim(cardInfo and cardInfo.cardId or "") if #cardId == 0 then return false, "missing_card_id" end local url = API_BASE .. "/ic-cards/" .. urlEncodeComponent(cardId) .. "/sync" return postJSON(url, { type = "topup", device = "refill_machine", amount = tonumber(amount) or 0, balance = tonumber(balance) or 0, result = "pass", last_action = "topup" }) end local function refreshSize() w, h = termDev.getSize() end local function clearScreen() refreshSize() termDev.setBackgroundColor(colors.black) termDev.setTextColor(colors.white) termDev.clear() termDev.setCursorPos(1, 1) end local function centerText(y, text, color) text = tostring(text or "") termDev.setTextColor(color or colors.white) termDev.setCursorPos(math.max(1, math.floor((w - #text) / 2) + 1), y) termDev.write(text) end local function writeText(x, y, text, fg, bg) if bg then termDev.setBackgroundColor(bg) end if fg then termDev.setTextColor(fg) end termDev.setCursorPos(x, y) termDev.write(text) end local function fitText(text, maxLen) text = tostring(text or "") maxLen = math.max(1, tonumber(maxLen) or #text) if #text <= maxLen then return text end if maxLen <= 3 then return text:sub(1, maxLen) end return text:sub(1, maxLen - 3) .. "..." end local function drawFooterHint(text) centerText(h - 1, fitText(text, math.max(1, w - 2)), UI.muted) end local function addButton(x, y, label, bw, bh, bg, fg, onClick) Buttons[#Buttons + 1] = { x = x, y = y, w = bw, h = bh, onClick = onClick, } termDev.setBackgroundColor(bg) termDev.setTextColor(fg) for row = 0, bh - 1 do termDev.setCursorPos(x, y + row) termDev.write(string.rep(" ", bw)) end local labelX = x + math.max(0, math.floor((bw - #label) / 2)) local labelY = y + math.floor((bh - 1) / 2) termDev.setCursorPos(labelX, labelY) termDev.write(label) end local function inRect(btn, px, py) return px >= btn.x and px <= (btn.x + btn.w - 1) and py >= btn.y and py <= (btn.y + btn.h - 1) end local function handleTouch(x, y) for _, btn in ipairs(Buttons) do if inRect(btn, x, y) then if btn.onClick then btn.onClick() end return true end end return false end local function getRefillMachine() local sideType = peripheral.getType and peripheral.getType(MACHINE_SIDE) or nil if sideType == "ic_refill_machine" then local ok, dev = pcall(peripheral.wrap, MACHINE_SIDE) if ok and type(dev) == "table" then return dev end end local dev = peripheral.find("ic_refill_machine") if type(dev) == "table" then return dev end return nil end local function readCardInfo() local dev = getRefillMachine() if not dev or type(dev.getCardInfo) ~= "function" then return nil, "machine_unavailable" end local ok, info = pcall(dev.getCardInfo) if not ok or type(info) ~= "table" then return nil, "no_card" end local hasData = info.cardId ~= nil or info.ownerName ~= nil or info.balance ~= nil if not hasData then return nil, "no_card" end return { cardId = tostring(info.cardId or ""), ownerName = tostring(info.ownerName or "UNKNOWN"), balance = tonumber(info.balance) or 0, } end local function drawHeader(title, subTitle) clearScreen() centerText(2, fitText(title, math.max(1, w - 4)), UI.text) if subTitle and #tostring(subTitle) > 0 then centerText(3, fitText(subTitle, math.max(1, w - 4)), UI.info) end writeText(1, 1, VERSION, UI.muted, UI.bg) local hasMachine = getRefillMachine() ~= nil local status = hasMachine and "S* OK" or "S* ERR" local statusColor = hasMachine and UI.success or UI.danger local statusX = math.max(1, w - #status + 1) writeText(statusX, 1, status, statusColor, UI.bg) termDev.setBackgroundColor(UI.bg) termDev.setTextColor(UI.text) end local function getAmountFromInput() local value = tonumber(state.amountInput) if not value then return 0 end return math.max(0, math.floor(value)) end local function setAmount(value) value = math.max(0, math.floor(tonumber(value) or 0)) state.amount = value state.amountInput = value > 0 and tostring(value) or "" end local function appendDigit(ch) ch = tostring(ch or "") if not ch:match("^%d$") then return end if #state.amountInput >= 4 then return end if state.amountInput == "0" then state.amountInput = ch else state.amountInput = state.amountInput .. ch end state.amount = getAmountFromInput() end local function drawProgressBar(current, total, y) local barW = math.max(10, w - 10) local barX = math.floor((w - barW) / 2) + 1 local ratio = total <= 0 and 1 or math.min(1, current / total) local fill = math.floor(barW * ratio) termDev.setCursorPos(barX, y) termDev.setTextColor(colors.green) termDev.write(string.rep("=", fill)) termDev.setTextColor(colors.gray) termDev.write(string.rep("-", math.max(0, barW - fill))) end local function resetFlowToHome() state.amount = 0 state.amountInput = "" state.paid = 0 state.finalBalance = nil state.cardInfo = nil state.page = "home" end local function waitForEventWithTimer(seconds) local timer = os.startTimer(seconds) while true do local ev, p1, p2, p3 = os.pullEvent() if ev == "timer" and p1 == timer then return "timer" end if ev == "mouse_click" or ev == "monitor_touch" then return ev, p1, p2, p3 end if ev == "redstone" or ev == "term_resize" or ev == "peripheral" or ev == "peripheral_detach" then return ev, p1, p2, p3 end end end local function drawHomePage() drawHeader("FSE Refill Machine", "IC Card Balance Refill") Buttons = {} local btnW = 18 local btnH = 5 local btnX = math.floor((w - btnW) / 2) + 1 local btnY = math.floor((h - btnH) / 2) addButton(btnX, btnY, "START REFILL", btnW, btnH, UI.action, UI.text, function() state.amount = 0 state.amountInput = "" state.paid = 0 state.finalBalance = nil state.cardInfo = nil state.page = "amount" end) drawFooterHint("Touch the button to start") end local function drawAmountPage() drawHeader("Select Refill Amount", "Preset amount or keyboard input") Buttons = {} local displayText = state.amountInput ~= "" and state.amountInput or "0" local boxW = math.min(20, w - 8) local boxX = math.floor((w - boxW) / 2) + 1 local presetY = 8 local presetW = 8 local gap = 2 local totalPresetW = (presetW * 4) + (gap * 3) local presetX = math.floor((w - totalPresetW) / 2) + 1 local notesY = presetY + 4 local clearY = notesY + 3 local bottomY = h - 3 if clearY + 2 >= bottomY then clearY = math.max(notesY + 2, bottomY - 4) end termDev.setBackgroundColor(colors.gray) termDev.setTextColor(colors.yellow) termDev.setCursorPos(boxX, 5) termDev.write(string.rep(" ", boxW)) termDev.setCursorPos(boxX + boxW - #displayText - 2, 5) termDev.write(displayText) termDev.setBackgroundColor(colors.black) centerText(4, "Amount", colors.white) for index, amount in ipairs(PRESET_AMOUNTS) do local x = presetX + (index - 1) * (presetW + gap) local selected = getAmountFromInput() == amount addButton(x, presetY, tostring(amount), presetW, 3, selected and colors.green or colors.gray, colors.white, function() setAmount(amount) end) end centerText(notesY, "Custom amount: type on keyboard", colors.lightGray) centerText(notesY + 1, "Enter to continue, Backspace to delete", colors.lightGray) addButton(math.floor((w - 10) / 2) + 1, clearY, "CLEAR", 10, 3, colors.red, colors.white, function() setAmount(0) end) addButton(2, h - 3, "BACK", 8, 3, colors.red, colors.white, function() state.page = "home" end) addButton(w - 11, h - 3, "NEXT", 10, 3, getAmountFromInput() > 0 and colors.green or colors.gray, colors.black, function() local amount = getAmountFromInput() if amount > 0 then state.amount = amount state.paid = 0 state.page = "payment" end end) end local function drawPaymentPage() drawHeader("Payment", "Insert payment from RIGHT side") Buttons = {} centerText(6, "Refill Amount: " .. tostring(state.amount), colors.yellow) centerText(8, "Paid: " .. tostring(state.paid), colors.white) centerText(9, "Remaining: " .. tostring(math.max(0, state.amount - state.paid)), colors.red) drawProgressBar(state.paid, state.amount, 11) centerText(13, "1 redstone pulse = 1 amount", colors.lightGray) centerText(14, "Auto continue after full payment", colors.lightGray) addButton(2, h - 3, "BACK", 8, 3, colors.red, colors.white, function() state.page = "amount" end) end local function drawWaitingCardPage(cardDetected) drawHeader("Refill Processing", "Please keep the IC card inserted") Buttons = {} if cardDetected then centerText(7, "Refilling, Please do not remove the IC card", colors.yellow) if state.cardInfo then centerText(10, "Card ID: " .. tostring(state.cardInfo.cardId ~= "" and state.cardInfo.cardId or "UNKNOWN"), colors.cyan) centerText(12, "Current: " .. tostring(state.cardInfo.balance), colors.white) centerText(13, "Add: " .. tostring(state.amount), colors.lightBlue) centerText(14, "Target: " .. tostring(state.cardInfo.balance + state.amount), colors.green) end else centerText(8, "Please Insert IC card", colors.red) centerText(11, "Hold card and right click to insert", colors.lightGray) centerText(12, "The program will continue automatically", colors.lightGray) end addButton(2, h - 3, "CANCEL", 10, 3, colors.red, colors.white, function() resetFlowToHome() end) end local function drawDonePage() drawHeader("Refill Complete", "Refill completed") centerText(math.floor(h / 2) - 1, "DONE", colors.lime) if state.finalBalance ~= nil then centerText(math.floor(h / 2) + 1, "New Balance: " .. tostring(state.finalBalance), colors.white) end end local function drawErrorPage(message) drawHeader("Refill Failed", "Please try again") centerText(math.floor(h / 2), tostring(message or "Unknown error"), colors.red) end local function homeLoop() while state.page == "home" do drawHomePage() local ev, p1, p2, p3 = os.pullEvent() if ev == "mouse_click" or ev == "monitor_touch" then handleTouch(p2, p3) elseif ev == "term_resize" then refreshSize() end end end local function amountLoop() while state.page == "amount" do drawAmountPage() local ev, p1, p2, p3 = os.pullEvent() if ev == "mouse_click" or ev == "monitor_touch" then handleTouch(p2, p3) elseif ev == "char" and tostring(p1):match("%d") then appendDigit(p1) elseif ev == "key" and p1 == keys.backspace then state.amountInput = state.amountInput:sub(1, -2) state.amount = getAmountFromInput() elseif ev == "key" and (p1 == keys.enter or p1 == keys.numPadEnter) then if getAmountFromInput() > 0 then state.amount = getAmountFromInput() state.paid = 0 state.page = "payment" end elseif ev == "term_resize" then refreshSize() end end end local function paymentLoop() state.paid = 0 local lastSignal = redstone.getInput(PAYMENT_SIDE) while state.page == "payment" do drawPaymentPage() if state.paid >= state.amount then sleep(0.3) state.page = "refill" return end local ev, p1, p2, p3 = os.pullEvent() if ev == "redstone" then local now = redstone.getInput(PAYMENT_SIDE) if now and not lastSignal then state.paid = state.paid + 1 end lastSignal = now elseif ev == "mouse_click" or ev == "monitor_touch" then handleTouch(p2, p3) elseif ev == "term_resize" then refreshSize() end end end local function refillLoop() while state.page == "refill" do local dev = getRefillMachine() if not dev then drawErrorPage("Refill machine not found on bottom") sleep(2) state.page = "home" return end local info = readCardInfo() if not info then drawWaitingCardPage(false) local ev, p1, p2, p3 = waitForEventWithTimer(0.2) if ev == "mouse_click" or ev == "monitor_touch" then handleTouch(p2, p3) elseif ev == "term_resize" then refreshSize() end else state.cardInfo = info drawWaitingCardPage(true) sleep(0.2) local currentBalance = tonumber(info.balance) or 0 local refillAmount = tonumber(state.amount) or 0 local expectedBalance = currentBalance + refillAmount local okCall, okRefill, newBalance = pcall(dev.refill, refillAmount) if okCall and okRefill then state.finalBalance = tonumber(newBalance) or expectedBalance state.cardInfo.balance = state.finalBalance syncRefillResult(state.cardInfo, refillAmount, state.finalBalance) state.page = "done" return end local errMessage = okCall and tostring(newBalance or "refill_failed") or "refill_call_failed" drawErrorPage(errMessage) sleep(8) state.page = "home" return end end end local function doneLoop() drawDonePage() sleep(3) resetFlowToHome() end while true do if state.page == "home" then homeLoop() elseif state.page == "amount" then amountLoop() elseif state.page == "payment" then paymentLoop() elseif state.page == "refill" then refillLoop() elseif state.page == "done" then doneLoop() else state.page = "home" end end