Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- --[[
- Advanced Peripherals Player Tracker
- Minecraft 1.21.1 / CC:Tweaked / Advanced Peripherals 0.8
- Features:
- * Live top-down map of every online player reported by Player Detector
- * Persistent tracked-player list
- * Teleport (> configurable distance), dimension, death, join/leave logging
- * 150-block dwell/area tracking with duration and milestones
- * Per-player breadcrumb history files
- * Clickable event log which jumps the map to the event coordinates
- * Hold RIGHT mouse button and drag to pan on the computer terminal
- * Mouse wheel zooms around the cursor
- * Live mirror to an attached Advanced Monitor; monitor touches select/use buttons
- * Optional all-stat snapshot and raw Player Detector snapshot dumps
- Edit SETTINGS.DEFAULT_TRACKED below if you want names preloaded.
- All persistent data is stored under /player_tracker.
- ]]
- local SETTINGS = {
- -- Display
- -- Mouse events only exist on the computer terminal. The attached monitor is
- -- therefore a live mirror: right-drag/wheel on the computer, and the monitor
- -- updates immediately. Monitor touches still work for selection/buttons.
- MIRROR_TO_MONITOR = true,
- MONITOR_NAME = nil, -- nil = first connected monitor
- MONITOR_TEXT_SCALE = 0.5,
- -- Tracking
- POLL_SECONDS = 0.50,
- RENDER_SECONDS = 0.20,
- TELEPORT_DISTANCE = 50,
- DWELL_RADIUS = 150,
- DWELL_MIN_LOG_SECONDS = 60,
- DWELL_MILESTONES = { 600, 1800, 3600 }, -- 10m, 30m, 1h
- -- Breadcrumb storage for tracked players
- HISTORY_SAMPLE_SECONDS = 15,
- HISTORY_MIN_MOVE = 5,
- HISTORY_FORCE_SAMPLE_SECONDS = 120,
- HISTORY_MAX_BYTES_PER_PLAYER = 50000,
- MIN_FREE_FOR_HISTORY_BYTES = 120000,
- -- Event storage/cache
- EVENT_MAX_FILE_BYTES = 220000,
- EVENT_CACHE_LIMIT = 700,
- -- Map scales are blocks per character cell
- ZOOM_LEVELS = { 1, 2, 4, 8, 16, 32, 64, 128, 256, 512 },
- DEFAULT_ZOOM_INDEX = 5,
- -- Names here are merged into the persistent tracked list at startup.
- DEFAULT_TRACKED = {
- -- "PlayerName",
- },
- -- Optional: taking every stat can be relatively expensive, so this is manual
- -- by default (press S with a selected player).
- CAPTURE_STATS_ON_MAJOR_EVENT = false,
- }
- local DATA_DIR = "/player_tracker"
- local CONFIG_FILE = DATA_DIR .. "/config.json"
- local EVENT_FILE = DATA_DIR .. "/events.jsonl"
- local HISTORY_DIR = DATA_DIR .. "/history"
- local STATS_DIR = DATA_DIR .. "/stats"
- local RAW_DIR = DATA_DIR .. "/raw"
- local function ensureDir(path)
- if not fs.exists(path) then fs.makeDir(path) end
- end
- ensureDir(DATA_DIR)
- ensureDir(HISTORY_DIR)
- ensureDir(STATS_DIR)
- ensureDir(RAW_DIR)
- local detector = peripheral.find("player_detector")
- if not detector then
- error("No Advanced Peripherals Player Detector is connected")
- end
- local monitor, monitorName
- if SETTINGS.MIRROR_TO_MONITOR then
- if SETTINGS.MONITOR_NAME then
- if peripheral.isPresent(SETTINGS.MONITOR_NAME) and peripheral.getType(SETTINGS.MONITOR_NAME) == "monitor" then
- monitorName = SETTINGS.MONITOR_NAME
- monitor = peripheral.wrap(monitorName)
- end
- else
- for _, name in ipairs(peripheral.getNames()) do
- if peripheral.getType(name) == "monitor" then
- monitorName = name
- monitor = peripheral.wrap(name)
- break
- end
- end
- end
- end
- -- Always render the interactive UI to the computer terminal. CC:Tweaked only
- -- emits mouse_click/mouse_drag/mouse_scroll for this terminal, not monitors.
- -- The rendered terminal buffer is copied to the monitor after every frame.
- local nativeTerm = term.current()
- local screen = nativeTerm
- if monitor and monitor.setTextScale then
- pcall(monitor.setTextScale, SETTINGS.MONITOR_TEXT_SCALE)
- end
- local function nowMs()
- return os.epoch("utc")
- end
- local function safeName(name)
- return tostring(name or "unknown"):gsub("[^%w_%-.]", "_")
- end
- local function round(v)
- if v >= 0 then return math.floor(v + 0.5) end
- return math.ceil(v - 0.5)
- end
- local function clamp(v, lo, hi)
- if v < lo then return lo end
- if v > hi then return hi end
- return v
- end
- local function distance3(a, b)
- local dx = (a.x or 0) - (b.x or 0)
- local dy = (a.y or 0) - (b.y or 0)
- local dz = (a.z or 0) - (b.z or 0)
- return math.sqrt(dx * dx + dy * dy + dz * dz)
- end
- local function distance2(a, b)
- local dx = (a.x or 0) - (b.x or 0)
- local dz = (a.z or 0) - (b.z or 0)
- return math.sqrt(dx * dx + dz * dz)
- end
- local function formatDuration(sec)
- sec = math.max(0, math.floor(sec or 0))
- local h = math.floor(sec / 3600)
- local m = math.floor((sec % 3600) / 60)
- local s = sec % 60
- if h > 0 then return string.format("%dh %02dm", h, m) end
- if m > 0 then return string.format("%dm %02ds", m, s) end
- return string.format("%ds", s)
- end
- local function shortDim(dim)
- dim = tostring(dim or "?")
- local last = dim:match("([^:]+)$")
- return last or dim
- end
- local function fit(text, width)
- text = tostring(text or "")
- if width <= 0 then return "" end
- if #text <= width then return text end
- if width <= 2 then return text:sub(1, width) end
- return text:sub(1, width - 1) .. "~"
- end
- local function jsonEncode(v)
- local ok, out = pcall(textutils.serializeJSON, v)
- if ok then return out end
- return nil
- end
- local function jsonDecode(s)
- local ok, out = pcall(textutils.unserializeJSON, s)
- if ok then return out end
- return nil
- end
- local function safeCopy(value, depth, seen)
- depth = depth or 0
- seen = seen or {}
- if depth > 5 then return "<depth>" end
- local t = type(value)
- if t == "nil" or t == "number" or t == "string" or t == "boolean" then
- return value
- elseif t ~= "table" then
- return tostring(value)
- end
- if seen[value] then return "<cycle>" end
- seen[value] = true
- local out = {}
- for k, v in pairs(value) do
- local kt = type(k)
- if kt == "string" or kt == "number" then
- out[k] = safeCopy(v, depth + 1, seen)
- end
- end
- seen[value] = nil
- return out
- end
- local function appendLine(path, line)
- local ok, result = pcall(function()
- local h = fs.open(path, "a")
- if not h then return false end
- h.writeLine(line)
- h.close()
- return true
- end)
- return ok and result == true
- end
- local function rewriteTail(path, maxBytes)
- if not fs.exists(path) then return end
- local size = fs.getSize(path)
- if type(size) ~= "number" or size <= maxBytes then return end
- local h = fs.open(path, "r")
- if not h then return end
- local data = h.readAll() or ""
- h.close()
- local keep = math.floor(maxBytes * 0.55)
- local start = math.max(1, #data - keep)
- local nl = data:find("\n", start, true)
- if nl then start = nl + 1 end
- local tail = data:sub(start)
- local tmp = path .. ".tmp"
- local out = fs.open(tmp, "w")
- if not out then return end
- out.write(tail)
- out.close()
- if fs.exists(path) then fs.delete(path) end
- fs.move(tmp, path)
- end
- local config = {
- tracked = {},
- centerX = 0,
- centerZ = 0,
- activeDim = nil,
- zoomIndex = SETTINGS.DEFAULT_ZOOM_INDEX,
- centerInitialized = false,
- }
- local function loadConfig()
- if not fs.exists(CONFIG_FILE) then return end
- local h = fs.open(CONFIG_FILE, "r")
- if not h then return end
- local raw = h.readAll()
- h.close()
- local data = jsonDecode(raw or "")
- if type(data) ~= "table" then return end
- if type(data.tracked) == "table" then config.tracked = data.tracked end
- if type(data.centerX) == "number" then config.centerX = data.centerX end
- if type(data.centerZ) == "number" then config.centerZ = data.centerZ end
- if type(data.activeDim) == "string" then config.activeDim = data.activeDim end
- if type(data.zoomIndex) == "number" then
- config.zoomIndex = clamp(math.floor(data.zoomIndex), 1, #SETTINGS.ZOOM_LEVELS)
- end
- if data.centerInitialized == true then config.centerInitialized = true end
- end
- local function saveConfig()
- local payload = {
- tracked = config.tracked,
- centerX = config.centerX,
- centerZ = config.centerZ,
- activeDim = config.activeDim,
- zoomIndex = config.zoomIndex,
- centerInitialized = config.centerInitialized,
- }
- local encoded = jsonEncode(payload)
- if not encoded then return end
- local tmp = CONFIG_FILE .. ".tmp"
- local h = fs.open(tmp, "w")
- if not h then return end
- h.write(encoded)
- h.close()
- if fs.exists(CONFIG_FILE) then fs.delete(CONFIG_FILE) end
- fs.move(tmp, CONFIG_FILE)
- end
- loadConfig()
- for _, name in ipairs(SETTINGS.DEFAULT_TRACKED) do
- config.tracked[tostring(name):lower()] = tostring(name)
- end
- saveConfig()
- local events = {}
- local function loadEvents()
- if not fs.exists(EVENT_FILE) then return end
- local h = fs.open(EVENT_FILE, "r")
- if not h then return end
- while true do
- local line = h.readLine()
- if not line then break end
- local ev = jsonDecode(line)
- if type(ev) == "table" then
- events[#events + 1] = ev
- if #events > SETTINGS.EVENT_CACHE_LIMIT then table.remove(events, 1) end
- end
- end
- h.close()
- end
- loadEvents()
- local statusText = "starting"
- local statusUntil = 0
- local function setStatus(text, seconds)
- statusText = tostring(text or "")
- statusUntil = nowMs() + math.floor((seconds or 4) * 1000)
- end
- local function eventPosition(ev)
- if type(ev.toX) == "number" then
- return ev.toX, ev.toY, ev.toZ, ev.toDim or ev.dim
- end
- if type(ev.x) == "number" then
- return ev.x, ev.y, ev.z, ev.dim
- end
- if type(ev.anchorX) == "number" then
- return ev.anchorX, ev.anchorY, ev.anchorZ, ev.dim
- end
- return nil, nil, nil, ev.dim
- end
- local function logEvent(kind, name, fields, snapshot)
- local ev = {
- ts = nowMs(),
- type = kind,
- player = name,
- }
- if type(fields) == "table" then
- for k, v in pairs(fields) do ev[k] = v end
- end
- if snapshot then ev.snapshot = safeCopy(snapshot) end
- local encoded = jsonEncode(ev)
- if encoded then
- appendLine(EVENT_FILE, encoded)
- rewriteTail(EVENT_FILE, SETTINGS.EVENT_MAX_FILE_BYTES)
- end
- events[#events + 1] = ev
- if #events > SETTINGS.EVENT_CACHE_LIMIT then table.remove(events, 1) end
- end
- local players = {}
- local trackerState = {}
- local trails = {}
- local lastHistory = {}
- local pendingJoin = {}
- local pendingDimension = {}
- local selectedPlayer = nil
- local activeView = "map"
- local eventOffset = 0
- local playerListOffset = 0
- local dirtyConfig = false
- local running = true
- local dragState = nil
- local lastPollMs = nowMs()
- local lastRenderMs = 0
- local function isTracked(name)
- return config.tracked[tostring(name):lower()] ~= nil
- end
- local function canonicalTrackedName(name)
- return config.tracked[tostring(name):lower()]
- end
- local function setTracked(name, yes)
- if not name or name == "" then return end
- local key = tostring(name):lower()
- if yes then
- config.tracked[key] = tostring(name)
- trackerState[key] = trackerState[key] or {}
- trails[key] = trails[key] or {}
- logEvent("track_on", tostring(name), {})
- setStatus("tracking " .. tostring(name))
- else
- local old = config.tracked[key] or tostring(name)
- config.tracked[key] = nil
- logEvent("track_off", old, {})
- setStatus("stopped tracking " .. old)
- end
- dirtyConfig = true
- saveConfig()
- end
- local function toggleTracked(name)
- setTracked(name, not isTracked(name))
- end
- local function copyPosition(p, ts)
- return {
- x = p.x,
- y = p.y,
- z = p.z,
- dimension = p.dimension,
- yaw = p.yaw,
- pitch = p.pitch,
- health = p.health,
- maxHealth = p.maxHealth,
- airSupply = p.airSupply,
- respawnPosition = safeCopy(p.respawnPosition),
- respawnDimension = safeCopy(p.respawnDimension),
- respawnAngle = safeCopy(p.respawnAngle),
- ts = ts or nowMs(),
- }
- end
- local function respawnFingerprint(p)
- if p.respawnPosition == nil and p.respawnDimension == nil and p.respawnAngle == nil then
- return nil
- end
- return jsonEncode({
- pos = safeCopy(p.respawnPosition),
- dim = safeCopy(p.respawnDimension),
- angle = safeCopy(p.respawnAngle),
- })
- end
- local function appendBreadcrumb(name, p, ts)
- local okFree, free = pcall(fs.getFreeSpace, "/")
- if okFree and type(free) == "number" and free < SETTINGS.MIN_FREE_FOR_HISTORY_BYTES then
- return
- end
- local key = tostring(name):lower()
- local last = lastHistory[key]
- local shouldWrite = false
- if not last then
- shouldWrite = true
- else
- local age = (ts - last.ts) / 1000
- local moved = distance3(last, p)
- if age >= SETTINGS.HISTORY_FORCE_SAMPLE_SECONDS then
- shouldWrite = true
- elseif age >= SETTINGS.HISTORY_SAMPLE_SECONDS and moved >= SETTINGS.HISTORY_MIN_MOVE then
- shouldWrite = true
- end
- end
- if not shouldWrite then return end
- local path = HISTORY_DIR .. "/" .. safeName(name) .. ".csv"
- if not fs.exists(path) then
- appendLine(path, "timestamp_ms,dimension,x,y,z,yaw,pitch,health,max_health,air")
- end
- local line = string.format(
- "%d,%s,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f",
- ts,
- tostring(p.dimension or "?"):gsub(",", "_"),
- tonumber(p.x) or 0,
- tonumber(p.y) or 0,
- tonumber(p.z) or 0,
- tonumber(p.yaw) or 0,
- tonumber(p.pitch) or 0,
- tonumber(p.health) or 0,
- tonumber(p.maxHealth) or 0,
- tonumber(p.airSupply) or 0
- )
- appendLine(path, line)
- rewriteTail(path, SETTINGS.HISTORY_MAX_BYTES_PER_PLAYER)
- lastHistory[key] = copyPosition(p, ts)
- end
- local function addTrailPoint(name, p, ts)
- local key = tostring(name):lower()
- local trail = trails[key] or {}
- trails[key] = trail
- local last = trail[#trail]
- if last and last.dimension == p.dimension and distance2(last, p) < 2 and (ts - last.ts) < 5000 then
- return
- end
- trail[#trail + 1] = {
- x = p.x, y = p.y, z = p.z,
- dimension = p.dimension,
- ts = ts,
- }
- while #trail > 220 do table.remove(trail, 1) end
- end
- local function startArea(state, p, ts, reason)
- state.area = {
- x = p.x,
- y = p.y,
- z = p.z,
- dimension = p.dimension,
- startMs = ts,
- lastMs = ts,
- reason = reason or "movement",
- milestones = {},
- }
- end
- local function closeArea(name, state, ts, reason, p)
- local area = state.area
- if not area then return end
- local endMs = area.lastMs or ts
- local duration = math.max(0, (endMs - area.startMs) / 1000)
- if duration >= SETTINGS.DWELL_MIN_LOG_SECONDS then
- logEvent("area_stay", name, {
- anchorX = area.x,
- anchorY = area.y,
- anchorZ = area.z,
- x = p and p.x or state.last and state.last.x,
- y = p and p.y or state.last and state.last.y,
- z = p and p.z or state.last and state.last.z,
- dim = area.dimension,
- duration = duration,
- radius = SETTINGS.DWELL_RADIUS,
- reason = reason,
- startedBy = area.reason,
- }, p)
- end
- state.area = nil
- end
- local function updateArea(name, state, p, ts)
- local area = state.area
- if not area then
- startArea(state, p, ts, "movement")
- return
- end
- if area.dimension ~= p.dimension then
- closeArea(name, state, ts, "dimension", p)
- startArea(state, p, ts, "dimension")
- return
- end
- if distance2(area, p) > SETTINGS.DWELL_RADIUS then
- closeArea(name, state, ts, "left_radius", p)
- startArea(state, p, ts, "movement")
- return
- end
- area.lastMs = ts
- local duration = (ts - area.startMs) / 1000
- for _, milestone in ipairs(SETTINGS.DWELL_MILESTONES) do
- if duration >= milestone and not area.milestones[tostring(milestone)] then
- area.milestones[tostring(milestone)] = true
- logEvent("dwell", name, {
- anchorX = area.x,
- anchorY = area.y,
- anchorZ = area.z,
- x = p.x,
- y = p.y,
- z = p.z,
- dim = p.dimension,
- duration = duration,
- radius = SETTINGS.DWELL_RADIUS,
- milestone = milestone,
- }, p)
- end
- end
- end
- local function captureStats(name)
- if not name then
- setStatus("select a player first")
- return false
- end
- if not advancedperipherals or not advancedperipherals.iterPlayerStatKeys then
- setStatus("Advanced Peripherals stat iterator unavailable", 6)
- return false
- end
- setStatus("capturing stats for " .. name, 10)
- local ids = {}
- for id in advancedperipherals.iterPlayerStatKeys() do
- ids[#ids + 1] = id
- end
- local result = {
- ts = nowMs(),
- player = name,
- stats = {},
- }
- local batchSize = 16
- local i = 1
- while i <= #ids do
- local batch = {}
- for j = i, math.min(i + batchSize - 1, #ids) do
- batch[#batch + 1] = ids[j]
- end
- local packed = { pcall(detector.getPlayerStat, name, table.unpack(batch)) }
- if packed[1] then
- for j = 1, #batch do
- result.stats[batch[j]] = packed[j + 1]
- end
- else
- result.errors = result.errors or {}
- result.errors[#result.errors + 1] = tostring(packed[2])
- end
- i = i + batchSize
- end
- local encoded = jsonEncode(result)
- if encoded then
- local path = STATS_DIR .. "/" .. safeName(name) .. ".jsonl"
- appendLine(path, encoded)
- rewriteTail(path, 160000)
- logEvent("stats_snapshot", name, { count = #ids })
- setStatus("saved " .. tostring(#ids) .. " stats for " .. name, 6)
- return true
- end
- setStatus("failed to encode stat snapshot", 6)
- return false
- end
- local function dumpRaw(name)
- if not name then
- setStatus("select a player first")
- return
- end
- local p = players[tostring(name):lower()]
- if not p then
- local ok, fetched = pcall(detector.getPlayer, name)
- if ok then p = fetched end
- end
- if type(p) ~= "table" then
- setStatus("player data unavailable")
- return
- end
- local data = safeCopy(p)
- data._dumpedAt = nowMs()
- local encoded = jsonEncode(data)
- if not encoded then
- setStatus("raw snapshot could not be encoded")
- return
- end
- local path = RAW_DIR .. "/" .. safeName(name) .. "_" .. tostring(nowMs()) .. ".json"
- local h = fs.open(path, "w")
- if h then
- h.write(encoded)
- h.close()
- setStatus("raw snapshot saved", 5)
- end
- end
- local function majorEventStats(name)
- if SETTINGS.CAPTURE_STATS_ON_MAJOR_EVENT then
- pcall(captureStats, name)
- end
- end
- local function processTrackedPlayer(name, p, ts)
- local key = tostring(name):lower()
- local state = trackerState[key] or {}
- trackerState[key] = state
- local firstThisSession = not state.online
- if firstThisSession then
- state.online = true
- state.last = copyPosition(p, ts)
- state.respawnFingerprint = respawnFingerprint(p)
- startArea(state, p, ts, pendingJoin[key] and "join" or "seen")
- addTrailPoint(name, p, ts)
- appendBreadcrumb(name, p, ts)
- local join = pendingJoin[key]
- if join then
- logEvent("join", name, {
- x = p.x, y = p.y, z = p.z,
- dim = p.dimension or join.dimension,
- }, p)
- pendingJoin[key] = nil
- else
- logEvent("seen", name, {
- x = p.x, y = p.y, z = p.z,
- dim = p.dimension,
- }, p)
- end
- return
- end
- local last = state.last
- if last then
- local dt = math.max(0.001, (ts - (last.ts or ts)) / 1000)
- local moved = distance3(last, p)
- p._speed = moved / dt
- if last.dimension ~= p.dimension then
- closeArea(name, state, ts, "dimension", p)
- local recentDeath = state.lastDeathMs and (ts - state.lastDeathMs) <= 15000
- local pending = pendingDimension[key]
- local kind = recentDeath and "respawn_dimension" or "dimension"
- logEvent(kind, name, {
- fromDim = pending and pending.fromDim or last.dimension,
- toDim = pending and pending.toDim or p.dimension,
- fromX = last.x, fromY = last.y, fromZ = last.z,
- toX = p.x, toY = p.y, toZ = p.z,
- dim = p.dimension,
- }, p)
- pendingDimension[key] = nil
- startArea(state, p, ts, kind)
- majorEventStats(name)
- elseif moved >= SETTINGS.TELEPORT_DISTANCE then
- closeArea(name, state, ts, "teleport", p)
- local recentDeath = state.lastDeathMs and (ts - state.lastDeathMs) <= 15000
- local kind = recentDeath and "respawn" or "teleport"
- logEvent(kind, name, {
- fromX = last.x, fromY = last.y, fromZ = last.z,
- toX = p.x, toY = p.y, toZ = p.z,
- dim = p.dimension,
- distance = moved,
- seconds = dt,
- }, p)
- startArea(state, p, ts, kind)
- majorEventStats(name)
- else
- updateArea(name, state, p, ts)
- end
- else
- startArea(state, p, ts, "seen")
- end
- local fp = respawnFingerprint(p)
- if fp and state.respawnFingerprint and fp ~= state.respawnFingerprint then
- logEvent("respawn_change", name, {
- x = p.x, y = p.y, z = p.z,
- dim = p.dimension,
- respawnPosition = safeCopy(p.respawnPosition),
- respawnDimension = safeCopy(p.respawnDimension),
- respawnAngle = safeCopy(p.respawnAngle),
- }, p)
- end
- if fp then state.respawnFingerprint = fp end
- addTrailPoint(name, p, ts)
- appendBreadcrumb(name, p, ts)
- state.last = copyPosition(p, ts)
- state.online = true
- end
- local function processOfflineTracked(name, ts, reason)
- local key = tostring(name):lower()
- local state = trackerState[key]
- if not state or not state.online then return end
- closeArea(name, state, ts, reason or "offline", state.last)
- if reason ~= "event_leave" then
- local last = state.last
- logEvent("leave", name, {
- x = last and last.x,
- y = last and last.y,
- z = last and last.z,
- dim = last and last.dimension,
- })
- end
- state.online = false
- end
- local function normalizeOnlineEntry(v)
- if type(v) == "string" then return v end
- if type(v) == "table" then return v.name or v.username or v.uuid end
- if v ~= nil then return tostring(v) end
- return nil
- end
- local function pollPlayers()
- local ts = nowMs()
- local ok, list = pcall(detector.getOnlinePlayers)
- if not ok or type(list) ~= "table" then
- setStatus("Player Detector getOnlinePlayers failed: " .. tostring(list), 6)
- return
- end
- local fresh = {}
- local seenOnline = {}
- for _, entry in pairs(list) do
- local name = normalizeOnlineEntry(entry)
- if name and name ~= "" then
- local pok, p = pcall(detector.getPlayer, name)
- if pok and type(p) == "table" and type(p.x) == "number" and type(p.z) == "number" then
- local canonical = tostring(p.name or name)
- local key = canonical:lower()
- p.name = canonical
- fresh[key] = p
- seenOnline[key] = canonical
- if isTracked(canonical) then processTrackedPlayer(canonical, p, ts) end
- end
- end
- end
- for key, trackedName in pairs(config.tracked) do
- if not seenOnline[key] then
- processOfflineTracked(trackedName, ts, "poll_offline")
- end
- end
- players = fresh
- lastPollMs = ts
- if not config.centerInitialized then
- local sumX, sumZ, count = 0, 0, 0
- local chosenDim = config.activeDim
- if not chosenDim then
- for _, p in pairs(players) do
- chosenDim = p.dimension
- break
- end
- end
- if chosenDim then
- for _, p in pairs(players) do
- if p.dimension == chosenDim then
- sumX = sumX + p.x
- sumZ = sumZ + p.z
- count = count + 1
- end
- end
- end
- if count > 0 then
- config.centerX = sumX / count
- config.centerZ = sumZ / count
- config.activeDim = chosenDim
- config.centerInitialized = true
- dirtyConfig = true
- end
- end
- end
- local function getPlayer(name)
- if not name then return nil end
- return players[tostring(name):lower()]
- end
- local function playerNamesForSidebar()
- local seen = {}
- local out = {}
- for key, name in pairs(config.tracked) do
- local p = players[key]
- out[#out + 1] = { name = p and p.name or name, online = p ~= nil, tracked = true }
- seen[key] = true
- end
- for key, p in pairs(players) do
- if not seen[key] then
- out[#out + 1] = { name = p.name or key, online = true, tracked = false }
- end
- end
- table.sort(out, function(a, b)
- if a.online ~= b.online then return a.online end
- if a.tracked ~= b.tracked then return a.tracked end
- return a.name:lower() < b.name:lower()
- end)
- return out
- end
- local function getDimensions()
- local set = {}
- local out = {}
- if config.activeDim then set[config.activeDim] = true end
- for _, p in pairs(players) do if p.dimension then set[p.dimension] = true end end
- for i = math.max(1, #events - 100), #events do
- local ev = events[i]
- if ev then
- if ev.dim then set[ev.dim] = true end
- if ev.toDim then set[ev.toDim] = true end
- if ev.fromDim then set[ev.fromDim] = true end
- end
- end
- for dim in pairs(set) do out[#out + 1] = dim end
- table.sort(out)
- return out
- end
- local function cycleDimension(direction)
- local dims = getDimensions()
- if #dims == 0 then return end
- local current = 1
- for i, d in ipairs(dims) do
- if d == config.activeDim then current = i break end
- end
- current = current + (direction or 1)
- if current > #dims then current = 1 end
- if current < 1 then current = #dims end
- config.activeDim = dims[current]
- dirtyConfig = true
- saveConfig()
- end
- local function centerOnSelected()
- local p = getPlayer(selectedPlayer)
- if p then
- config.centerX = p.x
- config.centerZ = p.z
- config.activeDim = p.dimension
- config.centerInitialized = true
- dirtyConfig = true
- saveConfig()
- return
- end
- local sumX, sumZ, count = 0, 0, 0
- for _, pl in pairs(players) do
- if not config.activeDim or pl.dimension == config.activeDim then
- sumX = sumX + pl.x
- sumZ = sumZ + pl.z
- count = count + 1
- end
- end
- if count > 0 then
- config.centerX = sumX / count
- config.centerZ = sumZ / count
- config.centerInitialized = true
- dirtyConfig = true
- saveConfig()
- end
- end
- local function zoom(delta)
- config.zoomIndex = clamp(config.zoomIndex + delta, 1, #SETTINGS.ZOOM_LEVELS)
- dirtyConfig = true
- end
- local function zoomAt(delta, sx, sy, mapRect)
- local oldScale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
- local oldIndex = config.zoomIndex
- config.zoomIndex = clamp(config.zoomIndex + delta, 1, #SETTINGS.ZOOM_LEVELS)
- if config.zoomIndex == oldIndex then return end
- local newScale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
- if mapRect and sx and sy then
- local cx = (mapRect.x1 + mapRect.x2) / 2
- local cy = (mapRect.y1 + mapRect.y2) / 2
- local worldX = config.centerX + (sx - cx) * oldScale
- local worldZ = config.centerZ + (sy - cy) * oldScale
- config.centerX = worldX - (sx - cx) * newScale
- config.centerZ = worldZ - (sy - cy) * newScale
- end
- dirtyConfig = true
- end
- local function facingArrow(yaw)
- yaw = tonumber(yaw)
- if not yaw then return "?" end
- yaw = ((yaw % 360) + 360) % 360
- if yaw >= 315 or yaw < 45 then return "v" end -- +Z south
- if yaw < 135 then return "<" end -- west
- if yaw < 225 then return "^" end -- north
- return ">" -- east
- end
- local palette = {
- colors.red, colors.orange, colors.yellow, colors.lime,
- colors.cyan, colors.lightBlue, colors.blue, colors.purple,
- colors.magenta, colors.pink, colors.white,
- }
- local function nameColor(name)
- local hash = 0
- name = tostring(name or "")
- for i = 1, #name do hash = (hash * 33 + name:byte(i)) % 2147483647 end
- return palette[(hash % #palette) + 1]
- end
- local buffer
- local screenW, screenH = 0, 0
- local hitboxes = {}
- local mapRect = nil
- local function recreateBuffer()
- local w, h = screen.getSize()
- screenW, screenH = w, h
- buffer = window.create(screen, 1, 1, w, h, false)
- end
- recreateBuffer()
- local function mirrorBufferToMonitor()
- if not monitor or not buffer then return end
- local ok = pcall(function()
- local mw, mh = monitor.getSize()
- monitor.setBackgroundColor(colors.black)
- monitor.setTextColor(colors.white)
- monitor.clear()
- local copyH = math.min(screenH, mh)
- local copyW = math.min(screenW, mw)
- for y = 1, copyH do
- local text, fg, bg = buffer.getLine(y)
- if text and copyW > 0 then
- text = text:sub(1, copyW)
- fg = fg:sub(1, copyW)
- bg = bg:sub(1, copyW)
- monitor.setCursorPos(1, y)
- monitor.blit(text, fg, bg)
- end
- end
- end)
- if not ok then
- -- Do not let a detached/broken monitor kill the tracker.
- monitor = nil
- monitorName = nil
- end
- end
- local function setColors(fg, bg)
- if bg then buffer.setBackgroundColor(bg) end
- if fg then buffer.setTextColor(fg) end
- end
- local function writeAt(x, y, text, fg, bg)
- if y < 1 or y > screenH then return end
- text = tostring(text or "")
- if x > screenW or x + #text - 1 < 1 then return end
- if x < 1 then
- text = text:sub(2 - x)
- x = 1
- end
- if x + #text - 1 > screenW then
- text = text:sub(1, screenW - x + 1)
- end
- if #text <= 0 then return end
- setColors(fg or colors.white, bg or colors.black)
- buffer.setCursorPos(x, y)
- buffer.write(text)
- end
- local function fillRect(x1, y1, x2, y2, bg, ch, fg)
- x1 = clamp(math.floor(x1), 1, screenW)
- x2 = clamp(math.floor(x2), 1, screenW)
- y1 = clamp(math.floor(y1), 1, screenH)
- y2 = clamp(math.floor(y2), 1, screenH)
- if x2 < x1 or y2 < y1 then return end
- ch = ch or " "
- local row = string.rep(ch, x2 - x1 + 1)
- for y = y1, y2 do writeAt(x1, y, row, fg or colors.white, bg) end
- end
- local function addHit(x1, y1, x2, y2, action, data)
- hitboxes[#hitboxes + 1] = {
- x1 = x1, y1 = y1, x2 = x2, y2 = y2,
- action = action, data = data,
- }
- end
- local function button(x1, y, width, label, action, data, active)
- if width < 1 then return end
- local bg = active and colors.lightBlue or colors.gray
- local fg = active and colors.black or colors.white
- local text = fit("[" .. label .. "]", width)
- local padded = text .. string.rep(" ", math.max(0, width - #text))
- writeAt(x1, y, padded, fg, bg)
- addHit(x1, y, x1 + width - 1, y, action, data)
- end
- local function getLayout()
- local sideW
- if screenW >= 70 then sideW = 27
- elseif screenW >= 50 then sideW = 23
- else sideW = math.max(18, math.floor(screenW * 0.40)) end
- local mapW = screenW - sideW
- if mapW < 15 then
- sideW = math.max(12, screenW - 15)
- mapW = screenW - sideW
- end
- return {
- sideW = sideW,
- mapW = mapW,
- sideX = mapW + 1,
- map = { x1 = 1, y1 = 3, x2 = mapW, y2 = screenH },
- }
- end
- local function worldToScreen(x, z, rect, scale)
- local cx = (rect.x1 + rect.x2) / 2
- local cy = (rect.y1 + rect.y2) / 2
- local sx = round(cx + (x - config.centerX) / scale)
- local sy = round(cy + (z - config.centerZ) / scale)
- return sx, sy
- end
- local function screenToWorld(sx, sy, rect, scale)
- local cx = (rect.x1 + rect.x2) / 2
- local cy = (rect.y1 + rect.y2) / 2
- local wx = config.centerX + (sx - cx) * scale
- local wz = config.centerZ + (sy - cy) * scale
- return wx, wz
- end
- local function drawChunkGrid(rect, scale)
- if scale > 32 then return end
- local cx = (rect.x1 + rect.x2) / 2
- local cy = (rect.y1 + rect.y2) / 2
- local previousChunk
- for sx = rect.x1, rect.x2 do
- local wx = config.centerX + (sx - cx) * scale
- local chunk = math.floor(wx / 16)
- if previousChunk ~= nil and chunk ~= previousChunk then
- for y = rect.y1, rect.y2 do
- writeAt(sx, y, "|", colors.gray, colors.black)
- end
- end
- previousChunk = chunk
- end
- previousChunk = nil
- for sy = rect.y1, rect.y2 do
- local wz = config.centerZ + (sy - cy) * scale
- local chunk = math.floor(wz / 16)
- if previousChunk ~= nil and chunk ~= previousChunk then
- for x = rect.x1, rect.x2 do
- writeAt(x, sy, "-", colors.gray, colors.black)
- end
- end
- previousChunk = chunk
- end
- end
- local function drawSelectedTrail(rect, scale)
- if not selectedPlayer then return end
- local key = tostring(selectedPlayer):lower()
- local trail = trails[key]
- if not trail then return end
- for _, point in ipairs(trail) do
- if point.dimension == config.activeDim then
- local sx, sy = worldToScreen(point.x, point.z, rect, scale)
- if sx >= rect.x1 and sx <= rect.x2 and sy >= rect.y1 and sy <= rect.y2 then
- writeAt(sx, sy, ".", colors.lightGray, colors.black)
- end
- end
- end
- end
- local function drawMap(layout)
- local rect = layout.map
- mapRect = rect
- local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
- -- Deliberately blank map: no chunk grid, world-origin cross, or trail overlay.
- fillRect(rect.x1, rect.y1, rect.x2, rect.y2, colors.black, " ")
- local names = {}
- for _, p in pairs(players) do
- if p.dimension == config.activeDim then names[#names + 1] = p.name end
- end
- table.sort(names, function(a, b) return a:lower() < b:lower() end)
- for _, name in ipairs(names) do
- local p = getPlayer(name)
- if p then
- local sx, sy = worldToScreen(p.x, p.z, rect, scale)
- if sx >= rect.x1 and sx <= rect.x2 and sy >= rect.y1 and sy <= rect.y2 then
- local selected = selectedPlayer and selectedPlayer:lower() == name:lower()
- local tracked = isTracked(name)
- local char = selected and "X" or (tracked and "@" or "o")
- local fg = selected and colors.yellow or nameColor(name)
- -- Player marker.
- writeAt(sx, sy, char, fg, colors.black)
- -- Name above and X,Z below. Clicking any of these selects the player.
- local label = tostring(name)
- local coords = string.format("%.0f,%.0f", p.x, p.z)
- local labelX = sx - math.floor(#label / 2)
- local coordX = sx - math.floor(#coords / 2)
- local y1, y2 = sy, sy
- local x1, x2 = sx, sx
- if sy - 1 >= rect.y1 then
- local maxW = rect.x2 - rect.x1 + 1
- label = fit(label, math.min(#label, maxW))
- labelX = clamp(labelX, rect.x1, math.max(rect.x1, rect.x2 - #label + 1))
- writeAt(labelX, sy - 1, label, fg, colors.black)
- x1 = math.min(x1, labelX)
- x2 = math.max(x2, labelX + #label - 1)
- y1 = sy - 1
- end
- if sy + 1 <= rect.y2 then
- local maxW = rect.x2 - rect.x1 + 1
- coords = fit(coords, math.min(#coords, maxW))
- coordX = clamp(coordX, rect.x1, math.max(rect.x1, rect.x2 - #coords + 1))
- writeAt(coordX, sy + 1, coords, colors.lightGray, colors.black)
- x1 = math.min(x1, coordX)
- x2 = math.max(x2, coordX + #coords - 1)
- y2 = sy + 1
- end
- addHit(x1, y1, x2, y2, "select_player", name)
- end
- end
- end
- local title = string.format(
- " MAP %s %.0f,%.0f %db/c | RIGHT-DRAG pan | WHEEL zoom ",
- shortDim(config.activeDim), config.centerX, config.centerZ, scale
- )
- writeAt(rect.x1, 2, fit(title, layout.mapW), colors.white, colors.black)
- end
- local function eventCode(kind)
- local map = {
- teleport = "TP",
- respawn = "RESP",
- dimension = "DIM",
- respawn_dimension = "R-DIM",
- death = "DEAD",
- join = "JOIN",
- leave = "LEAVE",
- seen = "SEEN",
- dwell = "DWELL",
- area_stay = "STAY",
- respawn_change = "BED",
- stats_snapshot = "STAT",
- track_on = "TRK+",
- track_off = "TRK-",
- }
- return map[kind] or tostring(kind or "?"):upper()
- end
- local function filteredEvents()
- local out = {}
- local filter = selectedPlayer and selectedPlayer:lower() or nil
- for i = #events, 1, -1 do
- local ev = events[i]
- if not filter or (ev.player and tostring(ev.player):lower() == filter) then
- out[#out + 1] = ev
- end
- end
- return out
- end
- local function formatEventLine(ev, width)
- local stamp = "?"
- if type(ev.ts) == "number" then
- local ok, s = pcall(os.date, "!%d/%m %H:%M", ev.ts / 1000)
- if ok then stamp = s end
- end
- local x, y, z = eventPosition(ev)
- local coord = ""
- if x then coord = string.format(" %.0f,%.0f,%.0f", x, y or 0, z or 0) end
- local extra = ""
- if ev.distance then extra = string.format(" %.0fb", ev.distance) end
- if ev.duration then extra = " " .. formatDuration(ev.duration) end
- local line = string.format("%s %-5s %-12s%s%s", stamp, eventCode(ev.type), tostring(ev.player or "?"), coord, extra)
- return fit(line, width)
- end
- local function drawEvents(layout)
- local rect = layout.map
- mapRect = rect
- fillRect(rect.x1, rect.y1, rect.x2, rect.y2, colors.black, " ")
- local filter = selectedPlayer and selectedPlayer or "ALL"
- writeAt(rect.x1, 2, fit(" EVENTS filter: " .. filter, layout.mapW), colors.white, colors.black)
- local list = filteredEvents()
- local row = rect.y1
- local start = 1 + eventOffset
- for i = start, math.min(#list, start + (rect.y2 - rect.y1)) do
- local ev = list[i]
- local fg = colors.white
- if ev.type == "teleport" or ev.type == "respawn" then fg = colors.yellow
- elseif ev.type == "death" then fg = colors.red
- elseif ev.type == "dwell" or ev.type == "area_stay" then fg = colors.lime
- elseif ev.type == "dimension" or ev.type == "respawn_dimension" then fg = colors.cyan
- end
- writeAt(rect.x1, row, formatEventLine(ev, layout.mapW), fg, colors.black)
- addHit(rect.x1, row, rect.x2, row, "event_jump", ev)
- row = row + 1
- if row > rect.y2 then break end
- end
- if #list == 0 then
- writeAt(rect.x1 + 1, rect.y1 + 1, "No matching events", colors.lightGray, colors.black)
- elseif start > #list then
- eventOffset = math.max(0, #list - 1)
- end
- end
- local function freeSpaceText()
- local ok, free = pcall(fs.getFreeSpace, "/")
- if not ok then return "?" end
- if type(free) == "string" then return free end
- if free >= 1000000 then return string.format("%.1fM", free / 1000000) end
- if free >= 1000 then return string.format("%.0fK", free / 1000) end
- return tostring(free)
- end
- local function drawSidebar(layout)
- local x = layout.sideX
- local w = layout.sideW
- fillRect(x, 1, screenW, screenH, colors.gray, " ")
- local onlineCount = 0
- for _ in pairs(players) do onlineCount = onlineCount + 1 end
- writeAt(x + 1, 1, fit("PLAYER TRACKER", w - 2), colors.yellow, colors.gray)
- writeAt(x + 1, 2, fit("Online " .. onlineCount .. " Free " .. freeSpaceText(), w - 2), colors.white, colors.gray)
- local controlTop = math.max(10, screenH - 7)
- local detailsRows = 4
- local listEnd = math.max(4, controlTop - detailsRows - 1)
- local listStart = 4
- local listRows = math.max(1, listEnd - listStart + 1)
- writeAt(x + 1, 3, fit("PLAYERS @=tracked", w - 2), colors.lightGray, colors.gray)
- local names = playerNamesForSidebar()
- playerListOffset = clamp(playerListOffset, 0, math.max(0, #names - listRows))
- for row = 0, listRows - 1 do
- local item = names[playerListOffset + row + 1]
- local y = listStart + row
- if item then
- local selected = selectedPlayer and selectedPlayer:lower() == item.name:lower()
- local prefix = (item.tracked and "@" or " ") .. (item.online and "+" or "-") .. " "
- local bg = selected and colors.lightBlue or colors.gray
- local fg = selected and colors.black or (item.online and colors.white or colors.lightGray)
- local text = fit(prefix .. item.name, w - 2)
- writeAt(x + 1, y, text .. string.rep(" ", math.max(0, w - 2 - #text)), fg, bg)
- addHit(x + 1, y, screenW - 1, y, "select_player", item.name)
- end
- end
- local detailY = listEnd + 1
- local p = getPlayer(selectedPlayer)
- if selectedPlayer then
- writeAt(x + 1, detailY, fit("SEL " .. selectedPlayer, w - 2), colors.yellow, colors.gray)
- if p then
- writeAt(x + 1, detailY + 1, fit(string.format("%.0f %.0f %.0f", p.x, p.y or 0, p.z), w - 2), colors.white, colors.gray)
- writeAt(x + 1, detailY + 2, fit("DIM " .. shortDim(p.dimension), w - 2), colors.white, colors.gray)
- local hp = tonumber(p.health)
- local maxHp = tonumber(p.maxHealth)
- local line = hp and string.format("HP %.1f/%.1f %s", hp, maxHp or 0, facingArrow(p.yaw)) or ("LOOK " .. facingArrow(p.yaw))
- writeAt(x + 1, detailY + 3, fit(line, w - 2), colors.white, colors.gray)
- else
- writeAt(x + 1, detailY + 1, "offline", colors.lightGray, colors.gray)
- end
- else
- writeAt(x + 1, detailY, "SEL none", colors.lightGray, colors.gray)
- writeAt(x + 1, detailY + 1, "Click a player", colors.lightGray, colors.gray)
- end
- local y = controlTop
- local half = math.floor((w - 3) / 2)
- button(x + 1, y, half, isTracked(selectedPlayer or "") and "UNTRACK" or "TRACK", "toggle_track", nil, false)
- button(x + 2 + half, y, w - 3 - half, activeView == "events" and "MAP" or "EVENTS", "toggle_view", nil, activeView == "events")
- y = y + 1
- if activeView == "map" then
- local third = math.floor((w - 4) / 3)
- button(x + 1, y, third, "<", "pan", { dx = -1, dz = 0 })
- button(x + 2 + third, y, third, "^", "pan", { dx = 0, dz = -1 })
- button(x + 3 + third * 2, y, w - 4 - third * 2, ">", "pan", { dx = 1, dz = 0 })
- y = y + 1
- button(x + 1, y, third, "-", "zoom", 1)
- button(x + 2 + third, y, third, "v", "pan", { dx = 0, dz = 1 })
- button(x + 3 + third * 2, y, w - 4 - third * 2, "+", "zoom", -1)
- else
- button(x + 1, y, half, "PAGE UP", "event_page", -1)
- button(x + 2 + half, y, w - 3 - half, "PAGE DN", "event_page", 1)
- y = y + 1
- button(x + 1, y, w - 2, "CLEAR FILTER", "clear_selection", nil)
- end
- y = y + 1
- button(x + 1, y, half, "CENTER", "center", nil)
- button(x + 2 + half, y, w - 3 - half, "DIM >", "cycle_dim", 1)
- y = y + 1
- button(x + 1, y, half, "STATS", "stats", nil)
- button(x + 2 + half, y, w - 3 - half, "RAW", "raw", nil)
- y = y + 1
- local status = statusUntil >= nowMs() and statusText or (monitor and "R-drag terminal | touch monitor" or "R-drag | wheel | T/E/Q")
- writeAt(x + 1, y, fit(status, w - 2), colors.lightGray, colors.gray)
- end
- local function render()
- local w, h = screen.getSize()
- if w ~= screenW or h ~= screenH or not buffer then recreateBuffer() end
- hitboxes = {}
- buffer.setVisible(false)
- buffer.setBackgroundColor(colors.black)
- buffer.setTextColor(colors.white)
- buffer.clear()
- if screenW < 30 or screenH < 12 then
- writeAt(1, 1, "Display too small. Enlarge terminal/monitor.", colors.red, colors.black)
- buffer.setVisible(true)
- mirrorBufferToMonitor()
- return
- end
- local layout = getLayout()
- if activeView == "events" then drawEvents(layout) else drawMap(layout) end
- drawSidebar(layout)
- buffer.setVisible(true)
- mirrorBufferToMonitor()
- lastRenderMs = nowMs()
- end
- local function hitAt(x, y)
- for i = #hitboxes, 1, -1 do
- local h = hitboxes[i]
- if x >= h.x1 and x <= h.x2 and y >= h.y1 and y <= h.y2 then return h end
- end
- return nil
- end
- local function executeAction(action, data)
- if action == "select_player" then
- selectedPlayer = tostring(data)
- local p = getPlayer(selectedPlayer)
- if p and activeView == "map" then config.activeDim = p.dimension end
- eventOffset = 0
- elseif action == "toggle_track" then
- if selectedPlayer then toggleTracked(selectedPlayer) else setStatus("select a player first") end
- elseif action == "toggle_view" then
- activeView = activeView == "map" and "events" or "map"
- eventOffset = 0
- elseif action == "pan" and data then
- local layout = getLayout()
- local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
- local stepX = math.max(5, math.floor(layout.mapW * 0.30)) * scale
- local stepZ = math.max(5, math.floor((screenH - 2) * 0.30)) * scale
- config.centerX = config.centerX + (data.dx or 0) * stepX
- config.centerZ = config.centerZ + (data.dz or 0) * stepZ
- config.centerInitialized = true
- dirtyConfig = true
- elseif action == "zoom" then
- zoom(tonumber(data) or 0)
- elseif action == "center" then
- centerOnSelected()
- elseif action == "cycle_dim" then
- cycleDimension(tonumber(data) or 1)
- elseif action == "event_page" then
- eventOffset = math.max(0, eventOffset + (tonumber(data) or 0) * math.max(1, screenH - 4))
- elseif action == "clear_selection" then
- selectedPlayer = nil
- eventOffset = 0
- elseif action == "stats" then
- captureStats(selectedPlayer)
- elseif action == "raw" then
- dumpRaw(selectedPlayer)
- elseif action == "event_jump" and type(data) == "table" then
- local x, y, z, dim = eventPosition(data)
- if x and z then
- config.centerX = x
- config.centerZ = z
- config.centerInitialized = true
- if dim then config.activeDim = dim end
- if data.player then selectedPlayer = tostring(data.player) end
- activeView = "map"
- dirtyConfig = true
- else
- setStatus("event has no coordinates")
- end
- end
- end
- local function handlePointer(x, y, source)
- local h = hitAt(x, y)
- if h then
- executeAction(h.action, h.data)
- return true
- end
- if source == "monitor" and activeView == "map" and mapRect and
- x >= mapRect.x1 and x <= mapRect.x2 and y >= mapRect.y1 and y <= mapRect.y2 then
- local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
- local wx, wz = screenToWorld(x, y, mapRect, scale)
- config.centerX = wx
- config.centerZ = wz
- config.centerInitialized = true
- dirtyConfig = true
- return true
- end
- return false
- end
- local function handleKey(code)
- if code == keys.q then
- running = false
- elseif code == keys.m then
- activeView = "map"
- elseif code == keys.e then
- activeView = "events"
- eventOffset = 0
- elseif code == keys.t then
- if selectedPlayer then toggleTracked(selectedPlayer) end
- elseif code == keys.c then
- centerOnSelected()
- elseif code == keys.d then
- cycleDimension(1)
- elseif code == keys.s then
- captureStats(selectedPlayer)
- elseif code == keys.r then
- dumpRaw(selectedPlayer)
- elseif code == keys.equals or code == keys.numPadAdd then
- zoom(-1)
- elseif code == keys.minus or code == keys.numPadSubtract then
- zoom(1)
- elseif code == keys.left then
- executeAction("pan", { dx = -1, dz = 0 })
- elseif code == keys.right then
- executeAction("pan", { dx = 1, dz = 0 })
- elseif code == keys.up then
- if activeView == "events" then
- eventOffset = math.max(0, eventOffset - 1)
- else
- executeAction("pan", { dx = 0, dz = -1 })
- end
- elseif code == keys.down then
- if activeView == "events" then
- eventOffset = eventOffset + 1
- else
- executeAction("pan", { dx = 0, dz = 1 })
- end
- elseif code == keys.home then
- selectedPlayer = nil
- centerOnSelected()
- end
- end
- local function handlePlayerEvent(ev)
- local kind = ev[1]
- local ts = nowMs()
- if kind == "player_join" then
- local _, uuid, username, dimension = table.unpack(ev)
- if username then pendingJoin[tostring(username):lower()] = { ts = ts, uuid = uuid, dimension = dimension } end
- elseif kind == "player_leave" then
- local _, uuid, username, dimension = table.unpack(ev)
- if username and isTracked(username) then
- local key = tostring(username):lower()
- local state = trackerState[key]
- if state and state.online then
- closeArea(username, state, ts, "offline", state.last)
- local last = state.last
- logEvent("leave", username, {
- x = last and last.x,
- y = last and last.y,
- z = last and last.z,
- dim = dimension or (last and last.dimension),
- uuid = uuid,
- })
- state.online = false
- end
- end
- elseif kind == "player_changed_dimension" then
- local _, uuid, username, fromDim, toDim = table.unpack(ev)
- if username then
- pendingDimension[tostring(username):lower()] = {
- ts = ts, uuid = uuid, fromDim = fromDim, toDim = toDim,
- }
- end
- elseif kind == "player_death" then
- local _, uuid, username, damageSource = table.unpack(ev)
- if username and isTracked(username) then
- local key = tostring(username):lower()
- local state = trackerState[key] or {}
- trackerState[key] = state
- state.lastDeathMs = ts
- local last = state.last
- logEvent("death", username, {
- x = last and last.x,
- y = last and last.y,
- z = last and last.z,
- dim = last and last.dimension,
- uuid = uuid,
- damageSource = damageSource,
- })
- end
- end
- end
- -- Try to expose useful detector config information in the initial event log/status.
- do
- local ok, cfg = pcall(detector.getConfiguration)
- if ok and type(cfg) == "table" then
- local encoded = jsonEncode(safeCopy(cfg))
- if encoded then
- local path = DATA_DIR .. "/detector_config.json"
- local h = fs.open(path, "w")
- if h then h.write(encoded) h.close() end
- end
- end
- end
- pollPlayers()
- render()
- setStatus(monitor and ("terminal + monitor " .. tostring(monitorName)) or "terminal mode", 5)
- local pollTimer = os.startTimer(SETTINGS.POLL_SECONDS)
- local renderTimer = os.startTimer(SETTINGS.RENDER_SECONDS)
- local saveTimer = os.startTimer(10)
- while running do
- local ev = { os.pullEventRaw() }
- local kind = ev[1]
- if kind == "terminate" then
- running = false
- elseif kind == "timer" then
- if ev[2] == pollTimer then
- pcall(pollPlayers)
- pollTimer = os.startTimer(SETTINGS.POLL_SECONDS)
- elseif ev[2] == renderTimer then
- pcall(render)
- renderTimer = os.startTimer(SETTINGS.RENDER_SECONDS)
- elseif ev[2] == saveTimer then
- if dirtyConfig then
- saveConfig()
- dirtyConfig = false
- end
- saveTimer = os.startTimer(10)
- end
- elseif kind == "monitor_touch" and monitor and ev[2] == monitorName then
- handlePointer(ev[3], ev[4], "monitor")
- pcall(render)
- elseif kind == "mouse_click" then
- local buttonId, x, y = ev[2], ev[3], ev[4]
- local inMap = activeView == "map" and mapRect and
- x >= mapRect.x1 and x <= mapRect.x2 and
- y >= mapRect.y1 and y <= mapRect.y2
- -- RIGHT mouse button is reserved for map dragging. We deliberately
- -- do not run hit-testing first, so you can begin a drag on top of a
- -- player marker/name without accidentally selecting it.
- if buttonId == 2 and inMap then
- dragState = { button = 2, x = x, y = y, moved = false }
- elseif buttonId == 1 then
- dragState = nil
- handlePointer(x, y, "terminal")
- pcall(render)
- else
- dragState = nil
- end
- elseif kind == "mouse_drag" and dragState and activeView == "map" then
- local buttonId, x, y = ev[2], ev[3], ev[4]
- if buttonId == dragState.button then
- local dx = x - dragState.x
- local dy = y - dragState.y
- if dx ~= 0 or dy ~= 0 then
- local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
- config.centerX = config.centerX - dx * scale
- config.centerZ = config.centerZ - dy * scale
- config.centerInitialized = true
- dirtyConfig = true
- dragState.x = x
- dragState.y = y
- dragState.moved = true
- pcall(render)
- end
- end
- elseif kind == "mouse_up" then
- if not dragState or ev[2] == dragState.button then
- dragState = nil
- end
- elseif kind == "mouse_scroll" then
- local direction, x, y = ev[2], ev[3], ev[4]
- local layout = getLayout()
- if x <= layout.mapW then
- if activeView == "map" then
- zoomAt(direction > 0 and 1 or -1, x, y, layout.map)
- else
- eventOffset = math.max(0, eventOffset + direction * 3)
- end
- else
- playerListOffset = math.max(0, playerListOffset + direction)
- end
- pcall(render)
- elseif kind == "key" then
- handleKey(ev[2])
- elseif kind == "term_resize" then
- recreateBuffer()
- elseif kind == "monitor_resize" and monitor and ev[2] == monitorName then
- pcall(mirrorBufferToMonitor)
- elseif kind == "peripheral_detach" and monitor and ev[2] == monitorName then
- monitor = nil
- monitorName = nil
- setStatus("monitor detached; terminal still active", 6)
- elseif kind == "player_join" or kind == "player_leave" or
- kind == "player_changed_dimension" or kind == "player_death" then
- pcall(handlePlayerEvent, ev)
- end
- end
- saveConfig()
- nativeTerm.setBackgroundColor(colors.black)
- nativeTerm.setTextColor(colors.white)
- nativeTerm.clear()
- nativeTerm.setCursorPos(1, 1)
- print("Player tracker stopped.")
Advertisement
Add Comment
Please, Sign In to add comment