From e76d0188dbd7c60cfc4be7b0ec56c85852039f48 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Tue, 11 Aug 2026 14:29:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=A3=80=E7=A5=A8?= =?UTF-8?q?=E6=9C=BA=E9=A6=96=E6=AC=A1=E5=8A=A0=E8=BD=BD=E9=BB=91=E5=B1=8F?= =?UTF-8?q?=E5=8F=8A=E5=AE=8C=E5=96=84=E4=BA=AE=E7=81=AF=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加外设初始化重试机制,等待monitor和检票机外设就绪(最多20秒), 在等待期间显示进度提示,避免区块加载时脚本提前退出导致屏幕无显示 - 修复Socket.IO服务端未强制websocket传输配置,避免负载均衡环境下轮询产生400错误 - 所有业务拒绝(deny)场景下调用markFault触发红灯,包括: * 无效票/卡、卡ID缺失、票ID缺失 * 站点不匹配、未进站/已出站、已进站/已出站 * 余额不足、票价查询失败、网络错误 * 外设写入/扣费失败等所有错误路径 - 版本号升级至v1.5.29 --- API.md | 296 +++++++++++++++++++++++++++++++++++++++++++++++++++ gate.lua | 204 +++++++++++++++++++++++++++++++---- server/io.js | 3 +- 3 files changed, 479 insertions(+), 24 deletions(-) create mode 100644 API.md diff --git a/API.md b/API.md new file mode 100644 index 0000000..50711d7 --- /dev/null +++ b/API.md @@ -0,0 +1,296 @@ +# FSE Ticketing CC API + +## 通用说明 + +- 外设类型通过 `peripheral.getType(name)` 返回,或在 `peripheral.wrap(name)` 后通过 `peripheral.call`/方法调用使用。 +- 返回值约定: + - 成功通常返回 `true`(部分方法额外返回数据)。 + - 失败通常返回 `false, "error_message"` 或 `nil, "error_message"`(取决于具体方法)。 +- 票/卡信息表(Lua table)中的字段 key 与模组 NBT key 一致,详见“数据字段”章节。 + +## 外设:ticket\_vending\_machine(售票机) + +### 方法:issueTicket + +发放一张票并从售票机正面吐出(生成掉落物)。 + +```lua +local ok, ticketId = vending.issueTicket( + startNameEn, + terminalNameEn, + type, + rides, + cost, + startStation, + terminalStation, + fromNameCnU, + toNameCnU +) +``` + +- 参数(均为可选,未提供时使用默认值) + - `startNameEn`:起点(英文/标识),默认 `"???"` + - `terminalNameEn`:终点(英文/标识),默认 `"???"` + - `type`:票种,默认 `"local"` + - `"local"`:普通票(Local Ticket) + - `"limited_express"`:特急票(Exp Ticket) + - `"single"`:单程票(Single-trip Ticket) + - `rides`:可乘坐次数,默认 `1`,最小会被钳制为 `1` + - `cost`:金额,默认 `0` + - `startStation`:起点站 ID/代码,默认 `""` + - `terminalStation`:终点站 ID/代码,默认 `""` + - `fromNameCnU`:中文起点名(可能包含 `\u` 转义),默认 `""` + - `toNameCnU`:中文终点名(可能包含 `\u` 转义),默认 `""` +- 返回 + - `ok`:`true` + - `ticketId`:生成的票 ID(形如 `AB-12345678`) +- 方块状态 / 模型切换(对应票种) + - 当 `type` 为 `"local"` 时:成功调用后,方块模型暂时切换为 `ticket_vending_machine_success_localticket`(露出绿票 / 本地票图案)。 + - 当 `type` 为 `"limited_express"` 或 `"single"` 时:暂时切换为 `ticket_vending_machine_success_expticket`(露出橙红特急票 / 单程票图案)。 + - 两种状态持续约 20 tick(1 秒)后自动恢复基础款模型 `ticket_vending_machine`。 + +### 方法:issueICCard + +发放一张 IC 卡并从售票机正面吐出(生成掉落物)。 + +```lua +local ok, cardId = vending.issueICCard(ownerName, balance) +``` + +- 参数(可选) + - `ownerName`:持有人名字,默认 `""` + - `balance`:初始余额,默认 `0` +- 返回 + - `ok`:`true` + - `cardId`:生成的卡号(形如 `IC-xxxxxxxx`) +- 方块状态 / 模型切换 + - 方法成功返回时,方块模型暂时切换到 `ticket_vending_machine_success_ic`(正面露出 IC 卡图案),20 tick(1 秒)后自动恢复基础款模型。 + +### 方法:issueFSEPass + +```lua +local ok, ticketId = vending.issueFSEPass(ownerName, startNameEn, terminalNameEn, cost) +``` + +发放一张 FSE Pass 通票(紫色 FSE_PASS 物品)并从售票机正面吐出。参数从左到右依次为: + +- 参数(均为可选) + - `ownerName`:持有人名字,默认 `""` + - `startNameEn`:起点(英文/标识),默认 `"???"` + - `terminalNameEn`:终点(英文/标识),默认 `"???"` + - `cost`:金额,默认 `0` +- 返回 + - `ok`:`true` + - `ticketId`:生成的通票 ID(同样形如 `AB-12345678`) +- 方块状态 / 模型切换 + - 方法成功返回时,方块模型暂时切换为 `ticket_vending_machine_success_expticket`(与特急票款外观),20 tick 后恢复基础款模型。 + +## 外设:ticket\_inspection\_machine(检票机) + +检票机通过玩家右键扫描票/卡,保存“最近一次扫描”的数据,并向连接的 CC 电脑推送事件。 + +### 方法:getLastScanned + +获取最近一次扫描到的票/卡信息表。 + +```lua +local infoOrNil, err = gate.getLastScanned() +``` + +- 返回(成功) + - `infoOrNil`:Lua table(票信息或 IC 信息) +- 返回(失败) + - `nil, "no ticket scanned"` + +### 方法:destroyTicket + +销毁最近一次扫描的物品(直接清空玩家手上那一张票/卡)。 + +```lua +local ok, err = gate.destroyTicket() +``` + +- 返回 + - `true`:已投递到服务器主线程执行 + - `false, "not server level"`:当前世界不是服务端世界 + +注意:该方法当前实现为“投递执行”,不会把实际销毁结果回传给 Lua;建议通过事件与 `getLastScanned()` 侧面确认状态。 + +- 方块状态 + - 成功执行:方块模型切换到 `ticket_inspection_machine_success`(绿灯通过状态) + - 执行失败(如找不到扫描玩家):切换到 `ticket_inspection_machine_false`(红灯拒绝状态) + - 两种状态均持续约 40 tick(2 秒)后自动恢复 `NONE` 基础模型。 + +### 方法:deductICCard + +对最近一次扫描到的 IC 卡扣费。 + +```lua +local ok, err = gate.deductICCard(amount) +``` + +- 参数 + - `amount`:扣费金额(double) +- 返回 + - `true`:已投递到服务器主线程执行 + - `false, "not server level"`:当前世界不是服务端世界 + +注意:当前实现同样不会把扣费后的余额回传给 Lua;如果需要余额,请调用 `getLastScanned()` 或使用充值机外设完成扣费/查询。 + +- 方块状态 + - 成功扣费:`_success`(绿灯)40 tick + - 失败(非 IC 卡 / 找不到玩家 / 卡片不匹配 / 余额不足):`_false`(红灯)40 tick + +### 方法:markEntered + +将最近一次扫描的票/卡标记为已进站,并(可选)写入进站站点。 + +```lua +local ok, err = gate.markEntered(entryStationId) +``` + +- 参数(可选) + - `entryStationId`:站点 ID/代码,默认 `""`(为空则不写入) +- 返回 + - `true`:已投递到服务器主线程执行 + - `false, "not server level"` + +执行后会推送 `ticket_state_updated` 或 `ic_card_state_updated` 事件(取决于最近扫描的是票还是卡)。 + +- 方块状态 + - 成功写入:`_success`(绿灯)40 tick + - 失败(玩家/票卡无效):`_false`(红灯)40 tick + +### 方法:markExited + +将最近一次扫描的票/卡标记为已出站,并清理进站站点字段。 + +```lua +local ok, err = gate.markExited() +``` + +- 返回 + - `true`:已投递到服务器主线程执行 + - `false, "not server level"` +- 方块状态 + - 成功写入:`_success`(绿灯)40 tick + - 失败(玩家/票卡无效):`_false`(红灯)40 tick + +### 方法:resetTicketState + +将最近一次扫描的票/卡状态复位(entered/exited 均为 false,并清理进站站点字段)。 + +```lua +local ok, err = gate.resetTicketState() +``` + +- 返回 + - `true`:已投递到服务器主线程执行 + - `false, "not server level"` +- 方块状态 + - 成功复位:`_success`(绿灯)40 tick + - 失败(玩家/票卡无效):`_false`(红灯)40 tick + +### 方法:markFault + +CC 侧业务判断失败时,主动触发红灯拒绝状态(例如业务逻辑拒绝进站/出站,但 CC 侧又不想调用 resetTicketState 时)。 + +```lua +local ok, err = gate.markFault() +``` + +- 返回 + - `true`:已投递到服务器主线程执行(红灯亮起) + - `false, "not server level"`:当前世界不是服务端世界 +- 方块状态 + - 调用后立即切换到 `ticket_inspection_machine_false`(红灯拒绝状态),持续 40 tick(2 秒)后自动恢复 NONE 基础款。 + - 与 `markEntered`/`markExited` 等方法内部业务错误触发的红灯效果完全相同。 + +### 事件 + +检票机对所有已连接电脑(`attach` 过的电脑)推送以下事件。事件参数为 1 个 table: + +- `ticket_scanned(info)` +- `ic_card_scanned(info)` +- `ticket_state_updated(info)` +- `ic_card_state_updated(info)` + +`info` 结构见“数据字段”章节(包含 `passenger` 字段,值为扫描者玩家名)。 + +## 外设:ic\_refill\_machine(IC 充值机) + +充值机要求玩家先把 IC 卡插入机器(右键把 IC 卡放进去)。只有在机器内有卡时,Lua 方法才可用。 + +- 方块状态 / 模型切换 + - 玩家右键把 IC 卡(手持 `limitcard` 物品)插入机器 → `HAS_CARD=true`,方块模型切换为 `ic_refill_machine_inserted`(卡片露出卡槽状态),同时 scheduleTick 100 tick(5 秒超时)。 + - 玩家右键空手拿走卡、或取出卡、或 5 秒超时(`tick()` 触发)→ `HAS_CARD=false`,模型切回基础款 `ic_refill_machine`。 + - 充值 / 扣费时(`refill`、`deduct`)只要卡还在,始终保持 `_inserted` 模型,不会额外切换。 + +### 方法:getCardInfo + +```lua +local infoOrNil, err = refill.getCardInfo() +``` + +- 返回(成功) + - `infoOrNil`:Lua table(IC 卡信息) +- 返回(失败) + - `nil, "no card"`:机器内没有插卡 + +### 方法:refill + +```lua +local okOrNil, r2 = refill.refill(amount) +``` + +- 参数 + - `amount`:充值金额(double) +- 返回(成功) + - `true, newBalance` +- 返回(失败) + - `nil, "no card"` + +### 方法:deduct + +```lua +local okOrNil, r2 = refill.deduct(amount) +``` + +- 参数 + - `amount`:扣费金额(double) +- 返回(成功) + - `true, newBalance` +- 返回(失败) + - `nil, "no card"` + - `false, "insufficient"`:余额不足(扣费后会变成负数时阻止) + +## 数据字段(info table keys) + +### 票信息(ticket\_\* / ticket\_state\_updated / getLastScanned 返回为票时) + +- `start_name_en`:起点名(英文/标识) +- `terminal_name_en`:终点名(英文/标识) +- `start_station`:起点站 ID/代码 +- `terminal_station`:终点站 ID/代码 +- `fromNameCnU`:中文起点名(可能包含 `\u` 转义) +- `toNameCnU`:中文终点名(可能包含 `\u` 转义) +- `type`:票种(`local` / `limited_express` / `single`) +- `rides`:剩余/可用次数 +- `entered`:是否已进站 +- `exited`:是否已出站 +- `ticketId`:票 ID +- `timestamp`:签发时间戳(毫秒) +- `cost`:金额 +- `order_datetime`:下单/签发时间字符串(`yyyy-MM-dd HH:mm:ss`) +- `passenger`:扫描者玩家名(仅检票机事件与 getLastScanned 返回中提供) + +注意:单程票的 `line_name` 当前不在 `info` table 中返回(仅保存在票的 NBT 内)。 + +### IC 信息(ic\_\* / ic\_card\_state\_updated / getLastScanned 返回为卡时) + +- `cardId`:卡号 +- `ownerName`:持有人 +- `balance`:余额 +- `entered`:是否已进站 +- `entry_station`:进站站点 ID/代码(可能为空) +- `passenger`:扫描者玩家名(仅检票机事件与 getLastScanned 返回中提供) diff --git a/gate.lua b/gate.lua index b49f9b5..3e5c35b 100644 --- a/gate.lua +++ b/gate.lua @@ -1,8 +1,10 @@ local DEFAULT_SERVER_BASE = "https://ticket.fse-media.group" local DEFAULT_SERVER_PATH = "/api/tickets/check" local GATE_OPEN_SECONDS = 2 -local VERSION = "v1.5.28" +local VERSION = "v1.5.29" local VERSION_CHECK_INTERVAL = 5 +local PERIPHERAL_WAIT_INTERVAL = 0.5 +local PERIPHERAL_WAIT_MAX_RETRIES = 40 local serverURL = nil @@ -87,11 +89,93 @@ local function stationSetFromList(list) end local nativeTerm = (term and type(term.current) == "function" and term.current()) or term -local monitor = peripheral.find("monitor") -local speaker = peripheral.find("speaker") -local inspection = peripheral.find("ticket_inspection_machine") +local monitor = nil +local speaker = nil +local inspection = nil local monitorSide = nil +local function safeFindPeripheral(peripheralType, sideHint) + if not peripheral or type(peripheral.find) ~= "function" then return nil, nil end + if sideHint and type(peripheral.wrap) == "function" and type(peripheral.isPresent) == "function" then + local okPres, pres = pcall(peripheral.isPresent, sideHint) + if okPres and pres then + local okW, wrapped = pcall(peripheral.wrap, sideHint) + if okW and wrapped then return wrapped, sideHint end + end + end + local foundName = nil + local found = nil + pcall(function() + found = peripheral.find(peripheralType, function(name, wrapped) + foundName = name + return true + end) + end) + if found then return found, foundName end + return nil, nil +end + +local function waitForPeripherals(requireInspection) + local attempts = 0 + local lastDrawMsg = nil + while attempts < PERIPHERAL_WAIT_MAX_RETRIES do + local hadChange = false + if not monitor then + local m, ms = safeFindPeripheral("monitor", monitorSide) + if m then monitor = m; monitorSide = ms; hadChange = true end + end + if not speaker then + local s, _ = safeFindPeripheral("speaker") + if s then speaker = s; hadChange = true end + end + if not inspection and requireInspection then + local i, _ = safeFindPeripheral("ticket_inspection_machine") + if i then inspection = i; hadChange = true end + end + local ok = true + if not monitor then ok = false end + if requireInspection and not inspection then ok = false end + if ok then return true end + local msg = nil + if not monitor and requireInspection and not inspection then + msg = "Waiting for monitor + gate..." + elseif not monitor then + msg = "Waiting for monitor..." + elseif requireInspection and not inspection then + msg = "Waiting for gate peripheral..." + end + if msg and msg ~= lastDrawMsg then + lastDrawMsg = msg + pcall(function() + local tmpTerm = nativeTerm or term + if tmpTerm and type(tmpTerm.getSize) == "function" then + tmpTerm.setBackgroundColor(colors.black) + tmpTerm.setTextColor(colors.yellow) + tmpTerm.clear() + tmpTerm.setCursorPos(1, 1) + local w, _ = tmpTerm.getSize() + local pad = string.rep(" ", math.max(0, math.floor((w - #VERSION) / 2))) + tmpTerm.write(pad .. VERSION) + local msg2 = tostring(msg) + tmpTerm.setCursorPos(1, 3) + local pad2 = string.rep(" ", math.max(0, math.floor((w - #msg2) / 2))) + tmpTerm.write(pad2 .. msg2) + tmpTerm.setCursorPos(1, 5) + tmpTerm.setTextColor(colors.gray) + local prog = "Attempt " .. tostring(attempts + 1) .. "/" .. tostring(PERIPHERAL_WAIT_MAX_RETRIES) + local pad3 = string.rep(" ", math.max(0, math.floor((w - #prog) / 2))) + tmpTerm.write(pad3 .. prog) + end + end) + end + sleep(PERIPHERAL_WAIT_INTERVAL) + attempts = attempts + 1 + end + return false +end + +pcall(function() waitForPeripherals(false) end) + local serverConnected = nil local isUpdating = false @@ -832,8 +916,32 @@ pcall(function() refreshRemoteLuaVersion(guessBaseFromStatusURL(serverURL)) end) -if not inspection then +pcall(function() if modeBySide == nil then + waitForPeripherals(true) + else + for side, _ in pairs(modeBySide) do + local dev = resolveInspection(side) + local retryCount = 0 + while not dev and retryCount < PERIPHERAL_WAIT_MAX_RETRIES do + sleep(PERIPHERAL_WAIT_INTERVAL) + dev = resolveInspection(side) + retryCount = retryCount + 1 + end + end + if not monitor then waitForPeripherals(false) end + end +end) + +if not inspection and modeBySide == nil then + local okDev = false + if peripheral and type(peripheral.find) == "function" then + pcall(function() + inspection = peripheral.find("ticket_inspection_machine") + if inspection then okDev = true end + end) + end + if not okDev then draw("Missing peripheral:", "ticket_inspection_machine", colors.red) error("ticket_inspection_machine not found") end @@ -1192,9 +1300,35 @@ local function queryFare(fromStation, toStation) return toMoney(fare), nil end -local function denyCard(reason, detail) +local function triggerFaultLights(devs) + local targets = {} + if type(devs) == "table" then + for _, d in ipairs(devs) do table.insert(targets, d) end + end + if inspection and not targets[1] then table.insert(targets, inspection) end + if modeBySide then + for side, _ in pairs(modeBySide) do + local d = resolveInspection(side) + if d then + local found = false + for _, t in ipairs(targets) do if t == d then found = true; break end end + if not found then table.insert(targets, d) end + end + end + end + for _, dev in ipairs(targets) do + if type(dev) == "table" and type(dev.markFault) == "function" then + pcall(dev.markFault) + end + end +end + +local function denyCard(reason, detail, inspectionDevs) draw("DENIED", tostring(detail or reason or "deny"), colors.red) - playDfpwm("error.dfpwm") + parallel.waitForAll( + function() playDfpwm("error.dfpwm") end, + function() triggerFaultLights(inspectionDevs) end + ) end local function deductICCardBalance(dev, amount) @@ -1240,7 +1374,10 @@ local function handleICCardScan(scan, side, scanDev) local cardId = getCardId(scan) if #cardId == 0 then draw("Invalid card.", "Missing card_id.", colors.red) - playDfpwm("error.dfpwm") + parallel.waitForAll( + function() playDfpwm("error.dfpwm") end, + function() triggerFaultLights(inspectionDevs) end + ) return end @@ -1251,13 +1388,13 @@ local function handleICCardScan(scan, side, scanDev) local usedAction = action if #exitStation == 0 then - denyCard("missing_station", "Missing gate station") + denyCard("missing_station", "Missing gate station", inspectionDevs) return end if usedAction == "entry" then if getCardEntered(scan) and not getCardExited(scan) then - denyCard("already_entered", "Already entered") + denyCard("already_entered", "Already entered", inspectionDevs) return end local okWrite = true @@ -1273,36 +1410,42 @@ local function handleICCardScan(scan, side, scanDev) end if not okWrite then draw("WRITE ERROR", tostring(writeErr or "Failed to update card"), colors.red) - playDfpwm("error.dfpwm") + parallel.waitForAll( + function() playDfpwm("error.dfpwm") end, + function() triggerFaultLights(inspectionDevs) end + ) return end entryStation = exitStation else if not getCardEntered(scan) then - denyCard("not_entered", "Not entered") + denyCard("not_entered", "Not entered", inspectionDevs) return end if getCardExited(scan) then - denyCard("already_exited", "Already exited") + denyCard("already_exited", "Already exited", inspectionDevs) return end if #entryStation == 0 then - denyCard("missing_entry_station", "Missing entry station") + denyCard("missing_entry_station", "Missing entry station", inspectionDevs) return end local fareValue, fareErr = queryFare(entryStation, exitStation) if fareValue == nil then if fareErr == "net_error" then draw("NET ERROR", "Fare lookup failed", colors.red) - playDfpwm("error.dfpwm") + parallel.waitForAll( + function() playDfpwm("error.dfpwm") end, + function() triggerFaultLights(inspectionDevs) end + ) else - denyCard("fare_not_found", fareErr) + denyCard("fare_not_found", fareErr, inspectionDevs) end return end fare = fareValue if balance < fare then - denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance)) + denyCard("insufficient_balance", "Fare: " .. tostring(fare) .. " Bal: " .. tostring(balance), inspectionDevs) return end local okWrite = true @@ -1325,10 +1468,13 @@ local function handleICCardScan(scan, side, scanDev) else okWrite = false if tostring(newBalanceOrErr) == "insufficient" then - denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance)) + denyCard("insufficient_balance", "-" .. tostring(fare) .. " Left: " .. tostring(balance), inspectionDevs) else draw("WRITE ERROR", tostring(newBalanceOrErr or "deduct_failed"), colors.red) - playDfpwm("error.dfpwm") + parallel.waitForAll( + function() playDfpwm("error.dfpwm") end, + function() triggerFaultLights(inspectionDevs) end + ) end break end @@ -1336,7 +1482,10 @@ local function handleICCardScan(scan, side, scanDev) if not okWrite then if writeErr then draw("WRITE ERROR", tostring(writeErr), colors.red) - playDfpwm("error.dfpwm") + parallel.waitForAll( + function() playDfpwm("error.dfpwm") end, + function() triggerFaultLights(inspectionDevs) end + ) end return end @@ -1402,7 +1551,10 @@ local function handleScan(scan, side, scanDev) local ticketId = getTicketId(scan) if not ticketId then draw("Invalid ticket.", "Missing ticketId.", colors.red) - playDfpwm("error.dfpwm") + parallel.waitForAll( + function() playDfpwm("error.dfpwm") end, + function() triggerFaultLights(inspectionDevs) end + ) return end @@ -1440,13 +1592,19 @@ local function handleScan(scan, side, scanDev) if not ok or type(resp) ~= "table" then draw("NET ERROR", "Server check failed.", colors.red) - playDfpwm("error.dfpwm") + parallel.waitForAll( + function() playDfpwm("error.dfpwm") end, + function() triggerFaultLights(inspectionDevs) end + ) return end if resp.result ~= "pass" then draw("DENIED", tostring(resp.reason or "deny"), colors.red) - playDfpwm("error.dfpwm") + parallel.waitForAll( + function() playDfpwm("error.dfpwm") end, + function() triggerFaultLights(inspectionDevs) end + ) return end diff --git a/server/io.js b/server/io.js index 6b1b09c..d27e56b 100644 --- a/server/io.js +++ b/server/io.js @@ -6,7 +6,8 @@ module.exports = { cors: { origin: "*", methods: ["GET", "POST"] - } + }, + transports: ['websocket'] }); io.engine.on('connection_error', (err) => {