Files
FSE-Ticket.sys/refillmachine.lua
T

581 lines
16 KiB
Lua
Raw Normal View History

2026-06-21 10:00:13 +08:00
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)
2026-06-21 10:00:13 +08:00
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