diff --git a/.gitignore b/.gitignore index 0667445..095fd32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,26 @@ -# ---> Lua -# Compiled Lua sources +.idea + +pzlibs +.emmyrc.json + +scripts +*.md + luac.out -# luarocks build files *.src.rock *.zip *.tar.gz -# Object files *.o *.os *.ko *.obj *.elf -# Precompiled Headers *.gch *.pch -# Libraries *.lib *.a *.la @@ -26,18 +28,14 @@ luac.out *.def *.exp -# Shared objects (inc. Windows DLLs) *.dll *.so *.so.* *.dylib -# Executables *.exe *.out *.app *.i*86 *.x86_64 *.hex - - diff --git a/Contents/mods/RconDataBridge/42/mod.info b/Contents/mods/RconDataBridge/42/mod.info new file mode 100644 index 0000000..31be1cd --- /dev/null +++ b/Contents/mods/RconDataBridge/42/mod.info @@ -0,0 +1,7 @@ +name=RCON Data Bridge +id=RconDataBridge +description=A Project Zomboid server mod enabling richer RCON tooling and automation. +poster=poster.png +author=Overlord_303 +modversion=0.1.0 +versionMin=42.0.0 diff --git a/Contents/mods/RconDataBridge/42/poster.png b/Contents/mods/RconDataBridge/42/poster.png new file mode 100644 index 0000000..8a7d53b Binary files /dev/null and b/Contents/mods/RconDataBridge/42/poster.png differ diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerGet.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerGet.lua new file mode 100644 index 0000000..363333e --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerGet.lua @@ -0,0 +1,20 @@ +-- Registration deferred to register(), called from RDB_Bootstrap's +-- onServerStarted, see RDB_OpWorldGetStats.lua. + +RDB_OpPlayerGet = {} + +function RDB_OpPlayerGet.register() + RDB_OpRegistry.register("get_player", { + argsSchema = { id = "string" }, + handler = function(args) + local snapshot = RDB_PlayerSnapshot.get(args.id) + if not snapshot then + return false, RDB_Constants.ERROR_CODES.PLAYER_NOT_FOUND, + "No registered player matched the supplied id." + end + + RDB_PlayerSnapshot.publishStatsOption(args.id) + return true, snapshot + end, + }) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerList.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerList.lua new file mode 100644 index 0000000..3f760c6 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpPlayerList.lua @@ -0,0 +1,48 @@ +-- Cursor-based pagination (spec: "Large responses use pagination or +-- cursor-based retrieval"): players are sorted by id, and the cursor is the +-- last id returned in the previous page. +-- +-- Registration deferred to register(), called from RDB_Bootstrap's +-- onServerStarted -- see RDB_OpWorldGetStats.lua for why. + +RDB_OpPlayerList = {} + +local DEFAULT_LIMIT = 50 +local MAX_LIMIT = 200 + +function RDB_OpPlayerList.register() + RDB_OpRegistry.register("list_players", { + handler = function(args) + local all = RDB_PlayerSnapshot.listAll() + + local limit = DEFAULT_LIMIT + if type(args.limit) == "number" then + limit = math.min(math.max(1, math.floor(args.limit)), MAX_LIMIT) + end + + local startIndex = 1 + if type(args.cursor) == "string" and args.cursor ~= "" then + for i, snapshot in ipairs(all) do + if snapshot.id > args.cursor then + startIndex = i + break + end + startIndex = i + 1 + end + end + + local page = {} + local endIndex = math.min(startIndex + limit - 1, #all) + for i = startIndex, endIndex do + page[#page + 1] = all[i] + end + + local nextCursor = nil + if endIndex < #all then + nextCursor = page[#page].id + end + + return true, { players = page, nextCursor = nextCursor } + end, + }) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpServerSave.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpServerSave.lua new file mode 100644 index 0000000..664b0fa --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpServerSave.lua @@ -0,0 +1,16 @@ +-- Registration deferred to register(), called from RDB_Bootstrap's +-- onServerStarted, see RDB_OpWorldGetStats.lua. + +RDB_OpServerSave = {} + +function RDB_OpServerSave.register() + RDB_OpRegistry.register("save_server", { + handler = function(_) + local ok = pcall(function() GameWindow.save(true) end) + if not ok then + return false, RDB_Constants.ERROR_CODES.SAVE_FAILED, "World save failed." + end + return true, { triggered = true } + end, + }) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpWorldGetStats.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpWorldGetStats.lua new file mode 100644 index 0000000..dccfe74 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/Ops/RDB_OpWorldGetStats.lua @@ -0,0 +1,17 @@ +-- Registration is deferred to register(), called from RDB_Bootstrap's +-- registerOps() (itself called from onServerStarted), rather than run at +-- file top-level: PZ's require() is best-effort (warns and continues rather +-- than forcing a synchronous load), so RDB_OpRegistry may not be defined yet +-- if this file's top-level code ran immediately at auto-load time. +-- onServerStarted fires only after every file has auto-loaded, so it's safe +-- there. + +RDB_OpWorldGetStats = {} + +function RDB_OpWorldGetStats.register() + RDB_OpRegistry.register("get_world_stats", { + handler = function(_) + return true, RDB_WorldStats.collect() + end, + }) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_AuditLog.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_AuditLog.lua new file mode 100644 index 0000000..66a8012 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_AuditLog.lua @@ -0,0 +1,48 @@ +-- Structured audit log: every remote action is logged with its request id, +-- operation, result, and timestamp, including rejected requests +-- (bad payload, unknown op, rate-limited, etc). Persisted as a bounded +-- ring buffer via ModData, plus a console line for live tailing. + +require("RconDataBridge.RDB_Log") + +RDB_AuditLog = {} + +local TAG = "RconDataBridge_AuditLog" +local MAX_ENTRIES = 500 + +local store + +local function ensureStore() + if not store then + store = ModData.getOrCreate(TAG) + if store.entries == nil then + store.entries = {} + store.nextIndex = 1 + end + end + + return store +end + +-- entry: { id, op, ok, code, ts } +function RDB_AuditLog.record(entry) + local s = ensureStore() + entry.ts = entry.ts or getTimestampMs() + if #s.entries < MAX_ENTRIES then + s.entries[#s.entries + 1] = entry + else + s.entries[s.nextIndex] = entry + s.nextIndex = (s.nextIndex % MAX_ENTRIES) + 1 + end + + local resultText = entry.ok and "ok" or ("error:" .. tostring(entry.code)) + RDB_Log.info( + "audit id=" .. tostring(entry.id) .. + " op=" .. tostring(entry.op) .. + " result=" .. resultText + ) +end + +function RDB_AuditLog.getEntries() + return ensureStore().entries +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Bootstrap.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Bootstrap.lua new file mode 100644 index 0000000..02aec97 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Bootstrap.lua @@ -0,0 +1,73 @@ +require("RconDataBridge.RDB_Constants") +require("RconDataBridge.RDB_Log") +require("RconDataBridge.RDB_Json") +require("RconDataBridge.RDB_Base64") +require("RconDataBridge.RDB_OptionsRegistry") +require("RconDataBridge.RDB_Config") +require("RconDataBridge.RDB_OpRegistry") +require("RconDataBridge.RDB_RequestPipeline") +require("RconDataBridge.RDB_WorldStats") +require("RconDataBridge.RDB_PlayerSnapshot") +require("RconDataBridge.Ops.RDB_OpWorldGetStats") +require("RconDataBridge.Ops.RDB_OpPlayerGet") +require("RconDataBridge.Ops.RDB_OpPlayerList") +require("RconDataBridge.Ops.RDB_OpServerSave") + +local function runSelfTests() + if not RDB_Constants.DEBUG then + return + end + + local ok, err = RDB_Json.selfTest() + if ok then + RDB_Log.info("RDB_Json self-test passed") + else + RDB_Log.error("RDB_Json self-test FAILED: " .. tostring(err)) + end + + local b64Ok, b64Err = RDB_Base64.selfTest() + if b64Ok then + RDB_Log.info("RDB_Base64 self-test passed") + else + RDB_Log.error("RDB_Base64 self-test FAILED: " .. tostring(b64Err)) + end +end + +local function registerBaseOptions() + local OPT = RDB_Constants.OPT + RDB_OptionsRegistry.register(OPT.PROTOCOL_VERSION, "{}", RDB_Constants.OPTION_MAX_LENGTH) + RDB_OptionsRegistry.register(OPT.WORLD_STATS, "{}", RDB_Constants.OPTION_MAX_LENGTH) + RDB_OptionsRegistry.register(OPT.REQUEST, "{}", RDB_Constants.OPTION_MAX_LENGTH) + RDB_OptionsRegistry.register(OPT.RESPONSE, "{}", RDB_Constants.OPTION_MAX_LENGTH) + RDB_OptionsRegistry.register(OPT.LAST_RESPONSE, "0", RDB_Constants.OPTION_MAX_LENGTH) +end + +local function registerOps() + RDB_OpWorldGetStats.register() + RDB_OpPlayerGet.register() + RDB_OpPlayerList.register() + RDB_OpServerSave.register() +end + +local function publishProtocolVersion() + local json = RDB_Json.encode({ + version = RDB_Constants.PROTOCOL_VERSION, + ops = RDB_OpRegistry.listNames(), + }) + + RDB_OptionsRegistry.set(RDB_Constants.OPT.PROTOCOL_VERSION, json) +end + +local function onServerStarted() + runSelfTests() + RDB_Config.init() + registerBaseOptions() + registerOps() + publishProtocolVersion() + RDB_RequestPipeline.init() + RDB_WorldStats.init() + RDB_PlayerSnapshot.init() + RDB_Log.info("bootstrap complete") +end + +Events.OnServerStarted.Add(onServerStarted) diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Config.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Config.lua new file mode 100644 index 0000000..a8b2cd8 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Config.lua @@ -0,0 +1,45 @@ +---@diagnostic disable: need-check-nil + +-- ModData-backed bridge configuration: rate limits, payload cap, sensitive +-- field policy. Defaults are baked in and only written to the ModData table +-- the first time it's created. + +require("RconDataBridge.RDB_Constants") + +RDB_Config = {} + +local TAG = "RconDataBridge_Config" + +local DEFAULTS = { + maxPayloadBytes = RDB_Constants.MAX_PAYLOAD_BYTES, + rateLimitMaxRequests = 10, + rateLimitWindowSeconds = 10, + includeSensitiveFields = false, +} + +local store + +function RDB_Config.init() + store = ModData.getOrCreate(TAG) + for key, value in pairs(DEFAULTS) do + if store[key] == nil then + store[key] = value + end + end +end + +function RDB_Config.get(key) + if not store then + RDB_Config.init() + end + + return store[key] +end + +function RDB_Config.set(key, value) + if not store then + RDB_Config.init() + end + + store[key] = value +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua new file mode 100644 index 0000000..a814e4e --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Idempotency.lua @@ -0,0 +1,84 @@ +-- Processed request-id -> cached response, so a repeated request id replays +-- the original response instead of re-executing the op. +-- Persisted via ModData so it survives restarts. +-- +-- Entries expire after RDB_Constants.IDEMPOTENCY_TTL_MS (60s by default): +-- a request id is only idempotent for retries within that window, not +-- forever. Without a TTL, a client that reuses a fixed id for routine +-- polling would get the first-ever response back on every call, since a +-- low-traffic server would rarely reach MAX_ENTRIES and trigger the +-- count-based eviction below. + +require("RconDataBridge.RDB_Constants") +require("RconDataBridge.RDB_Log") + +RDB_Idempotency = {} + +local TAG = "RconDataBridge_Idempotency" +local MAX_ENTRIES = 500 + +local store + +local function ensureStore() + if not store then + store = ModData.getOrCreate(TAG) + end + return store +end + +local function isExpired(entry) + return getTimestampMs() - (entry.ts or 0) >= RDB_Constants.IDEMPOTENCY_TTL_MS +end + +function RDB_Idempotency.has(id) + local entry = ensureStore()[id] + return entry ~= nil and not isExpired(entry) +end + +function RDB_Idempotency.get(id) + local entry = ensureStore()[id] + if not entry or isExpired(entry) then + return nil + end + + return entry.response +end + +-- Sweeps expired entries first (the common case that keeps the store small +-- under normal traffic), then falls back to trimming the oldest entries if +-- still over MAX_ENTRIES (a backstop against a burst of distinct ids within +-- a single TTL window). +local function prune(s) + for id, entry in pairs(s) do + if isExpired(entry) then + s[id] = nil + end + end + + local n = 0 + for _ in pairs(s) do + n = n + 1 + end + + if n <= MAX_ENTRIES then + return + end + + local entries = {} + for id, entry in pairs(s) do + entries[#entries + 1] = { id = id, ts = entry.ts or 0 } + end + + table.sort(entries, function(a, b) return a.ts < b.ts end) + + local toRemove = n - MAX_ENTRIES + for i = 1, toRemove do + s[entries[i].id] = nil + end +end + +function RDB_Idempotency.put(id, response) + local s = ensureStore() + s[id] = { response = response, ts = getTimestampMs() } + prune(s) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OpRegistry.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OpRegistry.lua new file mode 100644 index 0000000..e5d4824 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OpRegistry.lua @@ -0,0 +1,59 @@ +-- Table-driven op registry. This is the allowlist: dispatch only ever looks +-- up a handler function by exact op name in this table -- there is no +-- dynamic _G[name]() call and no load()/loadstring() anywhere in this mod. + +require("RconDataBridge.RDB_Constants") + +RDB_OpRegistry = {} + +local handlers = {} + +-- def: { handler = function(args) -> ok, dataOrErrCode[, errMessage], argsSchema = {fieldName = "type", ...} } +function RDB_OpRegistry.register(name, def) + handlers[name] = def +end + +function RDB_OpRegistry.isRegistered(name) + return handlers[name] ~= nil +end + +function RDB_OpRegistry.listNames() + local names = {} + for name in pairs(handlers) do + names[#names + 1] = name + end + table.sort(names) + return names +end + +-- Validates args against the op's declared schema (shallow: field presence + +-- Lua type of top-level fields), then dispatches. Returns: +-- ok, data on success +-- false, errCode, errMessage on failure (schema or handler-reported error) +function RDB_OpRegistry.dispatch(name, args) + local def = handlers[name] + if not def then + return false, RDB_Constants.ERROR_CODES.OP_NOT_ALLOWED, "Unknown or disallowed operation: " .. tostring(name) + end + + args = args or {} + if def.argsSchema then + for field, expectedType in pairs(def.argsSchema) do + local value = args[field] + if value == nil then + return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Missing required arg: " .. field + end + if type(value) ~= expectedType then + return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, + "Arg " .. field .. " must be " .. expectedType + end + end + end + + local ok, dataOrCode, message = def.handler(args) + if not ok then + return false, dataOrCode, message + end + + return true, dataOrCode +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OptionsRegistry.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OptionsRegistry.lua new file mode 100644 index 0000000..d2c7505 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_OptionsRegistry.lua @@ -0,0 +1,59 @@ +-- Thin wrapper around zombie.network.ServerOptions. +-- +-- Registration must do two things for an option to be both settable (via +-- changeoption) and visible (via showoptions): +-- 1. ServerOptions.instance:addOption(...) -- makes it settable/gettable +-- 2. ServerOptions.instance:getPublicOptions():add(name) -- makes it visible +-- addOption alone is not enough: showoptions reads a separate publicOptions +-- list that addOption never touches. +-- +-- Registration is idempotent: Events.OnServerStarted fires more than once per +-- boot. A getOptionByName pre-check alone isn't sufficient to suppress the +-- resulting duplicate-name IllegalArgumentException from addOption (observed +-- in practice even with the pre-check in place, likely a timing/ordering +-- quirk between the two OnServerStarted firings); addOption is wrapped in +-- pcall as a second, authoritative line of defense. Runtime state ends up +-- correct either way. +-- +-- Every published value is base64-encoded here, uniformly, for both +-- directions: register()'s default and set()'s value both go through +-- RDB_Base64.encode before reaching ServerOptions. This isn't optional for +-- the request channel (changeoption strips every literal double-quote +-- character from its arguments, corrupting raw JSON and is applied to every +-- other option too so RCON clients deal with one rule, not request-only +-- encoding as a surprise exception. get() intentionally stays raw: +-- the request pipeline diffs the raw stored string for change detection +-- and needs to see it either way, decode failure included. + +require("RconDataBridge.RDB_Log") +require("RconDataBridge.RDB_Base64") + +RDB_OptionsRegistry = {} + +function RDB_OptionsRegistry.register(name, defaultValue, maxLength) + local so = ServerOptions.instance + if so:getOptionByName(name) ~= nil then + return + end + + local opt = TextServerOption.new(so, name, RDB_Base64.encode(defaultValue), maxLength) + local ok = pcall(function() so:addOption(opt) end) + if not ok then + return + end + + so:getPublicOptions():add(name) + RDB_Log.info("registered option " .. name) +end + +function RDB_OptionsRegistry.get(name) + return ServerOptions.instance:getOption(name) +end + +-- Uses putOption (in-memory only), not changeOption: changeOption also does a +-- full saveServerTextFile disk write on every call, which is fine for a real +-- RCON-initiated config change but wasteful for our own frequent internal +-- publishes (Response, WorldStats, etc. are runtime state, not persisted config). +function RDB_OptionsRegistry.set(name, value) + ServerOptions.instance:putOption(name, RDB_Base64.encode(value)) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua new file mode 100644 index 0000000..29d3bd7 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_PlayerSnapshot.lua @@ -0,0 +1,155 @@ +-- Normalized player snapshots: id (username), online, characterId, +-- lastSeenAt, stats{hoursSurvived, zombiesKilled}. +-- Persisted via ModData so offline lookups work, there is no +-- engine-provided offline-player registry API (checked exhaustively +-- against the stub trees), so this snapshot store is the only source +-- of truth for offline players.. +-- +-- Connect/disconnect detection: no server-side connect/disconnect event was +-- found in the stub trees, so this polls getOnlinePlayers() on the same +-- tick throttle as the request pipeline and diffs against the last-known +-- online set. + +require("RconDataBridge.RDB_Constants") +require("RconDataBridge.RDB_Log") +require("RconDataBridge.RDB_Json") +require("RconDataBridge.RDB_OptionsRegistry") + +RDB_PlayerSnapshot = {} + +local TAG = "RconDataBridge_PlayerSnapshots" +local store + +local function ensureStore() + if not store then + store = ModData.getOrCreate(TAG) + end + + return store +end + +local function buildStatsFromPlayer(player) + return { + hoursSurvived = player:getHoursSurvived(), + zombiesKilled = player:getZombieKills(), + } +end + +-- Refreshes (or creates) the snapshot for an online IsoPlayer. +function RDB_PlayerSnapshot.refresh(player) + local username = player:getUsername() + local s = ensureStore() + s[username] = { + id = username, + online = true, + characterId = player:getOnlineID(), + lastSeenAt = getTimestampMs(), + stats = buildStatsFromPlayer(player), + } +end + +-- Marks a previously-online player as offline, keeping their last known +-- stats and characterId. +function RDB_PlayerSnapshot.markOffline(username) + local s = ensureStore() + local existing = s[username] + if not existing then + return + end + + existing.online = false + existing.lastSeenAt = getTimestampMs() +end + +function RDB_PlayerSnapshot.get(id) + return ensureStore()[id] +end + +-- RconDataBridge_PlayerStats_ options are registered lazily, only for +-- players actually queried, not eagerly for the whole roster: ServerOptions +-- has no observed removeOption, so eager per-player registration would +-- grow unboundedly and bloat every RCON client's showoptions output +-- for a server with any real player history. +-- Not restored across a restart, it's simply re-created the next time +-- that player is queried. +function RDB_PlayerSnapshot.publishStatsOption(id) + local snapshot = ensureStore()[id] + if not snapshot then + return + end + + local optionName = RDB_Constants.PLAYER_STATS_PREFIX .. id:gsub("[^%w_]", "_") + RDB_OptionsRegistry.register(optionName, "{}", RDB_Constants.OPTION_MAX_LENGTH) + RDB_OptionsRegistry.set(optionName, RDB_Json.encode(snapshot)) +end + +function RDB_PlayerSnapshot.listAll() + local out = {} + for _, snapshot in pairs(ensureStore()) do + out[#out + 1] = snapshot + end + + table.sort(out, function(a, b) return a.id < b.id end) + return out +end + +-- Death hook: refresh immediately so the final stats (e.g. zombiesKilled at +-- time of death) are captured rather than waiting for the next periodic +-- sweep. +function RDB_PlayerSnapshot.onCharacterDeath(character) + if character == nil or character.getUsername == nil then + return -- not a player character + end + + local ok = pcall(RDB_PlayerSnapshot.refresh, character) + if not ok then + return + end +end + +local knownOnline = {} + +local function pollOnlineRoster() + local currentOnline = {} + local players = getOnlinePlayers() + for i = 0, players:size() - 1 do + local player = players:get(i) + local username = player:getUsername() + currentOnline[username] = true + RDB_PlayerSnapshot.refresh(player) + end + + for username in pairs(knownOnline) do + if not currentOnline[username] then + RDB_PlayerSnapshot.markOffline(username) + end + end + + knownOnline = currentOnline +end + +local tickCounter = 0 +local initialized = false + +local function onTick() + tickCounter = tickCounter + 1 + if tickCounter < RDB_Constants.POLL_EVERY_TICKS then + return + end + + tickCounter = 0 + local ok, err = pcall(pollOnlineRoster) + if not ok then + RDB_Log.error("error polling online roster: " .. tostring(err)) + end +end + +function RDB_PlayerSnapshot.init() + if initialized then + return + end + initialized = true + + Events.OnTick.Add(onTick) + Events.OnCharacterDeath.Add(RDB_PlayerSnapshot.onCharacterDeath) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua new file mode 100644 index 0000000..8470122 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_RequestPipeline.lua @@ -0,0 +1,151 @@ +-- Polls RconDataBridge_Request for changes (there is no "option changed" +-- event in the PZ Lua API) and runs the full request/response pipeline. +-- Every outcome, including every rejection, is audited. + +require("RconDataBridge.RDB_Constants") +require("RconDataBridge.RDB_Json") +require("RconDataBridge.RDB_Base64") +require("RconDataBridge.RDB_Log") +require("RconDataBridge.RDB_OptionsRegistry") +require("RconDataBridge.RDB_Security") +require("RconDataBridge.RDB_Idempotency") +require("RconDataBridge.RDB_AuditLog") +require("RconDataBridge.RDB_OpRegistry") + +RDB_RequestPipeline = {} + +local lastSeenRaw = nil +local tickCounter = 0 + +local function buildOkResponse(id, data) + return { + v = RDB_Constants.PROTOCOL_VERSION, + id = id, + ok = true, + data = data or {} + } +end + +local function buildErrorResponse(id, code, message) + return { + v = RDB_Constants.PROTOCOL_VERSION, + id = id, + ok = false, + error = { code = code, message = message }, + } +end + +local function publishResponse(response) + RDB_OptionsRegistry.set(RDB_Constants.OPT.RESPONSE, RDB_Json.encode(response)) + RDB_OptionsRegistry.set(RDB_Constants.OPT.LAST_RESPONSE, tostring(getTimestampMs())) +end + +local function audit(id, op, ok, code) + RDB_AuditLog.record({ id = id, op = op, ok = ok, code = code }) +end + +-- Handles one raw request string end to end. raw is base64: RCON's +-- changeoption command strips every literal double-quote character from +-- every argument (see RESEARCH_LOG.md), so a client can't send raw JSON -- +-- base64 has no quote characters in its alphabet and survives intact. +local function processRequest(rawBase64) + local raw, b64Err = RDB_Base64.decode(rawBase64) + if b64Err then + local response = buildErrorResponse("", RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Invalid base64: " .. b64Err) + publishResponse(response) + audit("", "", false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR) + return + end + + local sizeOk, sizeCode, sizeMsg = RDB_Security.checkPayloadSize(raw) + if not sizeOk then + local response = buildErrorResponse("", sizeCode, sizeMsg) + publishResponse(response) + audit("", "", false, sizeCode) + return + end + + local decoded, decodeErr = RDB_Json.decode(raw) + if decodeErr then + local response = buildErrorResponse("", RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Invalid JSON: " .. decodeErr) + publishResponse(response) + audit("", "", false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR) + return + end + + -- Empty object ({}) is the option's rest state, not a real request. + if type(decoded) == "table" and decoded.v == nil and decoded.id == nil and decoded.op == nil then + return + end + + local envelopeOk, envelopeCode, envelopeMsg = RDB_Security.validateEnvelope(decoded) + if not envelopeOk then + local id = (type(decoded) == "table" and type(decoded.id) == "string") and decoded.id or "" + local response = buildErrorResponse(id, envelopeCode, envelopeMsg) + publishResponse(response) + audit(id, (type(decoded) == "table" and decoded.op) or "", false, envelopeCode) + return + end + + local id, op, args = decoded.id, decoded.op, decoded.args + + if RDB_Idempotency.has(id) then + local cached = RDB_Idempotency.get(id) + publishResponse(cached) + audit(id, op, cached.ok, cached.ok and nil or (cached.error and cached.error.code)) + return + end + + local rateOk, rateCode, rateMsg = RDB_Security.checkRateLimit() + if not rateOk then + local response = buildErrorResponse(id, rateCode, rateMsg) + publishResponse(response) + audit(id, op, false, rateCode) + return + end + + local dispatchOk, dataOrCode, message = RDB_OpRegistry.dispatch(op, args) + local response + if dispatchOk then + response = buildOkResponse(id, dataOrCode) + else + response = buildErrorResponse(id, dataOrCode, message) + end + + RDB_Idempotency.put(id, response) + publishResponse(response) + audit(id, op, dispatchOk, dispatchOk and nil or dataOrCode) +end + +local function onTick() + tickCounter = tickCounter + 1 + if tickCounter < RDB_Constants.POLL_EVERY_TICKS then + return + end + tickCounter = 0 + + local raw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST) + if raw == lastSeenRaw then + return + end + lastSeenRaw = raw + + local ok, err = pcall(processRequest, raw) + if not ok then + RDB_Log.error("unhandled error processing request: " .. tostring(err)) + end +end + +local initialized = false + +-- Guarded: Events.OnServerStarted fires more than once per boot (see +-- RESEARCH_LOG.md), and Events.OnTick.Add does not deduplicate identical +-- listeners, so calling this twice would otherwise double-register onTick. +function RDB_RequestPipeline.init() + if initialized then + return + end + initialized = true + lastSeenRaw = RDB_OptionsRegistry.get(RDB_Constants.OPT.REQUEST) + Events.OnTick.Add(onTick) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Security.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Security.lua new file mode 100644 index 0000000..d60f00e --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_Security.lua @@ -0,0 +1,63 @@ +-- Payload size cap, envelope schema validation, and rate limiting. +-- +-- Rate limiting is necessarily global (not per-caller): RCON requests carry +-- no per-connection identity in this transport -- every RCON client shares +-- the same RconDataBridge_Request mailbox. The window is an in-memory sliding +-- log; it does not need to survive a restart. + +require("RconDataBridge.RDB_Constants") +require("RconDataBridge.RDB_Config") + +RDB_Security = {} + +function RDB_Security.checkPayloadSize(raw) + if #raw > RDB_Config.get("maxPayloadBytes") then + return false, RDB_Constants.ERROR_CODES.PAYLOAD_TOO_LARGE, "Request payload exceeds size limit" + end + + return true +end + +-- Requires: v (number, ==PROTOCOL_VERSION), id (non-empty string), op (non-empty string), +-- args (table or nil). +function RDB_Security.validateEnvelope(obj) + if type(obj) ~= "table" then + return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Request must be a JSON object" + end + if obj.v ~= RDB_Constants.PROTOCOL_VERSION then + return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Unsupported or missing protocol version" + end + if type(obj.id) ~= "string" or #obj.id == 0 or #obj.id > 128 then + return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Missing or invalid id" + end + if type(obj.op) ~= "string" or #obj.op == 0 then + return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "Missing or invalid op" + end + if obj.args ~= nil and type(obj.args) ~= "table" then + return false, RDB_Constants.ERROR_CODES.SCHEMA_ERROR, "args must be an object when present" + end + return true +end + +local requestTimestamps = {} + +function RDB_Security.checkRateLimit() + local now = getTimestampMs() + local windowMs = RDB_Config.get("rateLimitWindowSeconds") * 1000 + local maxRequests = RDB_Config.get("rateLimitMaxRequests") + + local kept = {} + for _, ts in ipairs(requestTimestamps) do + if now - ts < windowMs then + kept[#kept + 1] = ts + end + end + requestTimestamps = kept + + if #requestTimestamps >= maxRequests then + return false, RDB_Constants.ERROR_CODES.RATE_LIMITED, "Too many requests, slow down" + end + + requestTimestamps[#requestTimestamps + 1] = now + return true +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_WorldStats.lua b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_WorldStats.lua new file mode 100644 index 0000000..2430812 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/server/RconDataBridge/RDB_WorldStats.lua @@ -0,0 +1,49 @@ +-- World telemetry, sourced from zombie.GameTime (getGameTime()). + +require("RconDataBridge.RDB_Constants") +require("RconDataBridge.RDB_Log") +require("RconDataBridge.RDB_Json") +require("RconDataBridge.RDB_OptionsRegistry") + +RDB_WorldStats = {} + +local cached = {} + +function RDB_WorldStats.collect() + return cached +end + +local function refresh() + local gt = getGameTime() + + cached = { + worldAgeHours = gt:getWorldAgeHours(), + worldAgeDays = gt:getWorldAgeDaysSinceBegin(), + nightsSurvived = gt:getNightsSurvived(), + date = { + year = gt:getYear(), + month = gt:getMonth(), + day = gt:getDay(), + hour = gt:getHour(), + minute = gt:getMinutes(), + }, + isNight = gt:isNight(), + isRaining = gt:isRainingToday(), + timeMultiplier = gt:getMultiplier(), + generatedAt = getTimestampMs(), + } + + RDB_OptionsRegistry.set(RDB_Constants.OPT.WORLD_STATS, RDB_Json.encode(cached)) +end + +local initialized = false + +function RDB_WorldStats.init() + if initialized then + return + end + + initialized = true + refresh() + Events.EveryOneMinute.Add(refresh) +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Base64.lua b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Base64.lua new file mode 100644 index 0000000..e081ae9 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Base64.lua @@ -0,0 +1,106 @@ +---@diagnostic disable: param-type-mismatch, need-check-nil + +-- Minimal base64 encode/decode. Required for the request channel: RCON's +-- command-line tokenizer (CommandBase's constructor) strips every literal +-- double-quote character from every changeoption argument, confirmed by +-- decompiling CommandBase, and confirmed empirically (a raw JSON payload +-- comes out with every " removed, corrupting it beyond recovery). No quoting +-- strategy survives this since the strip is unconditional and happens after +-- tokenization regardless of which token form matched. Base64 has no quote +-- characters in its alphabet, so it survives changeoption intact. +-- +-- Applied uniformly to every RconDataBridge_* option in both directions +-- (see RDB_OptionsRegistry.register/set), not just the request channel that +-- strictly needs it: a client only ever has to deal with one rule ("every +-- value is base64"), never a request-only exception to remember. + +RDB_Base64 = {} + +local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +local decodeTable +local function buildDecodeTable() + decodeTable = {} + for i = 1, #ALPHABET do + decodeTable[ALPHABET:sub(i, i)] = i - 1 + end +end + +function RDB_Base64.encode(data) + local out = {} + local len = #data + local i = 1 + while i <= len do + local b1, b2, b3 = data:byte(i, i + 2) + b2 = b2 or 0 + b3 = b3 or 0 + + local n = b1 * 65536 + b2 * 256 + b3 + + local c1 = math.floor(n / 262144) % 64 + local c2 = math.floor(n / 4096) % 64 + local c3 = math.floor(n / 64) % 64 + local c4 = n % 64 + + out[#out + 1] = ALPHABET:sub(c1 + 1, c1 + 1) + out[#out + 1] = ALPHABET:sub(c2 + 1, c2 + 1) + out[#out + 1] = (i + 1 <= len) and ALPHABET:sub(c3 + 1, c3 + 1) or "=" + out[#out + 1] = (i + 2 <= len) and ALPHABET:sub(c4 + 1, c4 + 1) or "=" + + i = i + 3 + end + return table.concat(out) +end + +function RDB_Base64.decode(str) + if not decodeTable then + buildDecodeTable() + end + + str = str:gsub("=", "") + local out = {} + local i = 1 + local len = #str + while i <= len do + local c1 = decodeTable[str:sub(i, i)] + local c2 = decodeTable[str:sub(i + 1, i + 1)] + local c3str = str:sub(i + 2, i + 2) + local c4str = str:sub(i + 3, i + 3) + local c3 = c3str ~= "" and decodeTable[c3str] or nil + local c4 = c4str ~= "" and decodeTable[c4str] or nil + + if c1 == nil or c2 == nil then + return nil, "invalid base64 input" + end + + local n = c1 * 262144 + c2 * 4096 + (c3 or 0) * 64 + (c4 or 0) + + out[#out + 1] = string.char(math.floor(n / 65536) % 256) + if c3 ~= nil then + out[#out + 1] = string.char(math.floor(n / 256) % 256) + end + if c4 ~= nil then + out[#out + 1] = string.char(n % 256) + end + + i = i + 4 + end + return table.concat(out) +end + +function RDB_Base64.selfTest() + local samples = { "", "a", "ab", "abc", '{"v":1,"id":"t1","op":"echo","args":{"x":1}}' } + + for _, s in ipairs(samples) do + local encoded = RDB_Base64.encode(s) + local decoded, err = RDB_Base64.decode(encoded) + if err then + return false, "decode error for " .. s .. ": " .. err + end + if decoded ~= s then + return false, "round-trip mismatch for " .. s + end + end + + return true +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Constants.lua b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Constants.lua new file mode 100644 index 0000000..7377467 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Constants.lua @@ -0,0 +1,44 @@ +-- Shared constants: option names, protocol version, limits. +-- +-- Option names use underscores, not dots: RCON's changeoption command validates +-- the option-name argument against the regex \w+ (word characters only), so a +-- dotted name is silently rejected before it ever reaches the option lookup. + +RDB_Constants = {} + +RDB_Constants.DEBUG = true + +RDB_Constants.PROTOCOL_VERSION = 1 + +RDB_Constants.OPT = { + PROTOCOL_VERSION = "RconDataBridge_ProtocolVersion", + WORLD_STATS = "RconDataBridge_WorldStats", + REQUEST = "RconDataBridge_Request", + RESPONSE = "RconDataBridge_Response", + LAST_RESPONSE = "RconDataBridge_LastResponse", +} + +RDB_Constants.PLAYER_STATS_PREFIX = "RconDataBridge_PlayerStats_" + +RDB_Constants.MAX_PAYLOAD_BYTES = 4096 +RDB_Constants.OPTION_MAX_LENGTH = 8192 +RDB_Constants.POLL_EVERY_TICKS = 30 + +-- How long a request id's cached response is replayed for on retry before +-- it's treated as expired and the op runs again. Deliberately short: a +-- client that reuses a fixed id for routine polling (e.g. "ws-poll" every +-- few minutes) should get a fresh answer each time, not the first-ever +-- response forever. A count-based cap alone (MAX_ENTRIES in +-- RDB_Idempotency) doesn't help here, it only evicts under high request +-- volume, never under low-and-slow polling, which is exactly this case. +RDB_Constants.IDEMPOTENCY_TTL_MS = 60 * 1000 + +RDB_Constants.ERROR_CODES = { + SCHEMA_ERROR = "SCHEMA_ERROR", + PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE", + OP_NOT_ALLOWED = "OP_NOT_ALLOWED", + RATE_LIMITED = "RATE_LIMITED", + PLAYER_NOT_FOUND = "PLAYER_NOT_FOUND", + SAVE_FAILED = "SAVE_FAILED", + INTERNAL_ERROR = "INTERNAL_ERROR", +} diff --git a/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Json.lua b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Json.lua new file mode 100644 index 0000000..9761074 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Json.lua @@ -0,0 +1,392 @@ +---@diagnostic disable: need-check-nil + +-- Dependency-free JSON encode/decode. No JSON library exists anywhere in the +-- PZ Lua API (checked exhaustively against the umbrella + zdoc-lua stub +-- trees), so the mod brings its own minimal one. +-- +-- encode() always produces compact output (no whitespace). This is required, +-- not just stylistic: RCON's command tokenizer only treats double-quoted +-- spans as a single argument, so a payload containing a literal space would +-- be split into multiple arguments and fail parsing. + +RDB_Json = {} + +local ESCAPES = { + ['"'] = '\\"', + ['\\'] = '\\\\', + ['\b'] = '\\b', + ['\f'] = '\\f', + ['\n'] = '\\n', + ['\r'] = '\\r', + ['\t'] = '\\t', +} + +local function encodeString(s) + local out = { '"' } + + for i = 1, #s do + local c = s:sub(i, i) + local esc = ESCAPES[c] + if esc then + out[#out + 1] = esc + elseif c:byte() < 0x20 then + out[#out + 1] = string.format('\\u%04x', c:byte()) + else + out[#out + 1] = c + end + end + + out[#out + 1] = '"' + + return table.concat(out) +end + +local function isArray(t) + local n = 0 + + for _ in pairs(t) do + n = n + 1 + end + + if n == 0 then + return true -- empty table encodes as [] ; use {} explicitly for an empty object + end + + for i = 1, n do + if t[i] == nil then + return false + end + end + + return true +end + +local encodeValue + +local function encodeArray(t) + local parts = {} + + for i = 1, #t do + parts[i] = encodeValue(t[i]) + end + + return "[" .. table.concat(parts, ",") .. "]" +end + +local function encodeObject(t) + local parts = {} + + for k, v in pairs(t) do + parts[#parts + 1] = encodeString(tostring(k)) .. ":" .. encodeValue(v) + end + + return "{" .. table.concat(parts, ",") .. "}" +end + +encodeValue = function(v) + local t = type(v) + + if v == nil then + return "null" + elseif t == "boolean" then + return v and "true" or "false" + elseif t == "number" then + return tostring(v) + elseif t == "string" then + return encodeString(v) + elseif t == "table" then + if isArray(v) then + return encodeArray(v) + end + return encodeObject(v) + end + + error("RDB_Json.encode: unsupported type " .. t) +end + +function RDB_Json.encode(value) + return encodeValue(value) +end + +-- Decoder: simple recursive-descent parser over a string + cursor index. + +local function newParser(str) + return { str = str, pos = 1, len = #str } +end + +local function peek(p) + if p.pos > p.len then + return nil + end + + return p.str:sub(p.pos, p.pos) +end + +local function skipWhitespace(p) + while p.pos <= p.len do + local c = p.str:sub(p.pos, p.pos) + + if c == " " or c == "\t" or c == "\n" or c == "\r" then + p.pos = p.pos + 1 + else + break + end + end +end + +local parseValue + +local function parseLiteral(p, literal, value) + if p.str:sub(p.pos, p.pos + #literal - 1) ~= literal then + return nil, "expected " .. literal + end + p.pos = p.pos + #literal + return value +end + +local function parseString(p) + if peek(p) ~= '"' then + return nil, "expected string" + end + + p.pos = p.pos + 1 + local out = {} + + while true do + if p.pos > p.len then + return nil, "unterminated string" + end + + local c = p.str:sub(p.pos, p.pos) + + if c == '"' then + p.pos = p.pos + 1 + return table.concat(out) + elseif c == "\\" then + local nextC = p.str:sub(p.pos + 1, p.pos + 1) + + if nextC == "u" then + local hex = p.str:sub(p.pos + 2, p.pos + 5) + local code = tonumber(hex, 16) + if not code then + return nil, "invalid \\u escape" + end + + out[#out + 1] = string.char(code < 256 and code or 63) + p.pos = p.pos + 6 + else + local unescaped = ({ + ['"'] = '"', ['\\'] = '\\', ['/'] = '/', + b = '\b', f = '\f', n = '\n', r = '\r', t = '\t', + })[nextC] + + if not unescaped then + return nil, "invalid escape \\" .. tostring(nextC) + end + + out[#out + 1] = unescaped + p.pos = p.pos + 2 + end + else + out[#out + 1] = c + p.pos = p.pos + 1 + end + end +end + +local function parseNumber(p) + local start = p.pos + + if peek(p) == "-" then + p.pos = p.pos + 1 + end + + while peek(p) and peek(p):match("%d") do + p.pos = p.pos + 1 + end + + if peek(p) == "." then + p.pos = p.pos + 1 + + while peek(p) and peek(p):match("%d") do + p.pos = p.pos + 1 + end + end + + if peek(p) == "e" or peek(p) == "E" then + p.pos = p.pos + 1 + + if peek(p) == "+" or peek(p) == "-" then + p.pos = p.pos + 1 + end + + while peek(p) and peek(p):match("%d") do + p.pos = p.pos + 1 + end + end + + local numStr = p.str:sub(start, p.pos - 1) + local n = tonumber(numStr) + if not n then + return nil, "invalid number" + end + + return n +end + +local function parseArray(p) + p.pos = p.pos + 1 -- consume [ + local out = {} + skipWhitespace(p) + + if peek(p) == "]" then + p.pos = p.pos + 1 + return out + end + + local i = 1 + while true do + skipWhitespace(p) + + local v, err = parseValue(p) + if err then + return nil, err + end + + out[i] = v + i = i + 1 + skipWhitespace(p) + local c = peek(p) + + if c == "," then + p.pos = p.pos + 1 + elseif c == "]" then + p.pos = p.pos + 1 + return out + else + return nil, "expected , or ] in array" + end + end +end + +local function parseObject(p) + p.pos = p.pos + 1 -- consume { + local out = {} + skipWhitespace(p) + + if peek(p) == "}" then + p.pos = p.pos + 1 + return out + end + + while true do + skipWhitespace(p) + + local key, err = parseString(p) + if err then + return nil, err + end + + skipWhitespace(p) + + if peek(p) ~= ":" then + return nil, "expected : in object" + end + + p.pos = p.pos + 1 + skipWhitespace(p) + + local value + value, err = parseValue(p) + if err then + return nil, err + end + + out[key] = value + skipWhitespace(p) + + local c = peek(p) + if c == "," then + p.pos = p.pos + 1 + elseif c == "}" then + p.pos = p.pos + 1 + return out + else + return nil, "expected , or } in object" + end + end +end + +parseValue = function(p) + skipWhitespace(p) + + local c = peek(p) + if c == nil then + return nil, "unexpected end of input" + elseif c == '"' then + return parseString(p) + elseif c == "{" then + return parseObject(p) + elseif c == "[" then + return parseArray(p) + elseif c == "t" then + return parseLiteral(p, "true", true) + elseif c == "f" then + return parseLiteral(p, "false", false) + elseif c == "n" then + return parseLiteral(p, "null", nil) + elseif c == "-" or c:match("%d") then + return parseNumber(p) + end + + return nil, "unexpected character " .. c +end + +-- Round-trips a nested table through encode/decode and checks the result +-- matches. Not exhaustive, just a smoke test that the codec isn't broken. +function RDB_Json.selfTest() + local sample = { + v = 1, + id = "t1", + ok = true, + nested = { a = 1, b = "two", c = { 1, 2, 3 } }, + empty = {}, + } + + local encoded = RDB_Json.encode(sample) + local decoded, err = RDB_Json.decode(encoded) + + if err then + return false, "decode error: " .. err + end + if decoded.v ~= 1 or decoded.id ~= "t1" or decoded.ok ~= true then + return false, "top-level mismatch" + end + if decoded.nested.a ~= 1 or decoded.nested.b ~= "two" then + return false, "nested mismatch" + end + if #decoded.nested.c ~= 3 or decoded.nested.c[2] ~= 2 then + return false, "array mismatch" + end + return true +end + +function RDB_Json.decode(str) + if type(str) ~= "string" then + return nil, "input is not a string" + end + + local p = newParser(str) + skipWhitespace(p) + local value, err = parseValue(p) + if err then + return nil, err + end + + skipWhitespace(p) + if p.pos <= p.len then + return nil, "trailing data after value" + end + + return value +end diff --git a/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Log.lua b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Log.lua new file mode 100644 index 0000000..287f0d0 --- /dev/null +++ b/Contents/mods/RconDataBridge/common/media/lua/shared/RconDataBridge/RDB_Log.lua @@ -0,0 +1,15 @@ +RDB_Log = {} + +local PREFIX = "[RconDataBridge] " + +function RDB_Log.info(msg) + print(PREFIX .. "INFO: " .. tostring(msg)) +end + +function RDB_Log.warn(msg) + print(PREFIX .. "WARN: " .. tostring(msg)) +end + +function RDB_Log.error(msg) + print(PREFIX .. "ERROR: " .. tostring(msg)) +end diff --git a/Contents/mods/RconDataBridge/common/media/sandbox-options.txt b/Contents/mods/RconDataBridge/common/media/sandbox-options.txt new file mode 100644 index 0000000..e69de29