Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- --[[
- MD_Sky Persistent Player Tracker
- Minecraft 1.21.1 / CC:Tweaked / Advanced Peripherals
- Supports Advanced Peripherals 0.7.x and 0.8.x naming where practical.
- Data directory:
- /md_sky_tracker/events.log high-level append-only timeline (JSONL)
- /md_sky_tracker/movements.log compact every-block movement stream
- /md_sky_tracker/locations.json discovered location database
- /md_sky_tracker/state.json crash/reboot recovery state
- /md_sky_tracker/player_debug.json last raw player object seen
- Important server-side Advanced Peripherals settings for worldwide tracking:
- playerDetMaxRange = -1
- enablePlayerPosFunction = true
- morePlayerInformation = true
- multidimensional player detector setting = true
- enablePlayerPosRandomError = false -- if your AP version has this option
- On some 1.21.1 0.7.x configs the multidimensional option under
- [Peripherals.Player_Detector] is unfortunately named chatBoxMultiDimensional.
- The AP docs call the setting playerDetMultiDimensional.
- The program deliberately does NOT log yaw/pitch camera rotation.
- It logs a movement sample only when the player's integer block changes.
- --]]
- local CONFIG = {
- TARGET = "MD_Sky",
- -- Tracking behaviour
- POLL_SECONDS = 0.10,
- TELEPORT_DISTANCE = 50, -- blocks between consecutive valid samples
- TELEPORT_MAX_GAP_MS = 1000, -- don't call a long observation gap a teleport
- TELEPORT_MIN_SPEED = 200, -- blocks/second; reduces false positives from fast flight
- LOCATION_RADIUS = 150, -- player is considered "at" a location inside this radius
- LOCATION_MERGE_RADIUS = 150, -- teleport destinations this close share the same location
- DWELL_DISCOVERY_SECONDS = 300, -- create a candidate location after 5 min inside a 150b area
- ENABLE_DWELL_DISCOVERY = true,
- LOG_HEALTH_CHANGES = true,
- -- Persistence/UI
- DATA_DIR = "/md_sky_tracker",
- MONITOR_TEXT_SCALE = 0.5,
- STATE_SAVE_SECONDS = 5,
- MOVEMENT_ANCHOR_EVERY = 256, -- absolute movement record every N samples
- MIN_FREE_SPACE = 50000, -- stop movement logging before the computer disk is totally full
- STORAGE_RECOVER_SPACE = 100000,
- DEBUG_PLAYER_DUMP_SECONDS = 60,
- }
- local PATH = {
- events = CONFIG.DATA_DIR .. "/events.log",
- movements = CONFIG.DATA_DIR .. "/movements.log",
- locations = CONFIG.DATA_DIR .. "/locations.json",
- state = CONFIG.DATA_DIR .. "/state.json",
- debugPlayer = CONFIG.DATA_DIR .. "/player_debug.json",
- }
- -- ---------------------------------------------------------------------------
- -- Basic helpers
- -- ---------------------------------------------------------------------------
- local function nowMs()
- return os.epoch("utc")
- end
- local function formatTimestamp(ms, short)
- if not ms then return "?" end
- local seconds = math.floor(ms / 1000)
- if short then
- return os.date("%m-%d %H:%M:%S", seconds)
- end
- return os.date("%Y-%m-%d %H:%M:%S", seconds)
- end
- local function formatDuration(ms)
- if not ms or ms < 0 then return "?" end
- local s = math.floor(ms / 1000)
- local d = math.floor(s / 86400)
- s = s % 86400
- local h = math.floor(s / 3600)
- s = s % 3600
- local m = math.floor(s / 60)
- s = s % 60
- if d > 0 then
- return string.format("%dd %02dh %02dm", d, h, m)
- elseif h > 0 then
- return string.format("%dh %02dm %02ds", h, m, s)
- elseif m > 0 then
- return string.format("%dm %02ds", m, s)
- else
- return string.format("%ds", s)
- end
- end
- local function floorCoord(n)
- if type(n) ~= "number" then return nil end
- return math.floor(n)
- end
- local function sameBlock(a, b)
- if not a or not b then return false end
- return a.dim == b.dim
- and floorCoord(a.x) == floorCoord(b.x)
- and floorCoord(a.y) == floorCoord(b.y)
- and floorCoord(a.z) == floorCoord(b.z)
- end
- local function distance3(a, b)
- if not a or not b or a.dim ~= b.dim then return math.huge end
- local dx = a.x - b.x
- local dy = a.y - b.y
- local dz = a.z - b.z
- return math.sqrt(dx * dx + dy * dy + dz * dz)
- end
- local function posString(p)
- if not p then return "?,?,?" end
- local x, y, z = floorCoord(p.x), floorCoord(p.y), floorCoord(p.z)
- if x == nil or y == nil or z == nil then return "?,?,?" end
- return string.format("%d,%d,%d", x, y, z)
- end
- local function dimShort(dim)
- if type(dim) ~= "string" then return tostring(dim or "?") end
- local namespace, path = dim:match("^([^:]+):(.+)$")
- if namespace == "minecraft" then return path end
- return dim
- end
- local function safeJsonEncode(value)
- local ok, encoded = pcall(textutils.serializeJSON, value)
- if ok then return encoded end
- return nil
- end
- local function safeJsonDecode(line)
- local ok, decoded = pcall(textutils.unserializeJSON, line)
- if ok then return decoded end
- return nil
- end
- local function ensureDataDir()
- if not fs.exists(CONFIG.DATA_DIR) then
- fs.makeDir(CONFIG.DATA_DIR)
- end
- end
- local function readJsonFile(path, fallback)
- if not fs.exists(path) then return fallback end
- local h = fs.open(path, "r")
- if not h then return fallback end
- local data = h.readAll()
- h.close()
- if not data or data == "" then return fallback end
- local decoded = safeJsonDecode(data)
- if decoded == nil then return fallback end
- return decoded
- end
- local function writeJsonAtomic(path, value)
- local encoded = safeJsonEncode(value)
- if not encoded then return false, "JSON encode failed" end
- local tmp = path .. ".tmp"
- local ok, err = pcall(function()
- local h = assert(fs.open(tmp, "w"))
- h.write(encoded)
- h.close()
- if fs.exists(path) then fs.delete(path) end
- fs.move(tmp, path)
- end)
- if not ok then
- pcall(function() if fs.exists(tmp) then fs.delete(tmp) end end)
- return false, tostring(err)
- end
- return true
- end
- local function appendLine(path, line)
- local ok, err = pcall(function()
- local h = assert(fs.open(path, "a"))
- h.writeLine(line)
- h.close()
- end)
- return ok, err
- end
- local function tableCopy(t)
- local out = {}
- if t then
- for k, v in pairs(t) do out[k] = v end
- end
- return out
- end
- local function containsIgnoreCase(haystack, needle)
- return type(haystack) == "string"
- and type(needle) == "string"
- and haystack:lower() == needle:lower()
- end
- local function indexToLetters(n)
- local s = ""
- while n > 0 do
- local r = (n - 1) % 26
- s = string.char(65 + r) .. s
- n = math.floor((n - 1) / 26)
- end
- return s
- end
- local function getFreeSpace()
- local ok, value = pcall(fs.getFreeSpace, CONFIG.DATA_DIR)
- if not ok then return nil end
- if type(value) == "number" then return value end
- return nil -- "unlimited" or another implementation-specific value
- end
- local function formatBytes(n)
- if type(n) ~= "number" then return "unlimited" end
- if n >= 1024 * 1024 then return string.format("%.1fMB", n / (1024 * 1024)) end
- if n >= 1024 then return string.format("%.0fKB", n / 1024) end
- return tostring(n) .. "B"
- end
- -- ---------------------------------------------------------------------------
- -- Peripheral discovery / AP compatibility adapter
- -- ---------------------------------------------------------------------------
- local detector = peripheral.find("player_detector") or peripheral.find("playerDetector")
- if not detector then
- error("No Advanced Peripherals player detector found. Attach a player_detector to this computer.", 0)
- end
- local detectorMethodName
- if type(detector.getPlayer) == "function" then
- detectorMethodName = "getPlayer"
- elseif type(detector.getPlayerPos) == "function" then
- detectorMethodName = "getPlayerPos"
- else
- error("Player detector found, but neither getPlayer nor getPlayerPos is available. Check enablePlayerPosFunction.", 0)
- end
- local function getPlayerInfo()
- local fn = detector[detectorMethodName]
- local ok, value = pcall(fn, CONFIG.TARGET)
- if ok then
- return value, nil, true
- end
- -- Some AP builds may throw for an unavailable player instead of returning nil.
- -- If getOnlinePlayers is available, use it to distinguish offline from a real API error.
- if type(detector.getOnlinePlayers) == "function" then
- local listOk, online = pcall(detector.getOnlinePlayers)
- if listOk and type(online) == "table" then
- local found = false
- for _, entry in pairs(online) do
- local name
- if type(entry) == "string" then
- name = entry
- elseif type(entry) == "table" then
- name = entry.name or entry.username
- end
- if name and containsIgnoreCase(name, CONFIG.TARGET) then
- found = true
- break
- end
- end
- if not found then return nil, nil, true end
- end
- end
- return nil, tostring(value), false
- end
- local monitor = peripheral.find("monitor")
- local display = monitor or term.current()
- local monitorName = nil
- if monitor then
- pcall(monitor.setTextScale, CONFIG.MONITOR_TEXT_SCALE)
- for _, name in ipairs(peripheral.getNames()) do
- local ok, wrapped = pcall(peripheral.wrap, name)
- if ok and wrapped == monitor then
- monitorName = name
- break
- end
- end
- end
- -- ---------------------------------------------------------------------------
- -- Persistent databases
- -- ---------------------------------------------------------------------------
- ensureDataDir()
- local locations = readJsonFile(PATH.locations, {
- nextIndex = 1,
- items = {},
- })
- if type(locations.items) ~= "table" then locations.items = {} end
- if type(locations.nextIndex) ~= "number" then locations.nextIndex = 1 end
- local loadedState = readJsonFile(PATH.state, {})
- local state = {
- online = loadedState.online == true,
- lastPos = loadedState.lastPos,
- lastSampleMs = loadedState.lastSampleMs,
- lastObservedMs = loadedState.lastObservedMs,
- currentLocationId = loadedState.currentLocationId,
- currentSessionId = loadedState.currentSessionId,
- sessionStartMs = loadedState.sessionStartMs,
- dwellAnchor = loadedState.dwellAnchor,
- dwellStartMs = loadedState.dwellStartMs,
- respawnKey = loadedState.respawnKey,
- sessionCounter = tonumber(loadedState.sessionCounter) or 0,
- lastHealth = loadedState.lastHealth,
- movementStoragePaused = loadedState.movementStoragePaused == true,
- lastDebugDumpMs = loadedState.lastDebugDumpMs,
- }
- local events = {}
- local sessionDurations = {}
- local sessionExitReason = {}
- local function loadEvents()
- if not fs.exists(PATH.events) then return end
- local h = fs.open(PATH.events, "r")
- if not h then return end
- while true do
- local line = h.readLine()
- if not line then break end
- local evt = safeJsonDecode(line)
- if type(evt) == "table" then
- events[#events + 1] = evt
- if evt.type == "LOCATION_EXIT" and evt.sessionId and evt.durationMs then
- sessionDurations[evt.sessionId] = evt.durationMs
- sessionExitReason[evt.sessionId] = evt.reason
- end
- end
- end
- h.close()
- end
- loadEvents()
- local uiDirty = true
- local storageError = nil
- local function logEvent(evt)
- evt = tableCopy(evt)
- evt.t = evt.t or nowMs()
- local encoded = safeJsonEncode(evt)
- if encoded then
- local ok, err = appendLine(PATH.events, encoded)
- if not ok then storageError = "event log: " .. tostring(err) end
- else
- storageError = "event JSON encode failed"
- end
- events[#events + 1] = evt
- if evt.type == "LOCATION_EXIT" and evt.sessionId and evt.durationMs then
- sessionDurations[evt.sessionId] = evt.durationMs
- sessionExitReason[evt.sessionId] = evt.reason
- end
- uiDirty = true
- end
- local function saveLocations()
- local ok, err = writeJsonAtomic(PATH.locations, locations)
- if not ok then storageError = "locations: " .. tostring(err) end
- end
- local function saveState()
- local out = {
- online = state.online,
- lastPos = state.lastPos,
- lastSampleMs = state.lastSampleMs,
- lastObservedMs = state.lastObservedMs,
- currentLocationId = state.currentLocationId,
- currentSessionId = state.currentSessionId,
- sessionStartMs = state.sessionStartMs,
- dwellAnchor = state.dwellAnchor,
- dwellStartMs = state.dwellStartMs,
- respawnKey = state.respawnKey,
- sessionCounter = state.sessionCounter,
- lastHealth = state.lastHealth,
- movementStoragePaused = state.movementStoragePaused,
- lastDebugDumpMs = state.lastDebugDumpMs,
- }
- local ok, err = writeJsonAtomic(PATH.state, out)
- if not ok then storageError = "state: " .. tostring(err) end
- end
- local function findLocationById(id)
- if not id then return nil end
- for _, loc in ipairs(locations.items) do
- if loc.id == id then return loc end
- end
- return nil
- end
- local function findNearestLocation(pos, radius)
- local best, bestDist
- radius = radius or CONFIG.LOCATION_RADIUS
- for _, loc in ipairs(locations.items) do
- if loc.dim == pos.dim then
- local d = distance3(pos, loc)
- if d <= radius and (not bestDist or d < bestDist) then
- best = loc
- bestDist = d
- end
- end
- end
- return best, bestDist
- end
- local function newLocation(pos, kind, t)
- local id
- local name
- if kind == "spawn" then
- id = "SPAWN"
- name = "Spawn Base"
- else
- local idx = locations.nextIndex
- locations.nextIndex = idx + 1
- id = indexToLetters(idx)
- name = "Location " .. id
- end
- local loc = {
- id = id,
- name = name,
- kind = kind or "unknown",
- dim = pos.dim,
- x = floorCoord(pos.x),
- y = floorCoord(pos.y),
- z = floorCoord(pos.z),
- createdMs = t or nowMs(),
- visits = 0,
- totalMs = 0,
- lastEnterMs = nil,
- lastExitMs = nil,
- lastSeenMs = nil,
- }
- locations.items[#locations.items + 1] = loc
- saveLocations()
- logEvent({
- type = "LOCATION_CREATED",
- t = t,
- locationId = loc.id,
- name = loc.name,
- kind = loc.kind,
- dim = loc.dim,
- x = loc.x,
- y = loc.y,
- z = loc.z,
- message = loc.name .. " discovered",
- })
- return loc
- end
- local function ensureTeleportLocation(pos, t)
- local loc = findNearestLocation(pos, CONFIG.LOCATION_MERGE_RADIUS)
- if loc then return loc end
- return newLocation(pos, "teleport", t)
- end
- local function makeSessionId(loc, t)
- state.sessionCounter = state.sessionCounter + 1
- return string.format("%d-%s-%d", t or nowMs(), tostring(loc.id), state.sessionCounter)
- end
- local function endLocationSession(t, reason, pos)
- if not state.currentLocationId or not state.currentSessionId or not state.sessionStartMs then
- state.currentLocationId = nil
- state.currentSessionId = nil
- state.sessionStartMs = nil
- return
- end
- t = t or nowMs()
- if t < state.sessionStartMs then t = state.sessionStartMs end
- local loc = findLocationById(state.currentLocationId)
- local duration = t - state.sessionStartMs
- if loc then
- loc.totalMs = (loc.totalMs or 0) + duration
- loc.lastExitMs = t
- loc.lastSeenMs = t
- saveLocations()
- end
- logEvent({
- type = "LOCATION_EXIT",
- t = t,
- locationId = state.currentLocationId,
- name = loc and loc.name or state.currentLocationId,
- sessionId = state.currentSessionId,
- durationMs = duration,
- reason = reason or "left",
- dim = pos and pos.dim or (loc and loc.dim),
- x = pos and floorCoord(pos.x) or nil,
- y = pos and floorCoord(pos.y) or nil,
- z = pos and floorCoord(pos.z) or nil,
- })
- state.currentLocationId = nil
- state.currentSessionId = nil
- state.sessionStartMs = nil
- saveState()
- end
- local function beginLocationSession(loc, t, reason, pos, forcedSessionId, forcedStartMs)
- if not loc then return nil end
- t = t or nowMs()
- if state.currentLocationId == loc.id and state.currentSessionId then
- loc.lastSeenMs = t
- return state.currentSessionId
- end
- if state.currentLocationId then
- endLocationSession(t, "switched_location", pos)
- end
- local sessionId = forcedSessionId or makeSessionId(loc, t)
- local startMs = forcedStartMs or t
- state.currentLocationId = loc.id
- state.currentSessionId = sessionId
- state.sessionStartMs = startMs
- loc.visits = (loc.visits or 0) + 1
- loc.lastEnterMs = startMs
- loc.lastSeenMs = t
- saveLocations()
- logEvent({
- type = "LOCATION_ENTER",
- t = t,
- startedMs = startMs,
- locationId = loc.id,
- name = loc.name,
- sessionId = sessionId,
- reason = reason or "entered",
- dim = pos and pos.dim or loc.dim,
- x = pos and floorCoord(pos.x) or loc.x,
- y = pos and floorCoord(pos.y) or loc.y,
- z = pos and floorCoord(pos.z) or loc.z,
- })
- saveState()
- return sessionId
- end
- -- ---------------------------------------------------------------------------
- -- Movement stream: compact delta encoding
- -- ---------------------------------------------------------------------------
- local movement = {
- count = 0,
- last = nil,
- sinceAnchor = 0,
- loaded = false,
- records = nil,
- }
- local function splitTabs(line)
- local out = {}
- local start = 1
- while true do
- local i = line:find("\t", start, true)
- if not i then
- out[#out + 1] = line:sub(start)
- break
- end
- out[#out + 1] = line:sub(start, i - 1)
- start = i + 1
- end
- return out
- end
- local function scanMovementFile(storeRecords)
- local count = 0
- local prev = nil
- local records = storeRecords and {} or nil
- if not fs.exists(PATH.movements) then
- return count, prev, records
- end
- local h = fs.open(PATH.movements, "r")
- if not h then return count, prev, records end
- while true do
- local line = h.readLine()
- if not line then break end
- local p = splitTabs(line)
- local rec = nil
- if p[1] == "A" and #p >= 6 then
- rec = {
- t = tonumber(p[2]),
- dim = p[3],
- x = tonumber(p[4]),
- y = tonumber(p[5]),
- z = tonumber(p[6]),
- }
- elseif p[1] == "D" and #p >= 5 and prev then
- local dt = tonumber(p[2])
- local dx = tonumber(p[3])
- local dy = tonumber(p[4])
- local dz = tonumber(p[5])
- if dt and dx and dy and dz then
- rec = {
- t = prev.t + dt,
- dim = prev.dim,
- x = prev.x + dx,
- y = prev.y + dy,
- z = prev.z + dz,
- }
- end
- end
- if rec and rec.t and rec.dim and rec.x and rec.y and rec.z then
- count = count + 1
- prev = rec
- if records then records[#records + 1] = rec end
- end
- end
- h.close()
- return count, prev, records
- end
- movement.count, movement.last = scanMovementFile(false)
- movement.sinceAnchor = movement.count % CONFIG.MOVEMENT_ANCHOR_EVERY
- local function loadMovementRecords()
- if movement.loaded then return movement.records end
- local count, last, records = scanMovementFile(true)
- movement.count = count
- movement.last = last
- movement.records = records or {}
- movement.loaded = true
- return movement.records
- end
- local function maybePauseMovementForStorage(t)
- local free = getFreeSpace()
- if not free then return false end
- if not state.movementStoragePaused and free < CONFIG.MIN_FREE_SPACE then
- state.movementStoragePaused = true
- logEvent({
- type = "STORAGE_LOW",
- t = t,
- freeBytes = free,
- message = "Movement logging paused: low computer storage",
- })
- saveState()
- elseif state.movementStoragePaused and free >= CONFIG.STORAGE_RECOVER_SPACE then
- state.movementStoragePaused = false
- logEvent({
- type = "STORAGE_RECOVERED",
- t = t,
- freeBytes = free,
- message = "Movement logging resumed",
- })
- saveState()
- end
- return state.movementStoragePaused
- end
- local function recordMovement(pos, t)
- local rec = {
- t = t,
- dim = pos.dim,
- x = floorCoord(pos.x),
- y = floorCoord(pos.y),
- z = floorCoord(pos.z),
- }
- if not rec.x or not rec.y or not rec.z then return end
- if movement.last and sameBlock(movement.last, rec) then return end
- if maybePauseMovementForStorage(t) then return end
- local line
- local forceAnchor = not movement.last
- or movement.last.dim ~= rec.dim
- or movement.sinceAnchor >= CONFIG.MOVEMENT_ANCHOR_EVERY
- or (rec.t - movement.last.t) < 0
- or (rec.t - movement.last.t) > 600000
- if forceAnchor then
- line = table.concat({"A", tostring(rec.t), rec.dim, tostring(rec.x), tostring(rec.y), tostring(rec.z)}, "\t")
- movement.sinceAnchor = 0
- else
- line = table.concat({
- "D",
- tostring(rec.t - movement.last.t),
- tostring(rec.x - movement.last.x),
- tostring(rec.y - movement.last.y),
- tostring(rec.z - movement.last.z),
- }, "\t")
- movement.sinceAnchor = movement.sinceAnchor + 1
- end
- local ok, err = appendLine(PATH.movements, line)
- if not ok then
- storageError = "movement log: " .. tostring(err)
- state.movementStoragePaused = true
- return
- end
- movement.count = movement.count + 1
- movement.last = rec
- if movement.loaded then
- movement.records[#movement.records + 1] = rec
- end
- uiDirty = true
- end
- -- ---------------------------------------------------------------------------
- -- Spawn/respawn parsing
- -- ---------------------------------------------------------------------------
- local function extractRespawn(info)
- if type(info) ~= "table" then return nil end
- local rp = info.respawnPosition
- local rd = info.respawnDimension
- if type(rp) == "table" then
- local x = tonumber(rp.x or rp[1])
- local y = tonumber(rp.y or rp[2])
- local z = tonumber(rp.z or rp[3])
- if x and y and z and rd ~= nil then
- return {
- x = x,
- y = y,
- z = z,
- dim = tostring(rd),
- }
- end
- end
- -- Defensive support for possible alternative field naming in mod builds.
- local x = tonumber(info.respawnX or info.spawnX)
- local y = tonumber(info.respawnY or info.spawnY)
- local z = tonumber(info.respawnZ or info.spawnZ)
- local dim = info.respawnDimension or info.spawnDimension
- if x and y and z and dim then
- return {x = x, y = y, z = z, dim = tostring(dim)}
- end
- return nil
- end
- local function ensureSpawnLocation(spawn, t)
- local existing = findLocationById("SPAWN")
- local key = table.concat({spawn.dim, floorCoord(spawn.x), floorCoord(spawn.y), floorCoord(spawn.z)}, "|")
- if not existing then
- existing = newLocation(spawn, "spawn", t)
- state.respawnKey = key
- logEvent({
- type = "SPAWN_SET",
- t = t,
- dim = spawn.dim,
- x = floorCoord(spawn.x),
- y = floorCoord(spawn.y),
- z = floorCoord(spawn.z),
- locationId = "SPAWN",
- })
- saveState()
- return existing
- end
- if state.respawnKey ~= key
- or existing.dim ~= spawn.dim
- or existing.x ~= floorCoord(spawn.x)
- or existing.y ~= floorCoord(spawn.y)
- or existing.z ~= floorCoord(spawn.z) then
- local old = {dim = existing.dim, x = existing.x, y = existing.y, z = existing.z}
- existing.dim = spawn.dim
- existing.x = floorCoord(spawn.x)
- existing.y = floorCoord(spawn.y)
- existing.z = floorCoord(spawn.z)
- existing.lastSeenMs = t
- state.respawnKey = key
- saveLocations()
- logEvent({
- type = "SPAWN_CHANGED",
- t = t,
- locationId = "SPAWN",
- fromDim = old.dim,
- fromX = old.x,
- fromY = old.y,
- fromZ = old.z,
- dim = existing.dim,
- x = existing.x,
- y = existing.y,
- z = existing.z,
- })
- saveState()
- end
- return existing
- end
- -- ---------------------------------------------------------------------------
- -- Tracking logic
- -- ---------------------------------------------------------------------------
- local lastApiErrorText = nil
- local lastApiErrorMs = 0
- local pendingDimensionEvent = nil
- local startupMs = nowMs()
- local startedWithPriorState = loadedState and next(loadedState) ~= nil
- local function dumpDebugPlayer(info, t)
- if not info then return end
- if state.lastDebugDumpMs and (t - state.lastDebugDumpMs) < CONFIG.DEBUG_PLAYER_DUMP_SECONDS * 1000 then
- return
- end
- local ok = writeJsonAtomic(PATH.debugPlayer, info)
- if ok then
- state.lastDebugDumpMs = t
- end
- end
- local function closeInterruptedPriorSession()
- if not state.currentLocationId or not state.currentSessionId or not state.sessionStartMs then return end
- local closeAt = state.lastObservedMs or state.lastSampleMs or state.sessionStartMs
- if closeAt < state.sessionStartMs then closeAt = state.sessionStartMs end
- endLocationSession(closeAt, "tracker_interrupted", state.lastPos)
- end
- if startedWithPriorState then
- closeInterruptedPriorSession()
- if loadedState.online == true and loadedState.lastObservedMs then
- logEvent({
- type = "TRACKING_GAP",
- t = startupMs,
- gapStartedMs = loadedState.lastObservedMs,
- durationMs = math.max(0, startupMs - loadedState.lastObservedMs),
- message = "Tracker restarted after an observation gap",
- })
- end
- end
- -- We cannot assume the player remained online while this computer was unloaded.
- state.online = false
- state.lastPos = nil
- state.lastSampleMs = nil
- state.lastObservedMs = nil
- state.currentLocationId = nil
- state.currentSessionId = nil
- state.sessionStartMs = nil
- state.dwellAnchor = nil
- state.dwellStartMs = nil
- state.lastHealth = nil
- saveState()
- logEvent({
- type = "SYSTEM_START",
- t = startupMs,
- target = CONFIG.TARGET,
- apiMethod = detectorMethodName,
- movementCount = movement.count,
- message = "Tracker started",
- })
- local function resetDwellCandidate()
- state.dwellAnchor = nil
- state.dwellStartMs = nil
- end
- local function updateKnownLocationMembership(pos, t)
- local current = findLocationById(state.currentLocationId)
- if current then
- local d = distance3(pos, current)
- if d <= CONFIG.LOCATION_RADIUS then
- current.lastSeenMs = t
- resetDwellCandidate()
- return current
- else
- endLocationSession(t, "left_radius", pos)
- current = nil
- end
- end
- local nearest = findNearestLocation(pos, CONFIG.LOCATION_RADIUS)
- if nearest then
- beginLocationSession(nearest, t, "entered_radius", pos)
- resetDwellCandidate()
- return nearest
- end
- return nil
- end
- local function updateDwellDiscovery(pos, t)
- if not CONFIG.ENABLE_DWELL_DISCOVERY then return end
- if state.currentLocationId then
- resetDwellCandidate()
- return
- end
- if not state.dwellAnchor
- or state.dwellAnchor.dim ~= pos.dim
- or distance3(pos, state.dwellAnchor) > CONFIG.LOCATION_RADIUS then
- state.dwellAnchor = {
- x = floorCoord(pos.x),
- y = floorCoord(pos.y),
- z = floorCoord(pos.z),
- dim = pos.dim,
- }
- state.dwellStartMs = t
- return
- end
- if state.dwellStartMs and (t - state.dwellStartMs) >= CONFIG.DWELL_DISCOVERY_SECONDS * 1000 then
- local anchor = state.dwellAnchor
- local startMs = state.dwellStartMs
- local loc = findNearestLocation(anchor, CONFIG.LOCATION_MERGE_RADIUS)
- if not loc then
- loc = newLocation(anchor, "dwell", t)
- end
- local sessionId = makeSessionId(loc, startMs)
- beginLocationSession(loc, t, "dwell_discovered", pos, sessionId, startMs)
- logEvent({
- type = "DWELL_DISCOVERY",
- t = t,
- locationId = loc.id,
- name = loc.name,
- sessionId = sessionId,
- dwellBeforeDiscoveryMs = t - startMs,
- dim = loc.dim,
- x = loc.x,
- y = loc.y,
- z = loc.z,
- })
- resetDwellCandidate()
- end
- end
- local function handleTeleport(prev, pos, t, distance, reason)
- local oldLocationId = state.currentLocationId
- -- If the destination is outside the old location, terminate its dwell first.
- local current = findLocationById(state.currentLocationId)
- if current and distance3(pos, current) > CONFIG.LOCATION_RADIUS then
- endLocationSession(t, "teleported_away", pos)
- end
- local destination = ensureTeleportLocation(pos, t)
- local sessionId
- if state.currentLocationId == destination.id and state.currentSessionId then
- sessionId = state.currentSessionId
- destination.lastSeenMs = t
- else
- sessionId = beginLocationSession(destination, t, "teleport_destination", pos)
- end
- logEvent({
- type = "TELEPORT",
- t = t,
- reason = reason,
- distance = distance,
- fromDim = prev and prev.dim or nil,
- fromX = prev and floorCoord(prev.x) or nil,
- fromY = prev and floorCoord(prev.y) or nil,
- fromZ = prev and floorCoord(prev.z) or nil,
- dim = pos.dim,
- x = floorCoord(pos.x),
- y = floorCoord(pos.y),
- z = floorCoord(pos.z),
- locationId = destination.id,
- name = destination.name,
- sessionId = sessionId,
- leftLocationId = oldLocationId,
- })
- resetDwellCandidate()
- end
- local function playerWentOffline(t, source)
- if not state.online then return end
- endLocationSession(t, source == "leave_event" and "disconnect" or "offline", state.lastPos)
- logEvent({
- type = "LEAVE",
- t = t,
- source = source,
- dim = state.lastPos and state.lastPos.dim or nil,
- x = state.lastPos and floorCoord(state.lastPos.x) or nil,
- y = state.lastPos and floorCoord(state.lastPos.y) or nil,
- z = state.lastPos and floorCoord(state.lastPos.z) or nil,
- })
- state.online = false
- state.lastPos = nil
- state.lastSampleMs = nil
- state.lastObservedMs = t
- state.lastHealth = nil
- resetDwellCandidate()
- saveState()
- end
- local function playerCameOnline(info, pos, t, source)
- if state.online then return end
- state.online = true
- logEvent({
- type = source == "join_event" and "JOIN" or "ONLINE",
- t = t,
- source = source,
- dim = pos.dim,
- x = floorCoord(pos.x),
- y = floorCoord(pos.y),
- z = floorCoord(pos.z),
- health = info.health,
- })
- end
- local function pollPlayer(source)
- local t = nowMs()
- local info, err, callSucceeded = getPlayerInfo()
- if not callSucceeded then
- if err ~= lastApiErrorText or (t - lastApiErrorMs) > 30000 then
- logEvent({
- type = "API_ERROR",
- t = t,
- message = err,
- })
- lastApiErrorText = err
- lastApiErrorMs = t
- end
- return
- end
- lastApiErrorText = nil
- if type(info) ~= "table" then
- playerWentOffline(t, source or "poll")
- return
- end
- local x, y, z = tonumber(info.x), tonumber(info.y), tonumber(info.z)
- local dim = info.dimension and tostring(info.dimension) or nil
- if not x or not y or not z or not dim then
- if (t - lastApiErrorMs) > 30000 then
- logEvent({
- type = "API_ERROR",
- t = t,
- message = "Player detector returned a player object without x/y/z/dimension. Check morePlayerInformation.",
- })
- lastApiErrorMs = t
- end
- return
- end
- local pos = {x = x, y = y, z = z, dim = dim}
- local wasOnline = state.online
- local prev = state.lastPos
- local prevSampleMs = state.lastSampleMs
- playerCameOnline(info, pos, t, source or "poll")
- dumpDebugPlayer(info, t)
- -- Detect respawn/spawn point if the AP build exposes it in a usable shape.
- local respawn = extractRespawn(info)
- if respawn then
- ensureSpawnLocation(respawn, t)
- end
- -- Detect large instantaneous movement or cross-dimensional movement.
- local teleported = false
- if wasOnline and prev and prevSampleMs then
- local gap = t - prevSampleMs
- if prev.dim ~= pos.dim then
- handleTeleport(prev, pos, t, nil, "dimension")
- teleported = true
- elseif gap <= CONFIG.TELEPORT_MAX_GAP_MS then
- local d = distance3(prev, pos)
- local seconds = math.max(gap / 1000, 0.001)
- local speed = d / seconds
- if d > CONFIG.TELEPORT_DISTANCE and speed >= CONFIG.TELEPORT_MIN_SPEED then
- handleTeleport(prev, pos, t, d, "distance")
- teleported = true
- end
- elseif gap > CONFIG.TELEPORT_MAX_GAP_MS * 2 then
- logEvent({
- type = "SAMPLE_GAP",
- t = t,
- durationMs = gap,
- fromDim = prev.dim,
- fromX = floorCoord(prev.x),
- fromY = floorCoord(prev.y),
- fromZ = floorCoord(prev.z),
- dim = pos.dim,
- x = floorCoord(pos.x),
- y = floorCoord(pos.y),
- z = floorCoord(pos.z),
- })
- end
- end
- if not teleported then
- local known = updateKnownLocationMembership(pos, t)
- if not known then updateDwellDiscovery(pos, t) end
- end
- -- Optional damage/heal log. It only records actual health value changes.
- if CONFIG.LOG_HEALTH_CHANGES and type(info.health) == "number" then
- if state.lastHealth ~= nil and info.health ~= state.lastHealth then
- logEvent({
- type = info.health < state.lastHealth and "DAMAGE" or "HEAL",
- t = t,
- fromHealth = state.lastHealth,
- health = info.health,
- maxHealth = info.maxHealth,
- dim = pos.dim,
- x = floorCoord(pos.x),
- y = floorCoord(pos.y),
- z = floorCoord(pos.z),
- })
- end
- state.lastHealth = info.health
- end
- recordMovement(pos, t)
- state.online = true
- state.lastPos = pos
- state.lastSampleMs = t
- state.lastObservedMs = t
- local current = findLocationById(state.currentLocationId)
- if current then current.lastSeenMs = t end
- end
- -- ---------------------------------------------------------------------------
- -- Advanced Peripherals event compatibility
- -- ---------------------------------------------------------------------------
- local function targetMatches(name)
- return containsIgnoreCase(name, CONFIG.TARGET)
- end
- local function handlePeripheralEvent(ev)
- local name = ev[1]
- local t = nowMs()
- if name == "playerJoin" then
- local username, dim = ev[2], ev[3]
- if targetMatches(username) then
- -- Poll immediately so the JOIN event can include an exact position if available.
- pollPlayer("join_event")
- if not state.online then
- logEvent({type = "JOIN_EVENT", t = t, dim = dim, source = "AP_0.7"})
- end
- end
- elseif name == "player_join" then
- local uuid, username, dim = ev[2], ev[3], ev[4]
- if targetMatches(username) then
- pollPlayer("join_event")
- if not state.online then
- logEvent({type = "JOIN_EVENT", t = t, uuid = uuid, dim = dim, source = "AP_0.8"})
- end
- end
- elseif name == "playerLeave" then
- local username, dim = ev[2], ev[3]
- if targetMatches(username) then
- playerWentOffline(t, "leave_event")
- if not state.online then uiDirty = true end
- end
- elseif name == "player_leave" then
- local uuid, username, dim = ev[2], ev[3], ev[4]
- if targetMatches(username) then
- playerWentOffline(t, "leave_event")
- uiDirty = true
- end
- elseif name == "playerChangedDimension" then
- local username, fromDim, toDim = ev[2], ev[3], ev[4]
- if targetMatches(username) then
- pendingDimensionEvent = {t = t, fromDim = fromDim, toDim = toDim}
- logEvent({
- type = "DIMENSION_CHANGE",
- t = t,
- fromDim = fromDim,
- dim = toDim,
- source = "AP_0.7",
- })
- pollPlayer("dimension_event")
- end
- elseif name == "player_changed_dimension" then
- local uuid, username, fromDim, toDim = ev[2], ev[3], ev[4], ev[5]
- if targetMatches(username) then
- pendingDimensionEvent = {t = t, fromDim = fromDim, toDim = toDim}
- logEvent({
- type = "DIMENSION_CHANGE",
- t = t,
- uuid = uuid,
- fromDim = fromDim,
- dim = toDim,
- source = "AP_0.8",
- })
- pollPlayer("dimension_event")
- end
- elseif name == "player_death" then
- local uuid, username, damageSource = ev[2], ev[3], ev[4]
- if targetMatches(username) then
- logEvent({
- type = "DEATH",
- t = t,
- uuid = uuid,
- damageSource = damageSource,
- dim = state.lastPos and state.lastPos.dim or nil,
- x = state.lastPos and floorCoord(state.lastPos.x) or nil,
- y = state.lastPos and floorCoord(state.lastPos.y) or nil,
- z = state.lastPos and floorCoord(state.lastPos.z) or nil,
- })
- end
- end
- end
- -- ---------------------------------------------------------------------------
- -- UI
- -- ---------------------------------------------------------------------------
- local TABS = {
- {key = "ALL", label = "ALL"},
- {key = "TP", label = "TP"},
- {key = "LOC", label = "LOC"},
- {key = "MOVE", label = "MOVE"},
- {key = "PRES", label = "ON/OFF"},
- {key = "SYS", label = "SYS"},
- }
- local ui = {
- tab = 1,
- scroll = 0,
- visibleRows = 1,
- maxScroll = 0,
- tabHitboxes = {},
- scrollbar = nil,
- terminalDragging = false,
- }
- local function setBg(c)
- if display.isColor and display.isColor() then display.setBackgroundColor(c) end
- end
- local function setFg(c)
- if display.isColor and display.isColor() then display.setTextColor(c) end
- end
- local function writeAt(x, y, text, fg, bg)
- local w, h = display.getSize()
- if y < 1 or y > h or x > w then return end
- text = tostring(text or "")
- if x < 1 then
- text = text:sub(2 - x)
- x = 1
- end
- if #text > (w - x + 1) then text = text:sub(1, w - x + 1) end
- if bg then setBg(bg) end
- if fg then setFg(fg) end
- display.setCursorPos(x, y)
- display.write(text)
- end
- local function padRight(s, width)
- s = tostring(s or "")
- if #s >= width then return s:sub(1, width) end
- return s .. string.rep(" ", width - #s)
- end
- local function eventMatchesTab(evt, key)
- if key == "ALL" then return true end
- if key == "TP" then return evt.type == "TELEPORT" or evt.type == "DIMENSION_CHANGE" end
- if key == "PRES" then
- return evt.type == "JOIN" or evt.type == "ONLINE" or evt.type == "LEAVE" or evt.type == "JOIN_EVENT"
- end
- if key == "SYS" then
- return evt.type == "SYSTEM_START"
- or evt.type == "TRACKING_GAP"
- or evt.type == "SAMPLE_GAP"
- or evt.type == "API_ERROR"
- or evt.type == "STORAGE_LOW"
- or evt.type == "STORAGE_RECOVERED"
- end
- return false
- end
- local function currentSessionDuration(sessionId)
- if not sessionId then return nil, false end
- if sessionDurations[sessionId] ~= nil then
- return sessionDurations[sessionId], false
- end
- if state.currentSessionId == sessionId and state.sessionStartMs then
- return math.max(0, nowMs() - state.sessionStartMs), true
- end
- return nil, false
- end
- local function formatEvent(evt)
- local ts = formatTimestamp(evt.t, true)
- local typ = evt.type or "EVENT"
- if typ == "TELEPORT" then
- local dwell, active = currentSessionDuration(evt.sessionId)
- local dwellText = dwell and ((active and "ACTIVE " or "") .. formatDuration(dwell)) or "pending"
- local distText = evt.reason == "dimension" and "DIM" or (evt.distance and string.format("%.0fb", evt.distance) or "?")
- return string.format("[%s] TP %s -> %s %s @ %s | %s",
- ts,
- distText,
- evt.name or evt.locationId or "location",
- dimShort(evt.dim),
- posString(evt),
- dwellText)
- elseif typ == "LOCATION_ENTER" then
- return string.format("[%s] ENTER %s %s @ %s (%s)",
- ts, evt.name or evt.locationId or "location", dimShort(evt.dim), posString(evt), evt.reason or "")
- elseif typ == "LOCATION_EXIT" then
- return string.format("[%s] EXIT %s after %s (%s)",
- ts, evt.name or evt.locationId or "location", formatDuration(evt.durationMs), evt.reason or "")
- elseif typ == "LOCATION_CREATED" then
- return string.format("[%s] NEW %s [%s] %s @ %s",
- ts, evt.name or evt.locationId or "location", evt.kind or "?", dimShort(evt.dim), posString(evt))
- elseif typ == "JOIN" or typ == "ONLINE" or typ == "JOIN_EVENT" then
- return string.format("[%s] ONLINE %s @ %s", ts, dimShort(evt.dim), posString(evt))
- elseif typ == "LEAVE" then
- return string.format("[%s] OFFLINE %s @ %s", ts, dimShort(evt.dim), posString(evt))
- elseif typ == "DIMENSION_CHANGE" then
- return string.format("[%s] DIM %s -> %s", ts, dimShort(evt.fromDim), dimShort(evt.dim))
- elseif typ == "SPAWN_SET" then
- return string.format("[%s] SPAWN %s @ %s", ts, dimShort(evt.dim), posString(evt))
- elseif typ == "SPAWN_CHANGED" then
- return string.format("[%s] SPAWN CHANGED %s %d,%d,%d -> %s %s",
- ts,
- dimShort(evt.fromDim), evt.fromX or 0, evt.fromY or 0, evt.fromZ or 0,
- dimShort(evt.dim), posString(evt))
- elseif typ == "DWELL_DISCOVERY" then
- return string.format("[%s] DWELL -> %s after %s", ts, evt.name or evt.locationId or "location", formatDuration(evt.dwellBeforeDiscoveryMs))
- elseif typ == "DAMAGE" or typ == "HEAL" then
- return string.format("[%s] %s %.1f -> %.1f HP @ %s", ts, typ, evt.fromHealth or 0, evt.health or 0, posString(evt))
- elseif typ == "DEATH" then
- return string.format("[%s] DEATH %s @ %s", ts, tostring(evt.damageSource or "unknown"), posString(evt))
- elseif typ == "TRACKING_GAP" then
- return string.format("[%s] TRACKER GAP %s", ts, formatDuration(evt.durationMs))
- elseif typ == "SAMPLE_GAP" then
- return string.format("[%s] SAMPLE GAP %s", ts, formatDuration(evt.durationMs))
- elseif typ == "STORAGE_LOW" then
- return string.format("[%s] STORAGE LOW - movement paused (%s free)", ts, formatBytes(evt.freeBytes))
- elseif typ == "STORAGE_RECOVERED" then
- return string.format("[%s] STORAGE OK - movement resumed (%s free)", ts, formatBytes(evt.freeBytes))
- elseif typ == "API_ERROR" then
- return string.format("[%s] API ERROR %s", ts, tostring(evt.message or ""))
- elseif typ == "SYSTEM_START" then
- return string.format("[%s] TRACKER START method=%s moves=%s", ts, tostring(evt.apiMethod or "?"), tostring(evt.movementCount or 0))
- end
- return string.format("[%s] %s %s", ts, typ, tostring(evt.message or ""))
- end
- local function buildEventRows(tabKey)
- local rows = {}
- for i = #events, 1, -1 do
- local evt = events[i]
- if eventMatchesTab(evt, tabKey) then
- rows[#rows + 1] = formatEvent(evt)
- end
- end
- return rows
- end
- local function buildLocationRows()
- local copy = {}
- for _, loc in ipairs(locations.items) do copy[#copy + 1] = loc end
- table.sort(copy, function(a, b)
- local at = tonumber(a.totalMs) or 0
- local bt = tonumber(b.totalMs) or 0
- if at == bt then return tostring(a.id) < tostring(b.id) end
- return at > bt
- end)
- local rows = {}
- for _, loc in ipairs(copy) do
- local active = state.currentLocationId == loc.id
- local liveTotal = tonumber(loc.totalMs) or 0
- if active and state.sessionStartMs then liveTotal = liveTotal + (nowMs() - state.sessionStartMs) end
- rows[#rows + 1] = string.format("%s%s %s | %s @ %d,%d,%d | visits:%d total:%s",
- active and "*" or " ",
- tostring(loc.id),
- loc.name or "Location",
- dimShort(loc.dim),
- loc.x or 0, loc.y or 0, loc.z or 0,
- loc.visits or 0,
- formatDuration(liveTotal))
- end
- return rows
- end
- local function buildMovementRows()
- local records = loadMovementRecords()
- local rows = {}
- for i = #records, 1, -1 do
- local p = records[i]
- rows[#rows + 1] = string.format("[%s] %s @ %d,%d,%d",
- formatTimestamp(p.t, true), dimShort(p.dim), p.x, p.y, p.z)
- end
- return rows
- end
- local function buildRows()
- local key = TABS[ui.tab].key
- if key == "LOC" then return buildLocationRows() end
- if key == "MOVE" then return buildMovementRows() end
- return buildEventRows(key)
- end
- local function statusLine(width)
- if state.online and state.lastPos then
- local site = findLocationById(state.currentLocationId)
- local s = string.format("%s ONLINE | %s %s",
- CONFIG.TARGET, dimShort(state.lastPos.dim), posString(state.lastPos))
- if site and state.sessionStartMs then
- s = s .. string.format(" | %s %s", site.name, formatDuration(nowMs() - state.sessionStartMs))
- end
- return padRight(s, width)
- end
- return padRight(CONFIG.TARGET .. " OFFLINE", width)
- end
- local function drawTabs(width)
- ui.tabHitboxes = {}
- local x = 1
- for i, tab in ipairs(TABS) do
- local label = " " .. tab.label .. " "
- if x > width - 1 then break end
- local maxLen = math.min(#label, width - x)
- if maxLen <= 0 then break end
- local shown = label:sub(1, maxLen)
- local active = i == ui.tab
- local bg = active and colors.blue or colors.gray
- local fg = active and colors.white or colors.lightGray
- writeAt(x, 1, shown, fg, bg)
- ui.tabHitboxes[#ui.tabHitboxes + 1] = {i = i, x1 = x, x2 = x + #shown - 1}
- x = x + #shown
- end
- if x <= width then writeAt(x, 1, string.rep(" ", width - x + 1), colors.white, colors.black) end
- end
- local function drawScrollbar(width, top, bottom, totalRows)
- local height = bottom - top + 1
- if height < 3 then
- ui.scrollbar = nil
- return
- end
- local x = width
- writeAt(x, top, "^", colors.white, colors.gray)
- writeAt(x, bottom, "v", colors.white, colors.gray)
- local trackTop = top + 1
- local trackBottom = bottom - 1
- local trackHeight = trackBottom - trackTop + 1
- for y = trackTop, trackBottom do writeAt(x, y, "|", colors.gray, colors.black) end
- local thumbY = trackTop
- if ui.maxScroll > 0 and trackHeight > 1 then
- thumbY = trackTop + math.floor((ui.scroll / ui.maxScroll) * (trackHeight - 1) + 0.5)
- end
- writeAt(x, thumbY, "#", colors.white, colors.blue)
- ui.scrollbar = {
- x = x,
- top = top,
- bottom = bottom,
- trackTop = trackTop,
- trackBottom = trackBottom,
- trackHeight = trackHeight,
- thumbY = thumbY,
- }
- end
- local function drawUI()
- local w, h = display.getSize()
- display.setCursorBlink(false)
- setBg(colors.black)
- setFg(colors.white)
- display.clear()
- if w < 12 or h < 5 then
- writeAt(1, 1, "Monitor too small", colors.white, colors.red)
- return
- end
- drawTabs(w)
- writeAt(1, 2, statusLine(w), state.online and colors.lime or colors.red, colors.black)
- local free = getFreeSpace()
- local info = string.format("Moves:%d Events:%d Free:%s%s",
- movement.count,
- #events,
- formatBytes(free),
- state.movementStoragePaused and " MOVE-PAUSED" or "")
- if storageError then info = "ERR: " .. storageError end
- writeAt(1, 3, padRight(info, w), storageError and colors.red or colors.lightGray, colors.black)
- local top = 4
- local bottom = h
- local contentWidth = w - 1
- ui.visibleRows = bottom - top + 1
- local rows = buildRows()
- ui.maxScroll = math.max(0, #rows - ui.visibleRows)
- if ui.scroll > ui.maxScroll then ui.scroll = ui.maxScroll end
- if ui.scroll < 0 then ui.scroll = 0 end
- for row = 0, ui.visibleRows - 1 do
- local idx = 1 + ui.scroll + row
- local text = rows[idx] or ""
- writeAt(1, top + row, padRight(text, contentWidth), colors.white, colors.black)
- end
- drawScrollbar(w, top, bottom, #rows)
- uiDirty = false
- end
- local function setTab(i)
- if i < 1 or i > #TABS then return end
- ui.tab = i
- ui.scroll = 0
- uiDirty = true
- end
- local function scrollBy(delta)
- ui.scroll = math.max(0, math.min(ui.maxScroll, ui.scroll + delta))
- uiDirty = true
- end
- local function scrollbarJump(y)
- local sb = ui.scrollbar
- if not sb or ui.maxScroll <= 0 then return end
- if y <= sb.top then
- scrollBy(-1)
- elseif y >= sb.bottom then
- scrollBy(1)
- elseif y >= sb.trackTop and y <= sb.trackBottom then
- local ratio
- if sb.trackHeight <= 1 then ratio = 0 else ratio = (y - sb.trackTop) / (sb.trackHeight - 1) end
- ui.scroll = math.floor(ratio * ui.maxScroll + 0.5)
- uiDirty = true
- end
- end
- local function handleTouch(x, y)
- if y == 1 then
- for _, hit in ipairs(ui.tabHitboxes) do
- if x >= hit.x1 and x <= hit.x2 then
- setTab(hit.i)
- return
- end
- end
- end
- if ui.scrollbar and x == ui.scrollbar.x then
- scrollbarJump(y)
- end
- end
- local function handleKey(key)
- if key == keys.up then scrollBy(-1)
- elseif key == keys.down then scrollBy(1)
- elseif key == keys.pageUp then scrollBy(-math.max(1, ui.visibleRows - 1))
- elseif key == keys.pageDown then scrollBy(math.max(1, ui.visibleRows - 1))
- elseif key == keys.home then ui.scroll = 0; uiDirty = true
- elseif key == keys["end"] then ui.scroll = ui.maxScroll; uiDirty = true
- elseif key == keys.one then setTab(1)
- elseif key == keys.two then setTab(2)
- elseif key == keys.three then setTab(3)
- elseif key == keys.four then setTab(4)
- elseif key == keys.five then setTab(5)
- elseif key == keys.six then setTab(6)
- end
- end
- -- ---------------------------------------------------------------------------
- -- Main loop
- -- ---------------------------------------------------------------------------
- local pollTimer = nil
- local stateTimer = nil
- local function schedulePoll()
- pollTimer = os.startTimer(CONFIG.POLL_SECONDS)
- end
- local function scheduleStateSave()
- stateTimer = os.startTimer(CONFIG.STATE_SAVE_SECONDS)
- end
- -- Initial authoritative sample.
- pollPlayer("startup")
- schedulePoll()
- scheduleStateSave()
- drawUI()
- while true do
- local ev = {os.pullEventRaw()}
- local name = ev[1]
- if name == "terminate" then
- local t = nowMs()
- -- Manual program termination is an observation stop, not a player disconnect.
- if state.currentLocationId then
- endLocationSession(state.lastObservedMs or t, "tracker_stopped", state.lastPos)
- end
- logEvent({type = "SYSTEM_STOP", t = t, message = "Tracker manually stopped"})
- saveState()
- setBg(colors.black)
- setFg(colors.white)
- display.clear()
- display.setCursorPos(1, 1)
- print("MD_Sky tracker stopped.")
- break
- elseif name == "timer" then
- local id = ev[2]
- if id == pollTimer then
- pollPlayer("poll")
- schedulePoll()
- elseif id == stateTimer then
- saveState()
- scheduleStateSave()
- uiDirty = true -- refresh active dwell duration / free-space display
- end
- elseif name == "monitor_touch" then
- local side, x, y = ev[2], ev[3], ev[4]
- if not monitorName or side == monitorName then handleTouch(x, y) end
- elseif name == "mouse_scroll" and not monitor then
- local direction = ev[2]
- scrollBy(direction)
- elseif name == "mouse_click" and not monitor then
- local button, x, y = ev[2], ev[3], ev[4]
- if button == 1 then
- handleTouch(x, y)
- if ui.scrollbar and x == ui.scrollbar.x and y >= ui.scrollbar.trackTop and y <= ui.scrollbar.trackBottom then
- ui.terminalDragging = true
- end
- end
- elseif name == "mouse_drag" and not monitor then
- local button, x, y = ev[2], ev[3], ev[4]
- if button == 1 and ui.terminalDragging and ui.scrollbar then
- scrollbarJump(y)
- end
- elseif name == "mouse_up" and not monitor then
- ui.terminalDragging = false
- elseif name == "key" and not monitor then
- handleKey(ev[2])
- elseif name == "monitor_resize" or name == "term_resize" then
- uiDirty = true
- else
- handlePeripheralEvent(ev)
- end
- if uiDirty then drawUI() end
- end
Advertisement
Add Comment
Please, Sign In to add comment