local CURRENT_STATION_CODE = 'Ticket-Machine' local API_BASE = 'http://ticket.fse-media.group/api' local VERSION = 'v1.5.7' -- ########################### -- Core HTTP & JSON Utilities -- ########################### local function jsonDecode(str) if not str then return nil end local ok, res = pcall(textutils.unserializeJSON, str) if ok then return res else return nil end end local serverConnected = nil local serverLastChangeTs = 0 local function setServerConnected(ok) if serverConnected == ok then return end serverConnected = ok serverLastChangeTs = (os.epoch and os.epoch('utc')) or (os.time() * 1000) os.queueEvent('config_updated') end local function fetchHTTP(url) if not http then print('Error: HTTP API is disabled in ComputerCraft config!') setServerConnected(false) return nil end if http.get then local ok, res, err = pcall(http.get, url) if not ok or not res then if err then print('HTTP GET failed: ' .. tostring(err) .. ' url=' .. tostring(url)) end setServerConnected(false) return nil end local data = res.readAll() res.close() setServerConnected(true) return data end if not http.request then print('Error: HTTP API is disabled in ComputerCraft config!') return nil end local okReq, reqOkOrErr = pcall(http.request, { url = url, method = 'GET' }) if not okReq or not reqOkOrErr then print('HTTP request failed: ' .. tostring(reqOkOrErr) .. ' url=' .. tostring(url)) setServerConnected(false) return nil end while true do local ev, u, p1, p2 = os.pullEvent() if ev == 'http_success' and u == url then local res = p1 local data = res.readAll() res.close() setServerConnected(true) return data elseif ev == 'http_failure' and u == url then print('HTTP GET failed: ' .. tostring(p1 or p2 or 'http_failure') .. ' url=' .. tostring(url)) setServerConnected(false) return nil else os.queueEvent(ev, u, p1, p2) sleep(0) end end end local function fetchJSON(url) local txt = fetchHTTP(url) if not txt then return nil end return jsonDecode(txt) end local function ensureDir(path) local dir = path:match('^(.+)/[^/]+$') if dir and not fs.exists(dir) then pcall(fs.makeDir, dir) end end local function postJSONResult(url, dataTable) if not http then setServerConnected(false) return false, nil, nil, 'HTTP API disabled' end local okBody, body = pcall(textutils.serializeJSON, dataTable) if not okBody or type(body) ~= 'string' then return false, nil, nil, '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 setServerConnected(false) return false, nil, nil, tostring(err or res or 'http.post error') end local code = res.getResponseCode and res.getResponseCode() or nil local respBody = res.readAll and res.readAll() or nil res.close() setServerConnected(true) return true, code, respBody, nil end if not http.request then setServerConnected(false) return false, nil, nil, 'HTTP API disabled' end local okReq, reqOkOrErr = pcall(http.request, { url = url, method = 'POST', body = body, headers = headers }) if not okReq or not reqOkOrErr then setServerConnected(false) return false, nil, nil, tostring(reqOkOrErr or 'http.request failed') end while true do local ev, u, p1, p2 = os.pullEvent() if ev == 'http_success' and u == url then local res = p1 local code = res.getResponseCode and res.getResponseCode() or nil local respBody = res.readAll and res.readAll() or nil res.close() setServerConnected(true) return true, code, respBody, nil elseif ev == 'http_failure' and u == url then setServerConnected(false) return false, nil, nil, tostring(p1 or p2 or 'http_failure') else os.queueEvent(ev, u, p1, p2) sleep(0) end end end local function postJSON(url, dataTable) local ok, code, body, err = postJSONResult(url, dataTable) if not ok or not code then return false, code, nil, tostring(err or 'request error') end local parsed = jsonDecode(body) if code < 200 or code >= 300 then local msg = nil if type(parsed) == 'table' then msg = parsed.error or parsed.message or parsed.reason end if not msg then msg = tostring(body or ('HTTP ' .. tostring(code))) end return false, code, parsed, msg end return true, code, parsed, nil end local function unicodeEscape(str) if type(str) ~= 'string' then return str end local out = {} local okUtf8 = type(utf8) == 'table' and type(utf8.codes) == 'function' if okUtf8 then for _, cp in utf8.codes(str) do if cp <= 0x7F then out[#out+1] = string.char(cp) elseif cp <= 0xFFFF then out[#out+1] = string.format("\\u%04x", cp) else local v = cp - 0x10000 local hi = 0xD800 + math.floor(v / 0x400) local lo = 0xDC00 + (v % 0x400) out[#out+1] = string.format("\\u%04x\\u%04x", hi, lo) end end return table.concat(out) end local i = 1 while i <= #str do local b1 = string.byte(str, i) if not b1 then break end if b1 <= 0x7F then out[#out+1] = string.char(b1) i = i + 1 elseif b1 >= 0xC0 and b1 <= 0xDF then local b2 = string.byte(str, i + 1) or 0 local cp = (b1 % 0x20) * 0x40 + (b2 % 0x40) out[#out+1] = string.format("\\u%04x", cp) i = i + 2 elseif b1 >= 0xE0 and b1 <= 0xEF then local b2 = string.byte(str, i + 1) or 0 local b3 = string.byte(str, i + 2) or 0 local cp = (b1 % 0x10) * 0x1000 + (b2 % 0x40) * 0x40 + (b3 % 0x40) out[#out+1] = string.format("\\u%04x", cp) i = i + 3 else local b2 = string.byte(str, i + 1) or 0 local b3 = string.byte(str, i + 2) or 0 local b4 = string.byte(str, i + 3) or 0 local cp = (b1 % 0x08) * 0x40000 + (b2 % 0x40) * 0x1000 + (b3 % 0x40) * 0x40 + (b4 % 0x40) local v = cp - 0x10000 local hi = 0xD800 + math.floor(v / 0x400) local lo = 0xDC00 + (v % 0x400) out[#out+1] = string.format("\\u%04x\\u%04x", hi, lo) i = i + 4 end end return table.concat(out) end local function firstString(...) for i = 1, select('#', ...) do local v = select(i, ...) if v ~= nil then local s = tostring(v):gsub('^%s+', ''):gsub('%s+$', '') if #s > 0 then return s end end end return '' end local function firstNumber(...) for i = 1, select('#', ...) do local v = select(i, ...) if v ~= nil then local n = tonumber(v) if n ~= nil then return n end end end return nil end local function currentDeviceId() local dev = nil if os.getComputerLabel then dev = os.getComputerLabel() end if not dev and os.getComputerID then dev = tostring(os.getComputerID()) end return tostring(dev or 'unknown') end local function normalizeTrainTypeLabel(v) local s = tostring(v or ''):lower() if s == 'express' or s == 'limited_express' or s == 'limited express' then return 'Express' end return 'Local' end local PENDING_UPLOAD_PATH = 'logs/pending_ticket_upload.jsonl' local pendingUploads = {} local pendingUploadsLoaded = false local function loadPendingUploadsOnce() if pendingUploadsLoaded then return end pendingUploadsLoaded = true if not fs.exists(PENDING_UPLOAD_PATH) then return end for line in io.lines(PENDING_UPLOAD_PATH) do local obj = jsonDecode(line) if type(obj) == 'table' then table.insert(pendingUploads, obj) end end end local function savePendingUploads() ensureDir(PENDING_UPLOAD_PATH) local f = fs.open(PENDING_UPLOAD_PATH, 'w') if not f then print('Save pending upload failed: cannot open ' .. tostring(PENDING_UPLOAD_PATH)) return false end for _, obj in ipairs(pendingUploads) do local okSer, s = pcall(textutils.serializeJSON, obj) if okSer and type(s) == 'string' then f.write(s .. "\n") end end f.close() return true end local function uploadTicketRecord(ticketData) local url = API_BASE .. '/tickets/sale' local payload = { ticket_id = tostring((type(ticketData) == 'table' and (ticketData.ticket_id or ticketData.id)) or ''), start = tostring((type(ticketData) == 'table' and (ticketData.start or ticketData.start_station_id or ticketData.start_station)) or ''), terminal = tostring((type(ticketData) == 'table' and (ticketData.terminal or ticketData.terminal_station_id or ticketData.end_station)) or ''), train_type = tostring((type(ticketData) == 'table' and (ticketData.train_type or ticketData.trainType or ticketData.type)) or ''), cost = (type(ticketData) == 'table' and tonumber(ticketData.cost)) or 0, station_code = tostring((type(ticketData) == 'table' and (ticketData.station_code or ticketData.stationCode)) or CURRENT_STATION_CODE or ''), device = tostring((type(ticketData) == 'table' and (ticketData.device or ticketData.device_id or ticketData.deviceId)) or 'unknown'), trips_total = (type(ticketData) == 'table' and (ticketData.trips_total or ticketData.rides_total or ticketData.rides)) or nil, trips_remaining = (type(ticketData) == 'table' and (ticketData.trips_remaining or ticketData.rides_remaining or ticketData.rides)) or nil } local ok, code, body, err = postJSONResult(url, payload) if not ok or not code then print('Upload ticket failed: ' .. tostring(err or 'request error') .. ' url=' .. tostring(url)) return false end if code < 200 or code >= 300 then print('Upload ticket failed: HTTP ' .. tostring(code) .. ' ' .. tostring(body or '') .. ' url=' .. tostring(url)) return false end return true end local function enqueueTicketUpload(ticketData) loadPendingUploadsOnce() if uploadTicketRecord(ticketData) then return true end table.insert(pendingUploads, ticketData) local okSave = savePendingUploads() if okSave then print('Ticket upload queued. pending=' .. tostring(#pendingUploads)) else print('Ticket upload queued in memory only. pending=' .. tostring(#pendingUploads)) end return false end -- ########################### -- Peripheral discovery -- ########################### local monitor = peripheral.find('monitor') local ticketVendingMachine = peripheral.find('ticket_vending_machine') local speaker = peripheral.find('speaker') local MOD_DEBUG = true pcall(math.randomseed, (os.epoch and os.epoch('utc')) or os.time()) local function safe(term) if monitor then return peripheral.wrap(peripheral.getName(monitor)) end return term end local termDev = safe(term) if monitor then pcall(monitor.setTextScale, 0.5) end local w, h = termDev.getSize() local function saveCardIssueSnapshot(cardData) pcall(function() ensureDir('logs/last_card_issue.json') local okSer, s = pcall(textutils.serializeJSON, cardData) if okSer and type(s) == 'string' then local f = fs.open('logs/last_card_issue.json', 'w') if f then f.write(s); f.close() end end end) end local function extractPeripheralId(...) for i = 1, select('#', ...) do local v = select(i, ...) if type(v) == 'string' or type(v) == 'number' then local s = tostring(v):gsub('^%s+', ''):gsub('%s+$', '') if #s > 0 then return s end end end return '' end local function peripheralCallSucceeded(r1) return r1 ~= nil and r1 ~= false end local function callPeripheralMethods(dev, methodNames, variants) if type(dev) ~= 'table' then return false, 'peripheral_unavailable' end for _, methodName in ipairs(methodNames) do local fn = dev[methodName] if type(fn) == 'function' then for _, args in ipairs(variants) do local okCall, r1, r2, r3 = pcall(fn, table.unpack(args)) if okCall and peripheralCallSucceeded(r1) then return true, methodName, r1, r2, r3 end end end end return false, 'unsupported_method' end local function issueBlankICCard(holderName, initialBalance) local dev = ticketVendingMachine if type(dev) ~= 'table' then return false, '', 'peripheral_unavailable' end local safeHolderName = firstString(holderName, 'CARD USER') local safeInitialBalance = math.max(0, math.floor(tonumber(initialBalance) or 0)) for _, methodName in ipairs({ 'issueICCard', 'issueCard' }) do local fn = dev[methodName] if type(fn) == 'function' then local okCall, r1, r2, r3 = pcall(fn, safeHolderName, safeInitialBalance) if not (okCall and peripheralCallSucceeded(r1)) then okCall, r1, r2, r3 = pcall(fn, safeHolderName) end if not (okCall and peripheralCallSucceeded(r1)) then okCall, r1, r2, r3 = pcall(fn) end if okCall and peripheralCallSucceeded(r1) then return true, extractPeripheralId(r1, r2, r3), methodName end end end return false, '', 'unsupported_method' end local function writeICCard(cardData) local dev = ticketVendingMachine local payload = {} for k, v in pairs(cardData or {}) do payload[k] = v end payload.media = payload.media or 'ic_card' payload.product_type = payload.product_type or 'stored_value' payload.device = payload.device or currentDeviceId() payload.initial_balance = payload.initial_balance or payload.topup or payload.balance or 0 payload.owner = payload.owner or payload.holder_name or '' payload.card_holder = payload.card_holder or payload.holder_name or '' _G.TICKET_MACHINE_LAST_TICKET = payload saveCardIssueSnapshot(payload) local okWrite, methodName, r1, r2, r3 = callPeripheralMethods(dev, { 'issueCard', 'writeCard', 'writeICCard', 'issueTicketData', 'writeTicketData', 'issueICCard' }, { { payload }, { tostring(payload.holder_name or ''), tonumber(payload.initial_balance) or 0 }, { tostring(payload.holder_name or '') }, { tostring(payload.card_id or ''), tostring(payload.holder_name or ''), tonumber(payload.balance) or 0, tonumber(payload.deposit) or 0, tostring(payload.station_code or ''), tostring(payload.product_type or 'stored_value') }, { tostring(payload.holder_name or ''), tonumber(payload.balance) or 0, tonumber(payload.deposit) or 0 }, {} }) if okWrite then local issuedId = extractPeripheralId(r1, r2, r3, payload.card_id) if #issuedId > 0 then payload.card_id = issuedId end return true, payload, methodName end return false, payload, methodName end local function submitCardOpen(payload) return postJSON(API_BASE .. '/cards/open', payload) end -- ########################### -- Audio Utilities & Playback -- ########################### local function stopAudio() end local function waitAudioComplete() end local function playNote(instrument, pitch, volume, dur) if not speaker then return end instrument = tostring(instrument or 'pling') local p = tonumber(pitch) if p == nil then p = 12 end p = math.max(0, math.min(24, math.floor(p))) local v = tonumber(volume) if v == nil then v = 1 end if v < 0 then v = 0 end local ok = pcall(function() speaker.playNote(instrument, v, p) end) if ok and dur and dur > 0 then sleep(dur) end end local function playMelody(instrument, notes, volume, noteDur, gap) if not speaker then return end instrument = tostring(instrument or 'harp') local v = tonumber(volume); if v == nil then v = 1 end local nd = tonumber(noteDur); if nd == nil then nd = 0.04 end local g = tonumber(gap); if g == nil then g = 0.02 end if type(notes) ~= 'table' then return end for i = 1, #notes do playNote(instrument, notes[i], v, nd) if g > 0 then sleep(g) end end end local function playConfirmTicketMelody() playMelody('bell', { 19, 22, 24, 22, 19 }, 1, 0.04, 0.02) end local function playAudioFile(path) local p = tostring(path or ''):lower() if p:find('welcome') then playMelody('chime', { 12, 16, 19, 24 }, 1, 0.05, 0.02) return end end local function clickSound() end -- ########################### -- Background Tasks -- ########################### local function backgroundSyncTask() while true do sleep(5) pcall(refreshConfigOnce) end end local function backgroundTicketUploadTask() loadPendingUploadsOnce() local backoff = 2 while true do if #pendingUploads > 0 then if uploadTicketRecord(pendingUploads[1]) then table.remove(pendingUploads, 1) savePendingUploads() backoff = 2 else backoff = math.min(60, math.floor(backoff * 1.5)) end end sleep(backoff) end end -- ########################### -- Data logic & Config -- ########################### local CFG = { stations = {}, lines = {}, fares = {}, transfers = {} } local stationByCode = {} local adjacency_regular, adjacency_express = {}, {} local transferGroupByCode = {} local function normalizeCode(s) s = tostring(s or '') s = s:gsub('[\239\187\191]', ''):gsub('%s+', '') return s end local function rebuildMaps() stationByCode = {} for _, s in ipairs(CFG.stations or {}) do if type(s) == 'table' then local code = normalizeCode(s.code or s.id) if #code > 0 then s.code = code if s.en_name == nil then s.en_name = s.en or s.enName or s.name_en or s.english_name end stationByCode[code] = s end end end adjacency_regular, adjacency_express = {}, {} local transferAdj = {} local function addTransfer(a, b) a = normalizeCode(a) b = normalizeCode(b) if #a == 0 or #b == 0 or a == b then return end adjacency_regular[a] = adjacency_regular[a] or {}; adjacency_regular[a][b] = 0 adjacency_regular[b] = adjacency_regular[b] or {}; adjacency_regular[b][a] = 0 adjacency_express[a] = adjacency_express[a] or {}; adjacency_express[a][b] = 0 adjacency_express[b] = adjacency_express[b] or {}; adjacency_express[b][a] = 0 transferAdj[a] = transferAdj[a] or {}; transferAdj[a][b] = true transferAdj[b] = transferAdj[b] or {}; transferAdj[b][a] = true end for _, e in ipairs(CFG.fares or {}) do local cr = e.cost_regular or e.cost or 0 local ce = e.cost_express or e.cost or 0 adjacency_regular[e.from] = adjacency_regular[e.from] or {} adjacency_regular[e.from][e.to] = cr adjacency_regular[e.to] = adjacency_regular[e.to] or {} adjacency_regular[e.to][e.from] = cr adjacency_express[e.from] = adjacency_express[e.from] or {} adjacency_express[e.from][e.to] = ce adjacency_express[e.to] = adjacency_express[e.to] or {} adjacency_express[e.to][e.from] = ce end for _, p in ipairs(CFG.transfers or {}) do addTransfer(p[1], p[2]) end for _, s in ipairs(CFG.stations or {}) do if type(s) == 'table' and s.transfer_enabled and type(s.transfer_to) == 'table' then local from = normalizeCode(s.code or s.id) for _, t in ipairs(s.transfer_to) do local to = t if type(t) == 'table' then to = t.code or t.station or t.id or t[1] end addTransfer(from, to) end end end local groups = {} for _, s in ipairs(CFG.stations or {}) do if type(s) == 'table' then local cn = tostring(s.name or s.cn_name or ''):gsub('%s+', '') local en = tostring(s.en_name or s.en or s.enName or ''):lower():gsub('%s+', '') local code = normalizeCode(s.code or s.id) if #cn > 0 and #en > 0 and #code > 0 then local k = cn .. '|' .. en groups[k] = groups[k] or {} table.insert(groups[k], code) end end end for _, arr in pairs(groups) do if #arr >= 2 then for i = 1, #arr do for j = i + 1, #arr do local a, b = tostring(arr[i] or ''), tostring(arr[j] or '') if #a > 0 and #b > 0 and a ~= b then addTransfer(a, b) end end end end end transferGroupByCode = {} local visited = {} local groupId = 0 local function flood(start) groupId = groupId + 1 local q = { start } visited[start] = true while #q > 0 do local u = table.remove(q, 1) transferGroupByCode[u] = groupId for v, _ in pairs(transferAdj[u] or {}) do if not visited[v] then visited[v] = true table.insert(q, v) end end end end for code, _ in pairs(stationByCode) do if not visited[code] then flood(code) end end for code, _ in pairs(transferAdj) do if not visited[code] then flood(code) end end end local function sameLogicalStation(a, b) a = normalizeCode(a) b = normalizeCode(b) if a == b then return true end local ga = transferGroupByCode[a] local gb = transferGroupByCode[b] return ga ~= nil and ga == gb end local function fetchConfig() local txt = fetchHTTP(API_BASE .. '/config') return txt and jsonDecode(txt) or nil end local function refreshConfigOnce() local cfg = fetchConfig() if not cfg then return false end local f = fs.open('config.json', 'w') if f then f.write(textutils.serializeJSON(cfg)); f.close() end CFG = cfg rebuildMaps() os.queueEvent('config_updated') return true end local function loadConfig() local cfg = fetchConfig() if cfg then local f = fs.open('config.json', 'w') if f then f.write(textutils.serializeJSON(cfg)); f.close() end return cfg end if fs.exists('config.json') then local f = fs.open('config.json', 'r') local c = f.readAll(); f.close() return jsonDecode(c) end return nil end CFG = loadConfig() or CFG rebuildMaps() -- ########################### -- UI Helpers -- ########################### local CC_PALETTE = { {name='white', val=colors.white, rgb={0xF2,0xF2,0xF2}}, {name='orange', val=colors.orange, rgb={0xF2,0xB2,0x33}}, {name='magenta', val=colors.magenta, rgb={0xE5,0x7F,0xD8}}, {name='lightBlue', val=colors.lightBlue, rgb={0x99,0xB2,0xF2}}, {name='yellow', val=colors.yellow, rgb={0xDE,0xDE,0x6C}}, {name='lime', val=colors.lime, rgb={0x7F,0xCC,0x19}}, {name='pink', val=colors.pink, rgb={0xF2,0xB2,0xCC}}, {name='gray', val=colors.gray, rgb={0x4C,0x4C,0x4C}}, {name='lightGray', val=colors.lightGray, rgb={0x99,0x99,0x99}}, {name='cyan', val=colors.cyan, rgb={0x4C,0x99,0xB2}}, {name='purple', val=colors.purple, rgb={0xB2,0x66,0xE5}}, {name='blue', val=colors.blue, rgb={0x33,0x66,0xCC}}, {name='brown', val=colors.brown, rgb={0x7F,0x66,0x4C}}, {name='green', val=colors.green, rgb={0x57,0xA6,0x4E}}, {name='red', val=colors.red, rgb={0xCC,0x4C,0x4C}}, {name='black', val=colors.black, rgb={0x11,0x11,0x11}}, } local function nearestCCColor(val) if type(val) ~= 'string' then return colors.gray end if val:sub(1,1) == '#' then local hex = val:sub(2) local r, g, b = tonumber(hex:sub(1,2), 16), tonumber(hex:sub(3,4), 16), tonumber(hex:sub(5,6), 16) if not r then return colors.gray end local bestD, bestV = math.huge, colors.gray for _,c in ipairs(CC_PALETTE) do local d = (r-c.rgb[1])^2 + (g-c.rgb[2])^2 + (b-c.rgb[3])^2 if d < bestD then bestD, bestV = d, c.val end end return bestV end for _,c in ipairs(CC_PALETTE) do if c.name == val then return c.val end end return colors.gray end local function clear() termDev.setBackgroundColor(colors.black) termDev.clear() termDev.setCursorPos(1,1) end local function centerText(y, text, color) termDev.setTextColor(color or colors.white) local x = math.max(1, math.floor((w - #text) / 2)) termDev.setCursorPos(x, y) termDev.write(text) end local function drawRainbowLabelRow(y, text, fg) local palette = { colors.red, colors.orange, colors.yellow, colors.lime, colors.green, colors.cyan, colors.blue, colors.purple, colors.magenta } local x = math.max(1, math.floor((w - #text) / 2)) termDev.setTextColor(fg or colors.white) for i = 1, #text do termDev.setBackgroundColor(palette[((i - 1) % #palette) + 1]) termDev.setCursorPos(x + i - 1, y) termDev.write(text:sub(i, i)) end termDev.setBackgroundColor(colors.black) end local Buttons = {} local function addButton(x, y, label, wBtn, hBtn, colorsPair, onClick) table.insert(Buttons, { x=x, y=y, w=wBtn, h=hBtn, onClick=onClick }) termDev.setBackgroundColor(colorsPair[1]); termDev.setTextColor(colorsPair[2]) for i=0,hBtn-1 do termDev.setCursorPos(x, y+i); termDev.write(string.rep(' ', wBtn)) end termDev.setCursorPos(x + math.floor((wBtn - #label)/2), y + math.floor(hBtn/2)) 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 waitButtons() while true do local ev, p1, p2, p3 = os.pullEvent() if ev == 'mouse_click' or ev == 'monitor_touch' then -- For mouse_click: p1=button, p2=x, p3=y -- For monitor_touch: p1=side, p2=x, p3=y for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; return end end elseif ev == 'config_updated' then -- If config was updated in background, return to force UI re-render return end end end -- ########################### -- Pages & Navigation -- ########################### local state = { page = 'home', stationName = (CFG.current_station and (CFG.current_station.en_name or CFG.current_station.name)) or 'Station', stationCode = (CFG.current_station and CFG.current_station.code) or CURRENT_STATION_CODE, departure = nil, terminal = nil, trainType = nil, trips = 1, cost = 0, paid = 0, doneAudioPlayed = false, productMode = 'ticket', cardMode = nil, cardPaymentMode = 'local', holderName = '', cardDeposit = 0, cardTopup = 0, cardBalance = 0, cardOrderValue = 0, card_id = nil, card_server_data = nil, pendingBlankCardId = nil } local function getCardConfig() local cfg = CFG.card or CFG.ic_card or {} local minTopup = math.max(1, math.floor(firstNumber(cfg.first_topup_min, cfg.min_first_topup, cfg.min_topup, 10) or 10)) local quickSrc = type(cfg.quick_amounts) == 'table' and cfg.quick_amounts or { 10, 20, 50, 100 } local quick = {} for _, v in ipairs(quickSrc) do local n = tonumber(v) if n and n > 0 then quick[#quick + 1] = math.floor(n) end end if #quick == 0 then quick = { 10, 20, 50, 100 } end return { deposit = 0, min_topup = minTopup, quick_amounts = quick } end local function resetTicketFlow() state.productMode = 'ticket' state.cardMode = nil state.cardPaymentMode = 'local' state.departure, state.terminal, state.trainType = nil, nil, nil state.trips, state.cost, state.paid, state.doneAudioPlayed = 1, 0, 0, false state.voucher_code = nil state.holderName = '' state.cardDeposit = 0 state.cardTopup = 0 state.cardBalance = 0 state.cardOrderValue = 0 state.card_id = nil state.card_server_data = nil state.pendingBlankCardId = nil end local function resetCardFlow(mode) local cardCfg = getCardConfig() state.productMode = 'card' state.cardMode = mode or 'open' state.cardPaymentMode = 'local' state.departure, state.terminal, state.trainType = nil, nil, nil state.trips, state.paid, state.doneAudioPlayed = 1, 0, 0, false state.voucher_code = nil state.holderName = '' state.cardDeposit = cardCfg.deposit state.cardTopup = cardCfg.min_topup state.cardBalance = cardCfg.min_topup state.cardOrderValue = state.cardTopup state.cost = state.cardOrderValue state.card_id = nil state.card_server_data = nil state.pendingBlankCardId = nil end local function isCardOrderLike(data) if type(data) ~= 'table' then return false end local kind = firstString( data.order_type, data.type, data.kind, data.media, data.media_type, data.product, data.product_type, data.ticket_type, data.service_type ):lower() if kind:find('card', 1, true) or kind:find('stored', 1, true) or kind:find('wallet', 1, true) then return true end if kind:find('ic', 1, true) then return true end if type(data.card) == 'table' or type(data.ic_card) == 'table' then return true end return firstString(data.holder_name, data.holderName, data.passenger_name, data.passengerName) ~= '' end local function buildCardOrderState(data, voucherCode) if not isCardOrderLike(data) then return nil end local cardCfg = getCardConfig() local totalValue = math.max(0, math.floor(firstNumber( data.order_value, data.total_value, data.price, data.cost, data.amount, type(data.card) == 'table' and data.card.order_value or nil, type(data.ic_card) == 'table' and data.ic_card.order_value or nil, cardCfg.min_topup ) or cardCfg.min_topup)) local topup = math.max(0, math.floor(firstNumber( data.topup, data.first_topup, data.recharge, data.initial_balance, type(data.card) == 'table' and data.card.topup or nil, type(data.ic_card) == 'table' and data.ic_card.topup or nil, totalValue ) or totalValue)) local balance = math.max(0, math.floor(firstNumber( data.balance, data.stored_value, data.wallet_balance, type(data.card) == 'table' and data.card.balance or nil, type(data.ic_card) == 'table' and data.ic_card.balance or nil, topup ) or topup)) return { holderName = firstString( data.holder_name, data.holderName, data.passenger_name, data.passengerName, type(data.card) == 'table' and data.card.holder_name or nil, type(data.ic_card) == 'table' and data.ic_card.holder_name or nil, 'CARD USER' ), deposit = 0, topup = topup, balance = balance, orderValue = totalValue, voucher = voucherCode, raw = data } end local function stationDisplay(code) code = normalizeCode(code) if #code == 0 then return '' end local st = stationByCode[code] if st then return tostring(st.en_name or st.name or code) .. ' ' .. code end return code end local function drawVersionIndicator() if w < 1 then return end termDev.setBackgroundColor(colors.black) termDev.setTextColor(colors.gray) termDev.setCursorPos(1, 1) termDev.write(tostring(VERSION)) termDev.setTextColor(colors.white) end local function drawServerStatusIndicator() if w < 2 then return end local col = colors.yellow if serverConnected == true then col = colors.lime elseif serverConnected == false then col = colors.red end termDev.setBackgroundColor(colors.black) termDev.setTextColor(col) termDev.setCursorPos(w-1, 1) termDev.write('S') termDev.setCursorPos(w, 1) termDev.write('*') termDev.setTextColor(colors.white) end local function drawHeader(title, sub, hideStationLabel) clear() drawVersionIndicator() drawServerStatusIndicator() centerText(2, title, colors.white) if sub and #tostring(sub) > 0 then centerText(3, tostring(sub), colors.lightBlue) end end local ui_cancel_request, ui_cancel_confirmed = false, false local function renderConfirmCancel() local boxW, boxH = math.max(24, math.min(32, w-4)), 6 local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1 termDev.setBackgroundColor(colors.gray) for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end local msg = 'Cancel this purchase?' if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg) Buttons = {} local bw = math.floor((boxW - 6) / 2) addButton(bx+2, by+4, 'YES', bw, 1, {colors.red, colors.white}, function() ui_cancel_confirmed = true; ui_cancel_request = false end) addButton(bx+4+bw, by+4, 'NO', bw, 1, {colors.green, colors.white}, function() ui_cancel_confirmed = false; ui_cancel_request = false end) end local function showAlert(msg) local boxW, boxH = math.max(24, math.min(32, w-4)), 6 local bx, by = math.floor((w-boxW)/2)+1, math.floor((h-boxH)/2)+1 termDev.setBackgroundColor(colors.gray) for i=0,boxH-1 do termDev.setCursorPos(bx, by+i); termDev.write(string.rep(' ', boxW)) end msg = tostring(msg or '') if #msg > boxW-4 then msg = msg:sub(1, boxW-4) end termDev.setTextColor(colors.white); termDev.setCursorPos(bx+2, by+2); termDev.write(msg) Buttons = {} addButton(bx+math.floor((boxW-6)/2), by+4, 'OK', 6, 1, {colors.green, colors.white}, function() end) waitButtons() end local function addCancelButton() addButton(2, h, 'CANCEL', 8, 1, {colors.red, colors.white}, function() ui_cancel_request = true end) end local function showHome() state.stationName = (CFG.current_station and (CFG.current_station.en_name or CFG.current_station.name)) or 'Station' state.stationCode = (CFG.current_station and CFG.current_station.code) or CURRENT_STATION_CODE drawHeader('FSE Ticket Machine', 'Select Mode', true) Buttons = {} local btnW, btnH = 12, 3 local y = math.floor(h/2) - 2 local x1, x2, x3 = math.floor(w/2) - btnW - 2, math.floor(w/2) + 3, math.floor(w/2) - math.floor(btnW / 2) if x1 < 2 or (x2 + btnW) > w-1 then local cx = math.floor((w - btnW) / 2) + 1 addButton(cx, y-2, 'NEW', btnW, btnH, {colors.green, colors.white}, function() resetTicketFlow() stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete() state.page = 'departure' end) addButton(cx, y+2, 'CARD', btnW, btnH, {colors.orange, colors.white}, function() resetCardFlow('open') stopAudio(); state.page = 'card_home' end) addButton(cx, y+6, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function() resetTicketFlow() stopAudio(); state.page = 'online' end) else addButton(x1, y, 'NEW', btnW, btnH, {colors.green, colors.white}, function() resetTicketFlow() stopAudio(); playAudioFile('Audio/welcome.wav'); waitAudioComplete() state.page = 'departure' end) addButton(x2, y, 'ONLINE', btnW, btnH, {colors.cyan, colors.white}, function() resetTicketFlow() stopAudio(); state.page = 'online' end) addButton(x3, y+4, 'CARD', btnW, btnH, {colors.orange, colors.white}, function() resetCardFlow('open') stopAudio(); state.page = 'card_home' end) end waitButtons() end local function showCardHome() while state.page == 'card_home' do drawHeader('IC Card Service', 'Open card or redeem online order') Buttons = {} local btnW, btnH = 16, 3 local cx = math.floor((w - btnW) / 2) + 1 local y = math.floor(h / 2) - 3 addButton(cx, y, 'OPEN CARD', btnW, btnH, {colors.orange, colors.white}, function() resetCardFlow('open') state.page = 'card_name' end) addButton(cx, y + 4, 'ONLINE REDEEM', btnW, btnH, {colors.cyan, colors.white}, function() resetCardFlow('redeem') state.cardPaymentMode = 'online' state.page = 'card_online' end) addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end) waitButtons() end end local function showCardNameInput() local name = firstString(state.holderName) local msg, msgCol = '', colors.red local maxNameLen = 24 local keyboardMode = 'alpha' local alphaRows = { {'Q','W','E','R','T','Y','U','I','O','P'}, {'A','S','D','F','G','H','J','K','L'}, {'Z','X','C','V','B','N','M'} } local symbolRows = { {'-','_','.','\'',','}, {'(',')','&','@','/'}, {'+'} } local function isAllowedHolderChar(ch) if type(ch) ~= 'string' or #ch ~= 1 then return false end if ch:match('[A-Za-z ]') then return true end return ch == '.' or ch == ',' or ch == '\'' or ch == '(' or ch == ')' or ch == '&' or ch == '@' or ch == '/' or ch == '_' or ch == '-' or ch == '+' end local function appendHolderChar(ch) if not isAllowedHolderChar(ch) or #name >= maxNameLen then return end if ch:match('[A-Za-z]') then ch = ch:upper() end if ch == ' ' and (#name == 0 or name:sub(-1) == ' ') then return end name = name .. ch end while state.page == 'card_name' do local placeholder = name if #placeholder == 0 then placeholder = 'ENTER NAME' elseif #placeholder > (w - 8) then placeholder = '...' .. placeholder:sub(-(w - 11)) end local rows = (keyboardMode == 'symbol') and symbolRows or alphaRows drawHeader('Enter Holder Name', (keyboardMode == 'symbol') and 'Symbol keyboard' or 'Letters / symbols / space') centerText(4, '[' .. placeholder .. ']', colors.yellow) if msg and #msg > 0 then centerText(5, msg, msgCol) end Buttons = {} local keyW, keyH = (w < 44 and 2 or 3), 2 local sY = 7 for rIdx, row in ipairs(rows) do local y = sY + (rIdx-1) * (keyH + 1) local rowW = math.max(0, #row * (keyW + 1) - 1) local x = math.max(1, math.floor((w - rowW) / 2) + 1) for _, ch in ipairs(row) do addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function() appendHolderChar(ch) end) x = x + keyW + 1 end end addButton(math.max(1, math.floor((w - 12) / 2) + 1), sY + 10, 'SPACE', 12, 2, {colors.gray, colors.white}, function() appendHolderChar(' ') end) addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end) addButton(12, h-3, (keyboardMode == 'symbol') and 'ABC' or 'SYM', 8, 3, {colors.black, colors.orange}, function() keyboardMode = (keyboardMode == 'symbol') and 'alpha' or 'symbol' end) addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() name = name:sub(1, -2) end) addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() name = '' end) addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function() local clean = firstString(name) if #clean < 2 then msg, msgCol = 'Name too short', colors.red return end state.holderName = clean state.page = 'card_topup' end) local ev, p1, p2, p3 = os.pullEvent() if ev == 'mouse_click' or ev == 'monitor_touch' then for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end end elseif ev == 'char' then appendHolderChar(tostring(p1 or '')) elseif ev == 'key' and p1 == keys.backspace then name = name:sub(1, -2) elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then local clean = firstString(name) if #clean >= 2 then state.holderName = clean state.page = 'card_topup' else msg, msgCol = 'Name too short', colors.red end end end end local function showCardTopup() while state.page == 'card_topup' do local cardCfg = getCardConfig() state.cardTopup = math.max(cardCfg.min_topup, tonumber(state.cardTopup) or cardCfg.min_topup) state.cardBalance = state.cardTopup state.cardDeposit = 0 state.cardOrderValue = state.cardTopup state.cost = state.cardOrderValue drawHeader('First Recharge', 'Holder: ' .. firstString(state.holderName, 'CARD USER')) Buttons = {} centerText(6, 'First Recharge: ' .. tostring(state.cardTopup), colors.yellow) centerText(8, 'Need Pay: ' .. tostring(state.cardOrderValue), colors.red) local colCount = 2 local btnW, btnH = 12, 3 local gap = 2 local startX = math.max(2, math.floor((w - (colCount * btnW + (colCount - 1) * gap)) / 2) + 1) local startY = 11 for idx, amount in ipairs(cardCfg.quick_amounts) do local row = math.floor((idx - 1) / colCount) local col = (idx - 1) % colCount addButton(startX + col * (btnW + gap), startY + row * (btnH + 1), tostring(amount), btnW, btnH, {state.cardTopup == amount and colors.green or colors.gray, colors.white}, function() state.cardTopup = amount end) end addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_name' end) addButton(math.max(12, math.floor(w / 2) - 7), h-3, '-10', 6, 3, {colors.black, colors.red}, function() state.cardTopup = math.max(cardCfg.min_topup, state.cardTopup - 10) end) addButton(math.max(20, math.floor(w / 2)), h-3, '+10', 6, 3, {colors.black, colors.green}, function() state.cardTopup = math.min(999, state.cardTopup + 10) end) addButton(w-11, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function() state.cardPaymentMode = 'local' state.page = 'order' end) waitButtons() end end local function showCardOnlineRedeem() local code = '' local msg, msgCol = '', colors.red local rows = { {'1','2','3','4','5','6','7','8','9','0'}, {'Q','W','E','R','T','Y','U','I','O','P'}, {'A','S','D','F','G','H','J','K','L'}, {'Z','X','C','V','B','N','M'} } local function submitCode() if #code ~= 5 then msg, msgCol = 'Need 5 chars', colors.red return end local res = fetchJSON(API_BASE .. '/public/ic-cards/orders/' .. code) if not (res and res.ok) then res = fetchJSON(API_BASE .. '/public/orders/' .. code) end if not (res and res.ok) then msg, msgCol = 'Voucher Invalid', colors.red return end local d = res.data or res local cardStatus = tostring(d.status or ''):lower() if d.consumed or (cardStatus ~= '' and cardStatus ~= 'pending_pickup' and isCardOrderLike(d)) then msg, msgCol = 'Already Used!', colors.red return end local cardOrder = buildCardOrderState(d, code) if not cardOrder then msg, msgCol = 'Not a card order', colors.red return end resetCardFlow('redeem') state.holderName = cardOrder.holderName state.cardDeposit = cardOrder.deposit state.cardTopup = cardOrder.topup state.cardBalance = cardOrder.balance state.cardOrderValue = cardOrder.orderValue state.cost = state.cardOrderValue state.paid = 0 state.voucher_code = code state.cardPaymentMode = 'local' state.card_server_data = cardOrder.raw state.page = 'order' end while state.page == 'card_online' do local placeholder = code .. string.rep('_', math.max(0, 5-#code)) drawHeader('Redeem IC Card', 'Type 5 chars then OK') centerText(4, '[' .. placeholder .. ']', colors.yellow) if msg and #msg > 0 then centerText(5, msg, msgCol) end Buttons = {} local keyW, keyH = (w < 44 and 2 or 3), 2 local kbW = 10 * (keyW + 1) local sX, sY = math.max(1, math.floor((w - kbW) / 2) + 1), 7 for rIdx, row in ipairs(rows) do local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1) for _, ch in ipairs(row) do addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function() if #code < 5 then code = code .. ch end end) x = x + keyW + 1 end end addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'card_home' end) addButton(w-31, h-3, 'BKSP', 8, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end) addButton(w-21, h-3, 'CLEAR', 8, 3, {colors.black, colors.red}, function() code = '' end) addButton(w-11, h-3, 'OK', 10, 3, {colors.black, colors.green}, submitCode) local ev, p1, p2, p3 = os.pullEvent() if ev == 'mouse_click' or ev == 'monitor_touch' then for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end end elseif ev == 'char' and #code < 5 then code = code .. tostring(p1 or ''):upper() elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2) elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode() end end end local ui_selected_code, ui_scroll_offset = nil, 0 local function renderLinesSelection(title, selectedCode, backPage, nextPage, infoLine) drawHeader(title, infoLine or 'Tap a station to select') Buttons = {}; local startY, endY = 5, h - 4 local sbX = w - 3 local function computeHeight() local y = startY for _, l in ipairs(CFG.lines) do y = y + 2; local x = 2 for _, sc in ipairs((type(l) == 'table' and type(l.stations) == 'table' and l.stations) or {}) do local c = normalizeCode(sc) local st = stationByCode[c] local label = (st and (st.en_name or st.name)) or c local bw = #label + 1 if x + bw > sbX - 1 then y = y + 4; x = 2 end x = x + bw + 2 end y = y + 4 end return y - startY end local ch = computeHeight(); local maxO = math.max(0, ch - (endY-startY+1)) ui_scroll_offset = math.min(ui_scroll_offset, maxO) addButton(sbX, startY-1, '^', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.max(0, ui_scroll_offset-4) end) addButton(sbX, endY+1, 'v', 3, 1, {colors.black, colors.white}, function() ui_scroll_offset = math.min(maxO, ui_scroll_offset+4) end) local y = startY for _, l in ipairs(CFG.lines) do local py = y - ui_scroll_offset if py >= startY and py <= endY then termDev.setTextColor(nearestCCColor(type(l) == 'table' and l.color or nil)); termDev.setCursorPos(2, py); termDev.write(tostring(type(l) == 'table' and l.en_name or '')) end y = y + 1 local by = y - ui_scroll_offset if by >= startY and by <= endY then local cc = nearestCCColor(type(l) == 'table' and l.color or nil); termDev.setBackgroundColor(cc); termDev.setCursorPos(2, by); termDev.write(string.rep(' ', sbX-4)) termDev.setBackgroundColor(colors.black) end y = y + 1; local x = 2 for _, sc in ipairs((type(l) == 'table' and type(l.stations) == 'table' and l.stations) or {}) do local c = normalizeCode(sc) local st = stationByCode[c] local label = (st and (st.en_name or st.name)) or c local bw = #label + 1 if x + bw > sbX - 1 then y = y + 4; x = 2 end local ry = y - ui_scroll_offset if ry >= startY and ry+2 <= endY then addButton(x, ry, label, bw, 3, {selectedCode == c and colors.green or colors.gray, colors.white}, function() ui_selected_code = c end) end x = x + bw + 2 end y = y + 4 end addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = backPage end) if selectedCode then addButton(w-15, h-3, 'NEXT->', 10, 3, {colors.black, colors.green}, function() state.page = nextPage end) end return selectedCode end local function showDeparture() local sel; stopAudio(); playAudioFile('Audio/xzqd.wav') while state.page == 'departure' do sel = renderLinesSelection('Select Departure', sel, 'home', 'terminal', 'From: ' .. stationDisplay(sel)); addCancelButton() waitButtons() if ui_selected_code then sel = ui_selected_code ui_selected_code = nil sel = normalizeCode(sel) state.departure = sel end if ui_cancel_request then renderConfirmCancel() waitButtons() if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end ui_cancel_request = false end end end local function showTerminal() local sel; stopAudio(); playAudioFile('Audio/xzzd.wav') local invalidUntil = 0 while state.page == 'terminal' do local info = 'From: ' .. stationDisplay(state.departure) .. ' To: ' .. stationDisplay(sel) if invalidUntil > os.epoch('utc') then info = info .. ' (Invalid: same station)' end sel = renderLinesSelection('Select Terminal', sel, 'departure', 'type', info); addCancelButton() waitButtons() if ui_selected_code then sel = ui_selected_code ui_selected_code = nil sel = normalizeCode(sel) if sameLogicalStation(sel, state.departure) then invalidUntil = os.epoch('utc') + 1500 sel = nil state.terminal = nil showAlert('Same station') else state.terminal = sel end end if ui_cancel_request then renderConfirmCancel() waitButtons() if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end ui_cancel_request = false end end end local function showType() stopAudio(); playAudioFile('Audio/xzlc.wav') while state.page == 'type' do drawHeader('Select Train Type', 'From: ' .. stationDisplay(state.departure) .. ' To: ' .. stationDisplay(state.terminal)) Buttons = {} local btnW, btnH = 12, 3 local y = math.floor(h/2) - 2 local x1, x2 = math.floor(w/2) - btnW - 2, math.floor(w/2) + 3 if x1 < 2 or (x2 + btnW) > w-1 then local cx = math.floor((w - btnW) / 2) + 1 addButton(cx, y-2, 'Local', btnW, btnH, {state.trainType == 'Local' and colors.green or colors.gray, colors.white}, function() state.trainType = 'Local' end) addButton(cx, y+2, 'Express', btnW, btnH, {state.trainType == 'Express' and colors.blue or colors.gray, colors.white}, function() state.trainType = 'Express' end) else addButton(x1, y, 'Local', btnW, btnH, {state.trainType == 'Local' and colors.green or colors.gray, colors.white}, function() state.trainType = 'Local' end) addButton(x2, y, 'Express', btnW, btnH, {state.trainType == 'Express' and colors.blue or colors.gray, colors.white}, function() state.trainType = 'Express' end) end addCancelButton() addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'terminal' end) if state.trainType then addButton(w-9, h-3, 'NEXT->', 8, 3, {colors.black, colors.green}, function() state.page = 'trips' end) end waitButtons() if ui_cancel_request then renderConfirmCancel() waitButtons() if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end ui_cancel_request = false end end end local function showTrips() while state.page == 'trips' do drawHeader('Select Trips', 'From: ' .. stationDisplay(state.departure) .. ' To: ' .. stationDisplay(state.terminal)) Buttons = {} local y = math.floor(h/2) - 2 local midW = 14 local xMid = math.floor((w - midW) / 2) + 1 addButton(xMid, y, tostring(state.trips) .. (state.trips == 1 and ' TRIP' or ' TRIPS'), midW, 3, {colors.gray, colors.white}, function() end) addButton(xMid-5, y, '+', 4, 3, {colors.black, colors.green}, function() state.trips = math.min(99, (tonumber(state.trips) or 1) + 1) end) addButton(xMid+midW+1, y, '-', 4, 3, {colors.black, colors.red}, function() state.trips = math.max(1, (tonumber(state.trips) or 1) - 1) end) addCancelButton() addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'type' end) addButton(w-9, h-3, 'NEXT->', 8, 3, {colors.black, colors.green}, function() state.page = 'order' end) waitButtons() if ui_cancel_request then renderConfirmCancel() waitButtons() if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false end ui_cancel_request = false end end end local function computeCost(src, dst, trainType) src = normalizeCode(src) dst = normalizeCode(dst) if sameLogicalStation(src, dst) then return 0 end local adj = trainType == 'Express' and adjacency_express or adjacency_regular local heapN, heapD = {}, {} local function heapPush(n, d) local i = #heapN + 1 heapN[i] = n; heapD[i] = d while i > 1 do local p = math.floor(i / 2) if heapD[p] <= heapD[i] then break end heapN[p], heapN[i] = heapN[i], heapN[p] heapD[p], heapD[i] = heapD[i], heapD[p] i = p end end local function heapPop() if #heapN == 0 then return nil end local n, d = heapN[1], heapD[1] local ln, ld = heapN[#heapN], heapD[#heapD] heapN[#heapN], heapD[#heapD] = nil, nil if #heapN > 0 then heapN[1], heapD[1] = ln, ld local i = 1 while true do local l = i * 2 local r = l + 1 if l > #heapN then break end local m = l if r <= #heapN and heapD[r] < heapD[l] then m = r end if heapD[i] <= heapD[m] then break end heapN[i], heapN[m] = heapN[m], heapN[i] heapD[i], heapD[m] = heapD[m], heapD[i] i = m end end return n, d end local dist = { [src] = 0 } heapPush(src, 0) while true do local u, du = heapPop() if not u then break end if du == dist[u] then if u == dst then return du end for v, w in pairs(adj[u] or {}) do local nd = du + (tonumber(w) or 0) if nd < (dist[v] or math.huge) then dist[v] = nd heapPush(v, nd) end end end end return nil end local function drawOrder() if state.productMode == 'card' then local cardSub = (state.cardMode == 'redeem') and 'Redeem IC card order' or 'Open new stored-value card' drawHeader('Confirm Card', cardSub) else local depLine = stationDisplay(state.departure) local terLine = stationDisplay(state.terminal) drawHeader('Confirm Order', 'From: ' .. depLine .. ' To: ' .. terLine) end local y = 5 local function L(label, value, col) termDev.setTextColor(colors.white) termDev.setCursorPos(2, y); termDev.write(label .. ': ') termDev.setTextColor(col or colors.lightBlue) termDev.write(tostring(value or 'N/A')) y = y + 2 end local cost = tonumber(state.cost) local paid = tonumber(state.paid) or 0 local total = tonumber(state.cost) if state.productMode == 'card' then L('Action', (state.cardMode == 'redeem') and 'Online Redeem' or 'Open Card', colors.lightBlue) L('Name', firstString(state.holderName, 'CARD USER'), colors.yellow) L('Top-up', tonumber(state.cardTopup) or 0, colors.lightBlue) L('Balance', tonumber(state.cardBalance) or 0, colors.cyan) L('Order', tonumber(state.cardOrderValue) or 0, colors.red) if state.cardPaymentMode == 'online' then L('Payment', 'Paid Online', colors.green) else L('Pay Now', total or 0, colors.red) end if state.voucher_code then L('Voucher', tostring(state.voucher_code), colors.cyan) end else L('Type', state.trainType or 'N/A', colors.lightBlue) local rides = tonumber(state.trips) or 1 if cost == nil then local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local') cost = unit and (unit * rides) or nil end L('Trips', rides) L('Cost', cost, colors.red) if state.voucher_code then L('Voucher', tostring(state.voucher_code), colors.cyan) end local discount = (CFG.promotion and CFG.promotion.discount) or 1 if discount > 0 and discount < 1 then local promoName = (CFG.promotion and CFG.promotion.name) or 'Promo' local perc = math.floor(discount * 100) local promoText = ('[PROMO] %s • Discount: %d%%'):format(promoName, perc) drawRainbowLabelRow(y, promoText, colors.black) y = y + 2 end end if total == nil then total = cost or 0 end local remain = math.max(0, total - paid) local statusY = math.max(1, h - 6) local barW = math.max(10, w - 10) local ratio = (total <= 0) and 1 or math.min(1, paid / math.max(1, total)) local fill = math.floor(barW * ratio) local barX = math.max(1, math.floor((w - barW) / 2) + 1) termDev.setCursorPos(barX, statusY) termDev.setTextColor(colors.green) termDev.write(string.rep('=', fill)) termDev.setTextColor(colors.gray) termDev.write(string.rep('-', math.max(0, barW - fill))) statusY = statusY + 1 if remain <= 0 then termDev.setTextColor(colors.green) centerText(statusY, ('Paid: %d / %d (OK)'):format(paid, total), colors.green) else termDev.setTextColor(colors.red) centerText(statusY, ('Paid: %d / %d Remaining: %d'):format(paid, total, remain), colors.red) end if total <= 0 then centerText(statusY + 1, 'Ready to confirm', colors.lightGray) else centerText(statusY + 1, 'Insert payment on RIGHT side', colors.lightGray) end end local function showOrderAndAudio() state.paid = 0; state.doneAudioPlayed = false; state.order_datetime = os.date('%Y/%m/%d %H:%M:%S') -- Recalculate cost immediately to avoid stale data if state.productMode == 'card' then state.cardDeposit = 0 state.cardOrderValue = tonumber(state.cardOrderValue) or (tonumber(state.cardTopup) or 0) state.cardBalance = tonumber(state.cardBalance) or tonumber(state.cardTopup) or 0 if state.cardPaymentMode ~= 'online' then state.cost = state.cardOrderValue elseif state.cost == nil then state.cost = 0 end elseif not state.voucher_code then local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local') if not unit then showAlert('No route') state.page = 'terminal' return end state.cost = unit * (state.trips or 1) else if state.cost == nil then state.cost = 0 end end if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then showAlert('Same station') state.page = state.voucher_code and 'online' or 'terminal' return end local confirmed = false local processing = false local statusMsg, statusCol = '', colors.red local render = nil local function confirmAction() if processing or confirmed then return end if state.productMode ~= 'card' and sameLogicalStation(state.departure, state.terminal) then showAlert('Same station') if render then render() end return end processing = true statusMsg, statusCol = 'Processing...', colors.yellow if render then render() end if state.productMode == 'card' then local payload = { holder_name = firstString(state.holderName, 'CARD USER'), deposit = tonumber(state.cardDeposit) or 0, topup = tonumber(state.cardTopup) or 0, balance = tonumber(state.cardBalance) or 0, order_value = tonumber(state.cardOrderValue) or 0, voucher_code = state.voucher_code, station_code = state.stationCode or CURRENT_STATION_CODE, device = currentDeviceId(), card_mode = state.cardMode or 'open', payment_mode = state.cardPaymentMode or 'local', amount_paid = tonumber(state.paid) or 0 } local reuseBlankCardId = firstString(state.pendingBlankCardId) if #reuseBlankCardId > 0 then payload.card_id = reuseBlankCardId end local okIssueBlank, blankCardId, issueMethod = false, '', 'reuse_pending' if #reuseBlankCardId == 0 then okIssueBlank, blankCardId, issueMethod = issueBlankICCard(payload.holder_name, payload.balance) else okIssueBlank, blankCardId = true, reuseBlankCardId end if okIssueBlank and #blankCardId > 0 then payload.card_id = blankCardId state.pendingBlankCardId = blankCardId local okReq, code, parsed, err = submitCardOpen(payload) if okReq then local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {} state.card_id = firstString(respData.card_id, respData.id, payload.card_id) state.cardBalance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance state.card_server_data = respData state.pendingBlankCardId = nil confirmed = true statusMsg, statusCol = 'Card ready', colors.green if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end else local errorMsg = 'Card API Err' if code == 409 then errorMsg = 'Already Used!' elseif err and #tostring(err) > 0 then errorMsg = tostring(err) elseif code and type(code) == 'number' then errorMsg = 'HTTP ' .. tostring(code) end statusMsg, statusCol = 'Issued, retry sync: ' .. errorMsg, colors.red end elseif okIssueBlank then statusMsg, statusCol = 'Card issued without ID', colors.red else local okReq, code, parsed, err = submitCardOpen(payload) if okReq then local respData = (type(parsed) == 'table' and (parsed.data or parsed.card or parsed)) or {} local finalCard = { card_id = firstString(respData.card_id, respData.id, payload.card_id), holder_name = payload.holder_name, balance = firstNumber(respData.balance, respData.stored_value, payload.balance) or payload.balance, deposit = firstNumber(respData.deposit, payload.deposit) or payload.deposit, topup = firstNumber(respData.topup, respData.first_topup, payload.topup) or payload.topup, station_code = payload.station_code, device = payload.device, voucher_code = payload.voucher_code, media = 'ic_card', product_type = 'stored_value', order_value = payload.order_value, initial_balance = payload.topup } local okWrite, writtenCard, writeMethod = writeICCard(finalCard) if okWrite then state.card_id = firstString(writtenCard.card_id, finalCard.card_id) state.cardBalance = tonumber(writtenCard.balance) or finalCard.balance state.card_server_data = respData state.pendingBlankCardId = nil confirmed = true statusMsg, statusCol = 'Card ready', colors.green if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end else statusMsg, statusCol = 'Write failed: ' .. tostring(writeMethod), colors.red end else local errorMsg = 'Card API Err' if code == 409 then errorMsg = 'Already Used!' elseif err and #tostring(err) > 0 then errorMsg = tostring(err) elseif code and type(code) == 'number' then errorMsg = 'HTTP ' .. tostring(code) end statusMsg, statusCol = errorMsg, colors.red end end elseif state.voucher_code then local url = API_BASE .. '/public/orders/' .. state.voucher_code .. '/consume' local okReq, code = postJSONResult(url, {}) if okReq and code and code >= 200 and code < 300 then confirmed = true if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end else local errorMsg = 'NetErr' if code == 409 then errorMsg = 'Already Used!' elseif code and type(code) == 'number' then errorMsg = 'HTTP ' .. tostring(code) end statusMsg, statusCol = errorMsg, colors.red end else confirmed = true local totalCost = tonumber(state.cost) or 0 if totalCost <= 0 and not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end end processing = false if render and not confirmed then render() end end render = function() Buttons = {} drawOrder() addCancelButton() local same = (state.productMode ~= 'card') and sameLogicalStation(state.departure, state.terminal) local costV = tonumber(state.cost) local paidV = tonumber(state.paid) or 0 local canConfirm = (not same) and ((costV or 0) <= 0 or (costV ~= nil and paidV >= costV)) local msgY = h - 2 if statusMsg and #statusMsg > 0 then centerText(msgY, statusMsg, statusCol) end addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() stopAudio() if state.productMode == 'card' then state.page = state.voucher_code and 'card_online' or 'card_topup' else state.page = state.voucher_code and 'online' or 'trips' end end) if canConfirm then if processing then addButton(w-12, h-3, 'WAIT', 10, 3, {colors.gray, colors.white}, function() end) else addButton(w-12, h-3, 'CONFIRM', 10, 3, {colors.green, colors.black}, confirmAction) end end end render() -- If cost is zero, auto-confirm immediately after a brief delay for audio if ((state.productMode == 'card') or (not sameLogicalStation(state.departure, state.terminal))) and ((tonumber(state.cost) or 0) <= 0) then sleep(0.5) confirmAction() end local prev = redstone.getInput('right') while state.page == 'order' do local ev, p1, p2, p3 = os.pullEvent() if ev == 'redstone' then local now = redstone.getInput('right') if now and not prev then playNote('hat', 20, 1, 0.01) state.paid = (state.paid or 0) + 1; render() if state.paid >= (state.cost or 0) then if not state.doneAudioPlayed then playConfirmTicketMelody(); state.doneAudioPlayed = true end -- Auto-confirm when paid enough sleep(0.5) -- Wait for UI/Audio slightly confirmAction() end end; prev = now elseif ev == 'mouse_click' or ev == 'monitor_touch' then -- For mouse_click: p1=button, p2=x, p3=y -- For monitor_touch: p1=side, p2=x, p3=y for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound() if b.onClick then b.onClick() end; render() break end end if ui_cancel_request then renderConfirmCancel() waitButtons() if ui_cancel_confirmed then stopAudio(); state.page = 'home'; ui_cancel_confirmed = false; break end ui_cancel_request = false render() end elseif ev == 'config_updated' then render() end if confirmed then state.page = 'done'; break end end end local function showPrePrintCheck() while state.page == 'preprint' do state.page = 'done' end end local function generateTicketId() local function randLetter() return string.char(string.byte('A') + math.random(0, 25)) end local prefix = randLetter() .. randLetter() local num = string.format('%08d', math.random(0, 99999999)) return prefix .. '-' .. num end local function ensureTicketIdFormat(id) if id == nil then return generateTicketId() end local s = tostring(id) s = s:gsub('%s+', '') local prefix, num = s:match('^([A-Za-z][A-Za-z])%-?([0-9]+)$') if prefix and num then prefix = prefix:upper() if #num < 8 then num = string.rep('0', 8 - #num) .. num elseif #num > 8 then num = num:sub(-8) end return prefix .. '-' .. num end return s:lower() end local function showDone() if state.productMode == 'card' then drawHeader('Card Ready', 'Please take your IC card') local y = 5 local function line(label, value, col) termDev.setTextColor(colors.white) termDev.setCursorPos(2, y); termDev.write(label .. ': ') termDev.setTextColor(col or colors.lightBlue) termDev.write(tostring(value or '')) y = y + 2 end line('Name', firstString(state.holderName, 'CARD USER'), colors.yellow) line('Deposit', tonumber(state.cardDeposit) or 0, colors.lightBlue) line('Top-up', tonumber(state.cardTopup) or 0, colors.lightBlue) line('Balance', tonumber(state.cardBalance) or 0, colors.green) line('Card ID', firstString(state.card_id, 'PENDING'), colors.cyan) if state.voucher_code then line('Voucher', state.voucher_code, colors.cyan) end saveCardIssueSnapshot({ card_id = state.card_id, holder_name = state.holderName, deposit = state.cardDeposit, topup = state.cardTopup, balance = state.cardBalance, voucher_code = state.voucher_code, station_code = state.stationCode or CURRENT_STATION_CODE, ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000) }) for i = 5, 1, -1 do centerText(h-4, 'Returning to Home: ' .. i .. 's', colors.red) sleep(1) end resetTicketFlow() state.page = 'home' return end local rides = tonumber(state.trips) or 1 local orderDatetime = state.order_datetime or os.date('%Y/%m/%d %H:%M:%S') local rawCost = tonumber(state.cost) if (not state.voucher_code) and rawCost == nil then local unit = computeCost(state.departure or '', state.terminal or '', state.trainType or 'Local') rawCost = unit and (unit * rides) or 0 state.cost = rawCost end local cost = rawCost or 0 local issueSource = 'local' local startCode = normalizeCode(state.departure) local terminalCode = normalizeCode(state.terminal) local startObj = stationByCode[startCode] local terminalObj = stationByCode[terminalCode] local fromNameEn = (startObj and (startObj.en_name or startObj.name)) or state.departure local toNameEn = (terminalObj and (terminalObj.en_name or terminalObj.name)) or state.terminal local fromNameCnU = (startObj and unicodeEscape(startObj.name)) or '' local toNameCnU = (terminalObj and unicodeEscape(terminalObj.name)) or '' local startStationArg = tostring(startCode or '') local terminalStationArg = tostring(terminalCode or '') local fromNameEnArg = tostring(fromNameEn or '???') local toNameEnArg = tostring(toNameEn or '???') local fromNameCnUArg = tostring(fromNameCnU or '') local toNameCnUArg = tostring(toNameCnU or '') local issueArgs = { start_name_en = fromNameEnArg, terminal_name_en = toNameEnArg, type = (state.trainType == 'Express') and 'limited_express' or 'local', rides = rides, cost = cost, start_station = startStationArg, terminal_station = terminalStationArg, fromNameCnU = fromNameCnUArg, toNameCnU = toNameCnUArg, } _G.TICKET_MACHINE_LAST_TICKET = { start_station_id = startCode, terminal_station_id = terminalCode, start_station = startCode, terminal_station = terminalCode, start_name = startObj and unicodeEscape(startObj.name) or nil, terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil, start_name_en = fromNameEn, terminal_name_en = toNameEn } if ticketVendingMachine and ticketVendingMachine.issueTicket then local apiType = (state.trainType == 'Express') and 'limited_express' or 'local' local okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg, fromNameCnUArg, toNameCnUArg) if not (okCall and okIssue and ticketId) then okCall, okIssue, ticketId = pcall(ticketVendingMachine.issueTicket, fromNameEnArg, toNameEnArg, apiType, rides, cost, startStationArg, terminalStationArg) end if okCall and okIssue and ticketId then state.ticket_id = ensureTicketIdFormat(ticketId) issueSource = 'ticket_vending_machine' else state.ticket_id = generateTicketId() end else state.ticket_id = generateTicketId() end pcall(function() ensureDir('logs/last_ticket_issue.json') issueArgs.ticket_id = state.ticket_id issueArgs.issue_source = issueSource issueArgs.ts = (os.epoch and os.epoch('utc')) or (os.time() * 1000) local okSer, s = pcall(textutils.serializeJSON, issueArgs) if okSer and type(s) == 'string' then local f = fs.open('logs/last_ticket_issue.json', 'w') if f then f.write(s); f.close() end end end) local typeStr = (state.trainType == 'Express') and 'Limited Express' or 'Ordinary' local ticketData = { start_station = startCode, terminal_station = terminalCode, end_station = terminalCode, type = typeStr, entered = false, exited = false, id = state.ticket_id, ticket_id = state.ticket_id, trips_total = rides, trips_remaining = rides, cost = cost, order_datetime = orderDatetime, timestamp = (os.epoch and os.epoch('utc')) or (os.time() * 1000) } local ticketDataMod = {} for k, v in pairs(ticketData) do ticketDataMod[k] = v end ticketDataMod.start_station_id = startCode ticketDataMod.terminal_station_id = terminalCode ticketDataMod.start_station = startCode ticketDataMod.terminal_station = terminalCode ticketDataMod.start_name = startObj and unicodeEscape(startObj.name) or nil ticketDataMod.terminal_name = terminalObj and unicodeEscape(terminalObj.name) or nil ticketDataMod.start_name_en = fromNameEn ticketDataMod.terminal_name_en = toNameEn ticketDataMod.issue_source = issueSource ticketDataMod.ticket_id = state.ticket_id ticketDataMod.train_type = state.trainType or 'Local' ticketDataMod.station_code = state.stationCode or CURRENT_STATION_CODE ticketDataMod.device = currentDeviceId() _G.TICKET_MACHINE_LAST_TICKET = ticketDataMod if MOD_DEBUG then local t = _G.TICKET_MACHINE_LAST_TICKET print("DEBUG start_name_en=" .. tostring(t.start_name_en)) print("DEBUG terminal_name_en=" .. tostring(t.terminal_name_en)) print("DEBUG cost=" .. tostring(t.cost)) end local okUpload, uploadRes = pcall(enqueueTicketUpload, ticketDataMod) if not okUpload then print('enqueueTicketUpload error: ' .. tostring(uploadRes)) end drawHeader('Purchase Complete', 'Please take your ticket') local y = 5 local function line(label, value, col) termDev.setTextColor(colors.white) termDev.setCursorPos(2, y); termDev.write(label .. ': ') termDev.setTextColor(col or colors.lightBlue) termDev.write(tostring(value or '')) y = y + 2 end line('From', fromNameEn, colors.yellow) line('To', toNameEn, colors.yellow) line('Type', typeStr, colors.lightBlue) line('Trips', rides, colors.lightBlue) line('Cost', cost, colors.red) line('ID', state.ticket_id or '', colors.cyan) for i = 5, 1, -1 do centerText(h-4, 'Returning to Home: ' .. i .. 's', colors.red) sleep(1) end resetTicketFlow() state.page = 'home' end local function showOnlineVoucher() local code = '' local msg, msgCol = '', colors.red local rows = { {'1','2','3','4','5','6','7','8','9','0'}, {'Q','W','E','R','T','Y','U','I','O','P'}, {'A','S','D','F','G','H','J','K','L'}, {'Z','X','C','V','B','N','M'} } local function submitCode() if #code == 5 then local res = fetchJSON(API_BASE .. '/public/orders/' .. code) if res and res.ok then local d = res.data or res -- Check if already consumed if d.consumed then msg, msgCol = 'Already Used!', colors.red return end state.departure = d.start or d.from or d.departure state.terminal = d.terminal or d.to or d.destination state.trainType = normalizeTrainTypeLabel(d.train_type or d.type or 'Local') state.trips = tonumber(d.trips or d.count) or 1 state.cost = tonumber(d.price) or tonumber(d.cost) or 0 state.voucher_code = code msg = '' state.page = 'order' else msg, msgCol = 'Voucher Invalid', colors.red end end end while state.page == 'online' do local placeholder = code .. string.rep('_', math.max(0, 5-#code)) drawHeader('Enter Voucher', 'Type 5 chars then OK') centerText(4, '[' .. placeholder .. ']', colors.yellow) if msg and #msg > 0 then centerText(5, msg, msgCol) end Buttons = {} local keyW, keyH = (w < 44 and 2 or 3), 2 local kbW = 10 * (keyW + 1) local sX, sY = math.max(1, math.floor((w - kbW) / 2) + 1), 7 for rIdx, row in ipairs(rows) do local y, x = sY + (rIdx-1) * (keyH + 1), sX + (rIdx-1) for _, ch in ipairs(row) do addButton(x, y, ch, keyW, keyH, {colors.black, colors.white}, function() if #code < 5 then code = code .. ch end end) x = x + keyW + 1 end end local actY = h - 6 local bw, cw, ow = 10, 8, 8 local gap = 2 local total = bw + gap + cw + gap + ow local ax = math.floor((w - total) / 2) + 1 if ax < 1 then bw, cw, ow = 6, 6, 6 total = bw + 1 + cw + 1 + ow ax = math.floor((w - total) / 2) + 1 addButton(math.max(1, ax), actY, 'BKSP', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end) addButton(math.max(1, ax) + bw + 1, actY, 'CLEAR', cw, 3, {colors.black, colors.red}, function() code = '' end) addButton(math.max(1, ax) + bw + 1 + cw + 1, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode) else addButton(ax, actY, 'Backspace', bw, 3, {colors.black, colors.red}, function() code = code:sub(1, -2) end) addButton(ax + bw + gap, actY, 'Clear', cw, 3, {colors.black, colors.red}, function() code = '' end) addButton(ax + bw + gap + cw + gap, actY, 'OK', ow, 3, {colors.black, colors.green}, submitCode) end addButton(2, h-3, '<-back', 8, 3, {colors.black, colors.red}, function() state.page = 'home' end) local ev, p1, p2, p3 = os.pullEvent() if ev == 'mouse_click' or ev == 'monitor_touch' then -- For mouse_click: p1=button, p2=x, p3=y -- For monitor_touch: p1=side, p2=x, p3=y for _, b in ipairs(Buttons) do if inRect(b, p2, p3) then clickSound(); if b.onClick then b.onClick() end; break end end elseif ev == 'char' and #code < 5 then code = code .. p1:upper() elseif ev == 'key' and p1 == keys.backspace then code = code:sub(1, -2) elseif ev == 'key' and (p1 == keys.enter or p1 == keys.numPadEnter) then submitCode() elseif ev == 'config_updated' then -- Config updated in background, continue to redraw end end end -- ########################### -- Main entry point -- ########################### local function mainPageLoop() while true do if state.page == 'home' then showHome() elseif state.page == 'card_home' then showCardHome() elseif state.page == 'card_name' then showCardNameInput() elseif state.page == 'card_topup' then showCardTopup() elseif state.page == 'card_online' then showCardOnlineRedeem() elseif state.page == 'departure' then showDeparture() elseif state.page == 'terminal' then showTerminal() elseif state.page == 'type' then showType() elseif state.page == 'trips' then showTrips() elseif state.page == 'order' then showOrderAndAudio() elseif state.page == 'preprint' then showPrePrintCheck() elseif state.page == 'done' then showDone() elseif state.page == 'online' then showOnlineVoucher() else state.page = 'home'; sleep(0.5) end end end parallel.waitForAny(mainPageLoop, backgroundSyncTask, backgroundTicketUploadTask)