初始提交
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
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(2)
|
||||
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
|
||||
Reference in New Issue
Block a user