AndyE7

Untitled

Aug 29th, 2026 (edited)
16
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 56.68 KB | None | 0 0
  1. --[[
  2. Advanced Peripherals Player Tracker
  3. Minecraft 1.21.1 / CC:Tweaked / Advanced Peripherals 0.8
  4.  
  5. Features:
  6. * Live top-down map of every online player reported by Player Detector
  7. * Persistent tracked-player list
  8. * Teleport (> configurable distance), dimension, death, join/leave logging
  9. * 150-block dwell/area tracking with duration and milestones
  10. * Per-player breadcrumb history files
  11. * Clickable event log which jumps the map to the event coordinates
  12. * Hold RIGHT mouse button and drag to pan on the computer terminal
  13. * Mouse wheel zooms around the cursor
  14. * Live mirror to an attached Advanced Monitor; monitor touches select/use buttons
  15. * Optional all-stat snapshot and raw Player Detector snapshot dumps
  16.  
  17. Edit SETTINGS.DEFAULT_TRACKED below if you want names preloaded.
  18. All persistent data is stored under /player_tracker.
  19. ]]
  20.  
  21. local SETTINGS = {
  22. -- Display
  23. -- Mouse events only exist on the computer terminal. The attached monitor is
  24. -- therefore a live mirror: right-drag/wheel on the computer, and the monitor
  25. -- updates immediately. Monitor touches still work for selection/buttons.
  26. MIRROR_TO_MONITOR = true,
  27. MONITOR_NAME = nil, -- nil = first connected monitor
  28. MONITOR_TEXT_SCALE = 0.5,
  29.  
  30. -- Tracking
  31. POLL_SECONDS = 0.50,
  32. RENDER_SECONDS = 0.20,
  33. TELEPORT_DISTANCE = 50,
  34. DWELL_RADIUS = 150,
  35. DWELL_MIN_LOG_SECONDS = 60,
  36. DWELL_MILESTONES = { 600, 1800, 3600 }, -- 10m, 30m, 1h
  37.  
  38. -- Breadcrumb storage for tracked players
  39. HISTORY_SAMPLE_SECONDS = 15,
  40. HISTORY_MIN_MOVE = 5,
  41. HISTORY_FORCE_SAMPLE_SECONDS = 120,
  42. HISTORY_MAX_BYTES_PER_PLAYER = 50000,
  43. MIN_FREE_FOR_HISTORY_BYTES = 120000,
  44.  
  45. -- Event storage/cache
  46. EVENT_MAX_FILE_BYTES = 220000,
  47. EVENT_CACHE_LIMIT = 700,
  48.  
  49. -- Map scales are blocks per character cell
  50. ZOOM_LEVELS = { 1, 2, 4, 8, 16, 32, 64, 128, 256, 512 },
  51. DEFAULT_ZOOM_INDEX = 5,
  52.  
  53. -- Names here are merged into the persistent tracked list at startup.
  54. DEFAULT_TRACKED = {
  55. -- "PlayerName",
  56. },
  57.  
  58. -- Optional: taking every stat can be relatively expensive, so this is manual
  59. -- by default (press S with a selected player).
  60. CAPTURE_STATS_ON_MAJOR_EVENT = false,
  61. }
  62.  
  63. local DATA_DIR = "/player_tracker"
  64. local CONFIG_FILE = DATA_DIR .. "/config.json"
  65. local EVENT_FILE = DATA_DIR .. "/events.jsonl"
  66. local HISTORY_DIR = DATA_DIR .. "/history"
  67. local STATS_DIR = DATA_DIR .. "/stats"
  68. local RAW_DIR = DATA_DIR .. "/raw"
  69.  
  70. local function ensureDir(path)
  71. if not fs.exists(path) then fs.makeDir(path) end
  72. end
  73.  
  74. ensureDir(DATA_DIR)
  75. ensureDir(HISTORY_DIR)
  76. ensureDir(STATS_DIR)
  77. ensureDir(RAW_DIR)
  78.  
  79. local detector = peripheral.find("player_detector")
  80. if not detector then
  81. error("No Advanced Peripherals Player Detector is connected")
  82. end
  83.  
  84. local monitor, monitorName
  85. if SETTINGS.MIRROR_TO_MONITOR then
  86. if SETTINGS.MONITOR_NAME then
  87. if peripheral.isPresent(SETTINGS.MONITOR_NAME) and peripheral.getType(SETTINGS.MONITOR_NAME) == "monitor" then
  88. monitorName = SETTINGS.MONITOR_NAME
  89. monitor = peripheral.wrap(monitorName)
  90. end
  91. else
  92. for _, name in ipairs(peripheral.getNames()) do
  93. if peripheral.getType(name) == "monitor" then
  94. monitorName = name
  95. monitor = peripheral.wrap(name)
  96. break
  97. end
  98. end
  99. end
  100. end
  101.  
  102. -- Always render the interactive UI to the computer terminal. CC:Tweaked only
  103. -- emits mouse_click/mouse_drag/mouse_scroll for this terminal, not monitors.
  104. -- The rendered terminal buffer is copied to the monitor after every frame.
  105. local nativeTerm = term.current()
  106. local screen = nativeTerm
  107. if monitor and monitor.setTextScale then
  108. pcall(monitor.setTextScale, SETTINGS.MONITOR_TEXT_SCALE)
  109. end
  110.  
  111. local function nowMs()
  112. return os.epoch("utc")
  113. end
  114.  
  115. local function safeName(name)
  116. return tostring(name or "unknown"):gsub("[^%w_%-.]", "_")
  117. end
  118.  
  119. local function round(v)
  120. if v >= 0 then return math.floor(v + 0.5) end
  121. return math.ceil(v - 0.5)
  122. end
  123.  
  124. local function clamp(v, lo, hi)
  125. if v < lo then return lo end
  126. if v > hi then return hi end
  127. return v
  128. end
  129.  
  130. local function distance3(a, b)
  131. local dx = (a.x or 0) - (b.x or 0)
  132. local dy = (a.y or 0) - (b.y or 0)
  133. local dz = (a.z or 0) - (b.z or 0)
  134. return math.sqrt(dx * dx + dy * dy + dz * dz)
  135. end
  136.  
  137. local function distance2(a, b)
  138. local dx = (a.x or 0) - (b.x or 0)
  139. local dz = (a.z or 0) - (b.z or 0)
  140. return math.sqrt(dx * dx + dz * dz)
  141. end
  142.  
  143. local function formatDuration(sec)
  144. sec = math.max(0, math.floor(sec or 0))
  145. local h = math.floor(sec / 3600)
  146. local m = math.floor((sec % 3600) / 60)
  147. local s = sec % 60
  148. if h > 0 then return string.format("%dh %02dm", h, m) end
  149. if m > 0 then return string.format("%dm %02ds", m, s) end
  150. return string.format("%ds", s)
  151. end
  152.  
  153. local function shortDim(dim)
  154. dim = tostring(dim or "?")
  155. local last = dim:match("([^:]+)$")
  156. return last or dim
  157. end
  158.  
  159. local function fit(text, width)
  160. text = tostring(text or "")
  161. if width <= 0 then return "" end
  162. if #text <= width then return text end
  163. if width <= 2 then return text:sub(1, width) end
  164. return text:sub(1, width - 1) .. "~"
  165. end
  166.  
  167. local function jsonEncode(v)
  168. local ok, out = pcall(textutils.serializeJSON, v)
  169. if ok then return out end
  170. return nil
  171. end
  172.  
  173. local function jsonDecode(s)
  174. local ok, out = pcall(textutils.unserializeJSON, s)
  175. if ok then return out end
  176. return nil
  177. end
  178.  
  179. local function safeCopy(value, depth, seen)
  180. depth = depth or 0
  181. seen = seen or {}
  182. if depth > 5 then return "<depth>" end
  183.  
  184. local t = type(value)
  185. if t == "nil" or t == "number" or t == "string" or t == "boolean" then
  186. return value
  187. elseif t ~= "table" then
  188. return tostring(value)
  189. end
  190.  
  191. if seen[value] then return "<cycle>" end
  192. seen[value] = true
  193.  
  194. local out = {}
  195. for k, v in pairs(value) do
  196. local kt = type(k)
  197. if kt == "string" or kt == "number" then
  198. out[k] = safeCopy(v, depth + 1, seen)
  199. end
  200. end
  201.  
  202. seen[value] = nil
  203. return out
  204. end
  205.  
  206. local function appendLine(path, line)
  207. local ok, result = pcall(function()
  208. local h = fs.open(path, "a")
  209. if not h then return false end
  210. h.writeLine(line)
  211. h.close()
  212. return true
  213. end)
  214. return ok and result == true
  215. end
  216.  
  217. local function rewriteTail(path, maxBytes)
  218. if not fs.exists(path) then return end
  219. local size = fs.getSize(path)
  220. if type(size) ~= "number" or size <= maxBytes then return end
  221.  
  222. local h = fs.open(path, "r")
  223. if not h then return end
  224. local data = h.readAll() or ""
  225. h.close()
  226.  
  227. local keep = math.floor(maxBytes * 0.55)
  228. local start = math.max(1, #data - keep)
  229. local nl = data:find("\n", start, true)
  230. if nl then start = nl + 1 end
  231. local tail = data:sub(start)
  232.  
  233. local tmp = path .. ".tmp"
  234. local out = fs.open(tmp, "w")
  235. if not out then return end
  236. out.write(tail)
  237. out.close()
  238. if fs.exists(path) then fs.delete(path) end
  239. fs.move(tmp, path)
  240. end
  241.  
  242. local config = {
  243. tracked = {},
  244. centerX = 0,
  245. centerZ = 0,
  246. activeDim = nil,
  247. zoomIndex = SETTINGS.DEFAULT_ZOOM_INDEX,
  248. centerInitialized = false,
  249. }
  250.  
  251. local function loadConfig()
  252. if not fs.exists(CONFIG_FILE) then return end
  253. local h = fs.open(CONFIG_FILE, "r")
  254. if not h then return end
  255. local raw = h.readAll()
  256. h.close()
  257. local data = jsonDecode(raw or "")
  258. if type(data) ~= "table" then return end
  259.  
  260. if type(data.tracked) == "table" then config.tracked = data.tracked end
  261. if type(data.centerX) == "number" then config.centerX = data.centerX end
  262. if type(data.centerZ) == "number" then config.centerZ = data.centerZ end
  263. if type(data.activeDim) == "string" then config.activeDim = data.activeDim end
  264. if type(data.zoomIndex) == "number" then
  265. config.zoomIndex = clamp(math.floor(data.zoomIndex), 1, #SETTINGS.ZOOM_LEVELS)
  266. end
  267. if data.centerInitialized == true then config.centerInitialized = true end
  268. end
  269.  
  270. local function saveConfig()
  271. local payload = {
  272. tracked = config.tracked,
  273. centerX = config.centerX,
  274. centerZ = config.centerZ,
  275. activeDim = config.activeDim,
  276. zoomIndex = config.zoomIndex,
  277. centerInitialized = config.centerInitialized,
  278. }
  279. local encoded = jsonEncode(payload)
  280. if not encoded then return end
  281.  
  282. local tmp = CONFIG_FILE .. ".tmp"
  283. local h = fs.open(tmp, "w")
  284. if not h then return end
  285. h.write(encoded)
  286. h.close()
  287. if fs.exists(CONFIG_FILE) then fs.delete(CONFIG_FILE) end
  288. fs.move(tmp, CONFIG_FILE)
  289. end
  290.  
  291. loadConfig()
  292. for _, name in ipairs(SETTINGS.DEFAULT_TRACKED) do
  293. config.tracked[tostring(name):lower()] = tostring(name)
  294. end
  295. saveConfig()
  296.  
  297. local events = {}
  298.  
  299. local function loadEvents()
  300. if not fs.exists(EVENT_FILE) then return end
  301. local h = fs.open(EVENT_FILE, "r")
  302. if not h then return end
  303. while true do
  304. local line = h.readLine()
  305. if not line then break end
  306. local ev = jsonDecode(line)
  307. if type(ev) == "table" then
  308. events[#events + 1] = ev
  309. if #events > SETTINGS.EVENT_CACHE_LIMIT then table.remove(events, 1) end
  310. end
  311. end
  312. h.close()
  313. end
  314.  
  315. loadEvents()
  316.  
  317. local statusText = "starting"
  318. local statusUntil = 0
  319. local function setStatus(text, seconds)
  320. statusText = tostring(text or "")
  321. statusUntil = nowMs() + math.floor((seconds or 4) * 1000)
  322. end
  323.  
  324. local function eventPosition(ev)
  325. if type(ev.toX) == "number" then
  326. return ev.toX, ev.toY, ev.toZ, ev.toDim or ev.dim
  327. end
  328. if type(ev.x) == "number" then
  329. return ev.x, ev.y, ev.z, ev.dim
  330. end
  331. if type(ev.anchorX) == "number" then
  332. return ev.anchorX, ev.anchorY, ev.anchorZ, ev.dim
  333. end
  334. return nil, nil, nil, ev.dim
  335. end
  336.  
  337. local function logEvent(kind, name, fields, snapshot)
  338. local ev = {
  339. ts = nowMs(),
  340. type = kind,
  341. player = name,
  342. }
  343. if type(fields) == "table" then
  344. for k, v in pairs(fields) do ev[k] = v end
  345. end
  346. if snapshot then ev.snapshot = safeCopy(snapshot) end
  347.  
  348. local encoded = jsonEncode(ev)
  349. if encoded then
  350. appendLine(EVENT_FILE, encoded)
  351. rewriteTail(EVENT_FILE, SETTINGS.EVENT_MAX_FILE_BYTES)
  352. end
  353.  
  354. events[#events + 1] = ev
  355. if #events > SETTINGS.EVENT_CACHE_LIMIT then table.remove(events, 1) end
  356. end
  357.  
  358. local players = {}
  359. local trackerState = {}
  360. local trails = {}
  361. local lastHistory = {}
  362. local pendingJoin = {}
  363. local pendingDimension = {}
  364. local selectedPlayer = nil
  365. local activeView = "map"
  366. local eventOffset = 0
  367. local playerListOffset = 0
  368. local dirtyConfig = false
  369. local running = true
  370. local dragState = nil
  371. local lastPollMs = nowMs()
  372. local lastRenderMs = 0
  373.  
  374. local function isTracked(name)
  375. return config.tracked[tostring(name):lower()] ~= nil
  376. end
  377.  
  378. local function canonicalTrackedName(name)
  379. return config.tracked[tostring(name):lower()]
  380. end
  381.  
  382. local function setTracked(name, yes)
  383. if not name or name == "" then return end
  384. local key = tostring(name):lower()
  385. if yes then
  386. config.tracked[key] = tostring(name)
  387. trackerState[key] = trackerState[key] or {}
  388. trails[key] = trails[key] or {}
  389. logEvent("track_on", tostring(name), {})
  390. setStatus("tracking " .. tostring(name))
  391. else
  392. local old = config.tracked[key] or tostring(name)
  393. config.tracked[key] = nil
  394. logEvent("track_off", old, {})
  395. setStatus("stopped tracking " .. old)
  396. end
  397. dirtyConfig = true
  398. saveConfig()
  399. end
  400.  
  401. local function toggleTracked(name)
  402. setTracked(name, not isTracked(name))
  403. end
  404.  
  405. local function copyPosition(p, ts)
  406. return {
  407. x = p.x,
  408. y = p.y,
  409. z = p.z,
  410. dimension = p.dimension,
  411. yaw = p.yaw,
  412. pitch = p.pitch,
  413. health = p.health,
  414. maxHealth = p.maxHealth,
  415. airSupply = p.airSupply,
  416. respawnPosition = safeCopy(p.respawnPosition),
  417. respawnDimension = safeCopy(p.respawnDimension),
  418. respawnAngle = safeCopy(p.respawnAngle),
  419. ts = ts or nowMs(),
  420. }
  421. end
  422.  
  423. local function respawnFingerprint(p)
  424. if p.respawnPosition == nil and p.respawnDimension == nil and p.respawnAngle == nil then
  425. return nil
  426. end
  427. return jsonEncode({
  428. pos = safeCopy(p.respawnPosition),
  429. dim = safeCopy(p.respawnDimension),
  430. angle = safeCopy(p.respawnAngle),
  431. })
  432. end
  433.  
  434. local function appendBreadcrumb(name, p, ts)
  435. local okFree, free = pcall(fs.getFreeSpace, "/")
  436. if okFree and type(free) == "number" and free < SETTINGS.MIN_FREE_FOR_HISTORY_BYTES then
  437. return
  438. end
  439.  
  440. local key = tostring(name):lower()
  441. local last = lastHistory[key]
  442. local shouldWrite = false
  443. if not last then
  444. shouldWrite = true
  445. else
  446. local age = (ts - last.ts) / 1000
  447. local moved = distance3(last, p)
  448. if age >= SETTINGS.HISTORY_FORCE_SAMPLE_SECONDS then
  449. shouldWrite = true
  450. elseif age >= SETTINGS.HISTORY_SAMPLE_SECONDS and moved >= SETTINGS.HISTORY_MIN_MOVE then
  451. shouldWrite = true
  452. end
  453. end
  454.  
  455. if not shouldWrite then return end
  456.  
  457. local path = HISTORY_DIR .. "/" .. safeName(name) .. ".csv"
  458. if not fs.exists(path) then
  459. appendLine(path, "timestamp_ms,dimension,x,y,z,yaw,pitch,health,max_health,air")
  460. end
  461.  
  462. local line = string.format(
  463. "%d,%s,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f",
  464. ts,
  465. tostring(p.dimension or "?"):gsub(",", "_"),
  466. tonumber(p.x) or 0,
  467. tonumber(p.y) or 0,
  468. tonumber(p.z) or 0,
  469. tonumber(p.yaw) or 0,
  470. tonumber(p.pitch) or 0,
  471. tonumber(p.health) or 0,
  472. tonumber(p.maxHealth) or 0,
  473. tonumber(p.airSupply) or 0
  474. )
  475. appendLine(path, line)
  476. rewriteTail(path, SETTINGS.HISTORY_MAX_BYTES_PER_PLAYER)
  477. lastHistory[key] = copyPosition(p, ts)
  478. end
  479.  
  480. local function addTrailPoint(name, p, ts)
  481. local key = tostring(name):lower()
  482. local trail = trails[key] or {}
  483. trails[key] = trail
  484. local last = trail[#trail]
  485. if last and last.dimension == p.dimension and distance2(last, p) < 2 and (ts - last.ts) < 5000 then
  486. return
  487. end
  488. trail[#trail + 1] = {
  489. x = p.x, y = p.y, z = p.z,
  490. dimension = p.dimension,
  491. ts = ts,
  492. }
  493. while #trail > 220 do table.remove(trail, 1) end
  494. end
  495.  
  496. local function startArea(state, p, ts, reason)
  497. state.area = {
  498. x = p.x,
  499. y = p.y,
  500. z = p.z,
  501. dimension = p.dimension,
  502. startMs = ts,
  503. lastMs = ts,
  504. reason = reason or "movement",
  505. milestones = {},
  506. }
  507. end
  508.  
  509. local function closeArea(name, state, ts, reason, p)
  510. local area = state.area
  511. if not area then return end
  512. local endMs = area.lastMs or ts
  513. local duration = math.max(0, (endMs - area.startMs) / 1000)
  514.  
  515. if duration >= SETTINGS.DWELL_MIN_LOG_SECONDS then
  516. logEvent("area_stay", name, {
  517. anchorX = area.x,
  518. anchorY = area.y,
  519. anchorZ = area.z,
  520. x = p and p.x or state.last and state.last.x,
  521. y = p and p.y or state.last and state.last.y,
  522. z = p and p.z or state.last and state.last.z,
  523. dim = area.dimension,
  524. duration = duration,
  525. radius = SETTINGS.DWELL_RADIUS,
  526. reason = reason,
  527. startedBy = area.reason,
  528. }, p)
  529. end
  530. state.area = nil
  531. end
  532.  
  533. local function updateArea(name, state, p, ts)
  534. local area = state.area
  535. if not area then
  536. startArea(state, p, ts, "movement")
  537. return
  538. end
  539.  
  540. if area.dimension ~= p.dimension then
  541. closeArea(name, state, ts, "dimension", p)
  542. startArea(state, p, ts, "dimension")
  543. return
  544. end
  545.  
  546. if distance2(area, p) > SETTINGS.DWELL_RADIUS then
  547. closeArea(name, state, ts, "left_radius", p)
  548. startArea(state, p, ts, "movement")
  549. return
  550. end
  551.  
  552. area.lastMs = ts
  553. local duration = (ts - area.startMs) / 1000
  554. for _, milestone in ipairs(SETTINGS.DWELL_MILESTONES) do
  555. if duration >= milestone and not area.milestones[tostring(milestone)] then
  556. area.milestones[tostring(milestone)] = true
  557. logEvent("dwell", name, {
  558. anchorX = area.x,
  559. anchorY = area.y,
  560. anchorZ = area.z,
  561. x = p.x,
  562. y = p.y,
  563. z = p.z,
  564. dim = p.dimension,
  565. duration = duration,
  566. radius = SETTINGS.DWELL_RADIUS,
  567. milestone = milestone,
  568. }, p)
  569. end
  570. end
  571. end
  572.  
  573. local function captureStats(name)
  574. if not name then
  575. setStatus("select a player first")
  576. return false
  577. end
  578. if not advancedperipherals or not advancedperipherals.iterPlayerStatKeys then
  579. setStatus("Advanced Peripherals stat iterator unavailable", 6)
  580. return false
  581. end
  582.  
  583. setStatus("capturing stats for " .. name, 10)
  584. local ids = {}
  585. for id in advancedperipherals.iterPlayerStatKeys() do
  586. ids[#ids + 1] = id
  587. end
  588.  
  589. local result = {
  590. ts = nowMs(),
  591. player = name,
  592. stats = {},
  593. }
  594.  
  595. local batchSize = 16
  596. local i = 1
  597. while i <= #ids do
  598. local batch = {}
  599. for j = i, math.min(i + batchSize - 1, #ids) do
  600. batch[#batch + 1] = ids[j]
  601. end
  602. local packed = { pcall(detector.getPlayerStat, name, table.unpack(batch)) }
  603. if packed[1] then
  604. for j = 1, #batch do
  605. result.stats[batch[j]] = packed[j + 1]
  606. end
  607. else
  608. result.errors = result.errors or {}
  609. result.errors[#result.errors + 1] = tostring(packed[2])
  610. end
  611. i = i + batchSize
  612. end
  613.  
  614. local encoded = jsonEncode(result)
  615. if encoded then
  616. local path = STATS_DIR .. "/" .. safeName(name) .. ".jsonl"
  617. appendLine(path, encoded)
  618. rewriteTail(path, 160000)
  619. logEvent("stats_snapshot", name, { count = #ids })
  620. setStatus("saved " .. tostring(#ids) .. " stats for " .. name, 6)
  621. return true
  622. end
  623.  
  624. setStatus("failed to encode stat snapshot", 6)
  625. return false
  626. end
  627.  
  628. local function dumpRaw(name)
  629. if not name then
  630. setStatus("select a player first")
  631. return
  632. end
  633. local p = players[tostring(name):lower()]
  634. if not p then
  635. local ok, fetched = pcall(detector.getPlayer, name)
  636. if ok then p = fetched end
  637. end
  638. if type(p) ~= "table" then
  639. setStatus("player data unavailable")
  640. return
  641. end
  642.  
  643. local data = safeCopy(p)
  644. data._dumpedAt = nowMs()
  645. local encoded = jsonEncode(data)
  646. if not encoded then
  647. setStatus("raw snapshot could not be encoded")
  648. return
  649. end
  650.  
  651. local path = RAW_DIR .. "/" .. safeName(name) .. "_" .. tostring(nowMs()) .. ".json"
  652. local h = fs.open(path, "w")
  653. if h then
  654. h.write(encoded)
  655. h.close()
  656. setStatus("raw snapshot saved", 5)
  657. end
  658. end
  659.  
  660. local function majorEventStats(name)
  661. if SETTINGS.CAPTURE_STATS_ON_MAJOR_EVENT then
  662. pcall(captureStats, name)
  663. end
  664. end
  665.  
  666. local function processTrackedPlayer(name, p, ts)
  667. local key = tostring(name):lower()
  668. local state = trackerState[key] or {}
  669. trackerState[key] = state
  670.  
  671. local firstThisSession = not state.online
  672. if firstThisSession then
  673. state.online = true
  674. state.last = copyPosition(p, ts)
  675. state.respawnFingerprint = respawnFingerprint(p)
  676. startArea(state, p, ts, pendingJoin[key] and "join" or "seen")
  677. addTrailPoint(name, p, ts)
  678. appendBreadcrumb(name, p, ts)
  679.  
  680. local join = pendingJoin[key]
  681. if join then
  682. logEvent("join", name, {
  683. x = p.x, y = p.y, z = p.z,
  684. dim = p.dimension or join.dimension,
  685. }, p)
  686. pendingJoin[key] = nil
  687. else
  688. logEvent("seen", name, {
  689. x = p.x, y = p.y, z = p.z,
  690. dim = p.dimension,
  691. }, p)
  692. end
  693. return
  694. end
  695.  
  696. local last = state.last
  697. if last then
  698. local dt = math.max(0.001, (ts - (last.ts or ts)) / 1000)
  699. local moved = distance3(last, p)
  700. p._speed = moved / dt
  701.  
  702. if last.dimension ~= p.dimension then
  703. closeArea(name, state, ts, "dimension", p)
  704. local recentDeath = state.lastDeathMs and (ts - state.lastDeathMs) <= 15000
  705. local pending = pendingDimension[key]
  706. local kind = recentDeath and "respawn_dimension" or "dimension"
  707. logEvent(kind, name, {
  708. fromDim = pending and pending.fromDim or last.dimension,
  709. toDim = pending and pending.toDim or p.dimension,
  710. fromX = last.x, fromY = last.y, fromZ = last.z,
  711. toX = p.x, toY = p.y, toZ = p.z,
  712. dim = p.dimension,
  713. }, p)
  714. pendingDimension[key] = nil
  715. startArea(state, p, ts, kind)
  716. majorEventStats(name)
  717. elseif moved >= SETTINGS.TELEPORT_DISTANCE then
  718. closeArea(name, state, ts, "teleport", p)
  719. local recentDeath = state.lastDeathMs and (ts - state.lastDeathMs) <= 15000
  720. local kind = recentDeath and "respawn" or "teleport"
  721. logEvent(kind, name, {
  722. fromX = last.x, fromY = last.y, fromZ = last.z,
  723. toX = p.x, toY = p.y, toZ = p.z,
  724. dim = p.dimension,
  725. distance = moved,
  726. seconds = dt,
  727. }, p)
  728. startArea(state, p, ts, kind)
  729. majorEventStats(name)
  730. else
  731. updateArea(name, state, p, ts)
  732. end
  733. else
  734. startArea(state, p, ts, "seen")
  735. end
  736.  
  737. local fp = respawnFingerprint(p)
  738. if fp and state.respawnFingerprint and fp ~= state.respawnFingerprint then
  739. logEvent("respawn_change", name, {
  740. x = p.x, y = p.y, z = p.z,
  741. dim = p.dimension,
  742. respawnPosition = safeCopy(p.respawnPosition),
  743. respawnDimension = safeCopy(p.respawnDimension),
  744. respawnAngle = safeCopy(p.respawnAngle),
  745. }, p)
  746. end
  747. if fp then state.respawnFingerprint = fp end
  748.  
  749. addTrailPoint(name, p, ts)
  750. appendBreadcrumb(name, p, ts)
  751. state.last = copyPosition(p, ts)
  752. state.online = true
  753. end
  754.  
  755. local function processOfflineTracked(name, ts, reason)
  756. local key = tostring(name):lower()
  757. local state = trackerState[key]
  758. if not state or not state.online then return end
  759. closeArea(name, state, ts, reason or "offline", state.last)
  760. if reason ~= "event_leave" then
  761. local last = state.last
  762. logEvent("leave", name, {
  763. x = last and last.x,
  764. y = last and last.y,
  765. z = last and last.z,
  766. dim = last and last.dimension,
  767. })
  768. end
  769. state.online = false
  770. end
  771.  
  772. local function normalizeOnlineEntry(v)
  773. if type(v) == "string" then return v end
  774. if type(v) == "table" then return v.name or v.username or v.uuid end
  775. if v ~= nil then return tostring(v) end
  776. return nil
  777. end
  778.  
  779. local function pollPlayers()
  780. local ts = nowMs()
  781. local ok, list = pcall(detector.getOnlinePlayers)
  782. if not ok or type(list) ~= "table" then
  783. setStatus("Player Detector getOnlinePlayers failed: " .. tostring(list), 6)
  784. return
  785. end
  786.  
  787. local fresh = {}
  788. local seenOnline = {}
  789.  
  790. for _, entry in pairs(list) do
  791. local name = normalizeOnlineEntry(entry)
  792. if name and name ~= "" then
  793. local pok, p = pcall(detector.getPlayer, name)
  794. if pok and type(p) == "table" and type(p.x) == "number" and type(p.z) == "number" then
  795. local canonical = tostring(p.name or name)
  796. local key = canonical:lower()
  797. p.name = canonical
  798. fresh[key] = p
  799. seenOnline[key] = canonical
  800. if isTracked(canonical) then processTrackedPlayer(canonical, p, ts) end
  801. end
  802. end
  803. end
  804.  
  805. for key, trackedName in pairs(config.tracked) do
  806. if not seenOnline[key] then
  807. processOfflineTracked(trackedName, ts, "poll_offline")
  808. end
  809. end
  810.  
  811. players = fresh
  812. lastPollMs = ts
  813.  
  814. if not config.centerInitialized then
  815. local sumX, sumZ, count = 0, 0, 0
  816. local chosenDim = config.activeDim
  817. if not chosenDim then
  818. for _, p in pairs(players) do
  819. chosenDim = p.dimension
  820. break
  821. end
  822. end
  823. if chosenDim then
  824. for _, p in pairs(players) do
  825. if p.dimension == chosenDim then
  826. sumX = sumX + p.x
  827. sumZ = sumZ + p.z
  828. count = count + 1
  829. end
  830. end
  831. end
  832. if count > 0 then
  833. config.centerX = sumX / count
  834. config.centerZ = sumZ / count
  835. config.activeDim = chosenDim
  836. config.centerInitialized = true
  837. dirtyConfig = true
  838. end
  839. end
  840. end
  841.  
  842. local function getPlayer(name)
  843. if not name then return nil end
  844. return players[tostring(name):lower()]
  845. end
  846.  
  847. local function playerNamesForSidebar()
  848. local seen = {}
  849. local out = {}
  850.  
  851. for key, name in pairs(config.tracked) do
  852. local p = players[key]
  853. out[#out + 1] = { name = p and p.name or name, online = p ~= nil, tracked = true }
  854. seen[key] = true
  855. end
  856. for key, p in pairs(players) do
  857. if not seen[key] then
  858. out[#out + 1] = { name = p.name or key, online = true, tracked = false }
  859. end
  860. end
  861.  
  862. table.sort(out, function(a, b)
  863. if a.online ~= b.online then return a.online end
  864. if a.tracked ~= b.tracked then return a.tracked end
  865. return a.name:lower() < b.name:lower()
  866. end)
  867. return out
  868. end
  869.  
  870. local function getDimensions()
  871. local set = {}
  872. local out = {}
  873. if config.activeDim then set[config.activeDim] = true end
  874. for _, p in pairs(players) do if p.dimension then set[p.dimension] = true end end
  875. for i = math.max(1, #events - 100), #events do
  876. local ev = events[i]
  877. if ev then
  878. if ev.dim then set[ev.dim] = true end
  879. if ev.toDim then set[ev.toDim] = true end
  880. if ev.fromDim then set[ev.fromDim] = true end
  881. end
  882. end
  883. for dim in pairs(set) do out[#out + 1] = dim end
  884. table.sort(out)
  885. return out
  886. end
  887.  
  888. local function cycleDimension(direction)
  889. local dims = getDimensions()
  890. if #dims == 0 then return end
  891. local current = 1
  892. for i, d in ipairs(dims) do
  893. if d == config.activeDim then current = i break end
  894. end
  895. current = current + (direction or 1)
  896. if current > #dims then current = 1 end
  897. if current < 1 then current = #dims end
  898. config.activeDim = dims[current]
  899. dirtyConfig = true
  900. saveConfig()
  901. end
  902.  
  903. local function centerOnSelected()
  904. local p = getPlayer(selectedPlayer)
  905. if p then
  906. config.centerX = p.x
  907. config.centerZ = p.z
  908. config.activeDim = p.dimension
  909. config.centerInitialized = true
  910. dirtyConfig = true
  911. saveConfig()
  912. return
  913. end
  914.  
  915. local sumX, sumZ, count = 0, 0, 0
  916. for _, pl in pairs(players) do
  917. if not config.activeDim or pl.dimension == config.activeDim then
  918. sumX = sumX + pl.x
  919. sumZ = sumZ + pl.z
  920. count = count + 1
  921. end
  922. end
  923. if count > 0 then
  924. config.centerX = sumX / count
  925. config.centerZ = sumZ / count
  926. config.centerInitialized = true
  927. dirtyConfig = true
  928. saveConfig()
  929. end
  930. end
  931.  
  932. local function zoom(delta)
  933. config.zoomIndex = clamp(config.zoomIndex + delta, 1, #SETTINGS.ZOOM_LEVELS)
  934. dirtyConfig = true
  935. end
  936.  
  937. local function zoomAt(delta, sx, sy, mapRect)
  938. local oldScale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  939. local oldIndex = config.zoomIndex
  940. config.zoomIndex = clamp(config.zoomIndex + delta, 1, #SETTINGS.ZOOM_LEVELS)
  941. if config.zoomIndex == oldIndex then return end
  942. local newScale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  943.  
  944. if mapRect and sx and sy then
  945. local cx = (mapRect.x1 + mapRect.x2) / 2
  946. local cy = (mapRect.y1 + mapRect.y2) / 2
  947. local worldX = config.centerX + (sx - cx) * oldScale
  948. local worldZ = config.centerZ + (sy - cy) * oldScale
  949. config.centerX = worldX - (sx - cx) * newScale
  950. config.centerZ = worldZ - (sy - cy) * newScale
  951. end
  952. dirtyConfig = true
  953. end
  954.  
  955. local function facingArrow(yaw)
  956. yaw = tonumber(yaw)
  957. if not yaw then return "?" end
  958. yaw = ((yaw % 360) + 360) % 360
  959. if yaw >= 315 or yaw < 45 then return "v" end -- +Z south
  960. if yaw < 135 then return "<" end -- west
  961. if yaw < 225 then return "^" end -- north
  962. return ">" -- east
  963. end
  964.  
  965. local palette = {
  966. colors.red, colors.orange, colors.yellow, colors.lime,
  967. colors.cyan, colors.lightBlue, colors.blue, colors.purple,
  968. colors.magenta, colors.pink, colors.white,
  969. }
  970.  
  971. local function nameColor(name)
  972. local hash = 0
  973. name = tostring(name or "")
  974. for i = 1, #name do hash = (hash * 33 + name:byte(i)) % 2147483647 end
  975. return palette[(hash % #palette) + 1]
  976. end
  977.  
  978. local buffer
  979. local screenW, screenH = 0, 0
  980. local hitboxes = {}
  981. local mapRect = nil
  982.  
  983. local function recreateBuffer()
  984. local w, h = screen.getSize()
  985. screenW, screenH = w, h
  986. buffer = window.create(screen, 1, 1, w, h, false)
  987. end
  988.  
  989. recreateBuffer()
  990.  
  991. local function mirrorBufferToMonitor()
  992. if not monitor or not buffer then return end
  993.  
  994. local ok = pcall(function()
  995. local mw, mh = monitor.getSize()
  996. monitor.setBackgroundColor(colors.black)
  997. monitor.setTextColor(colors.white)
  998. monitor.clear()
  999.  
  1000. local copyH = math.min(screenH, mh)
  1001. local copyW = math.min(screenW, mw)
  1002.  
  1003. for y = 1, copyH do
  1004. local text, fg, bg = buffer.getLine(y)
  1005. if text and copyW > 0 then
  1006. text = text:sub(1, copyW)
  1007. fg = fg:sub(1, copyW)
  1008. bg = bg:sub(1, copyW)
  1009. monitor.setCursorPos(1, y)
  1010. monitor.blit(text, fg, bg)
  1011. end
  1012. end
  1013. end)
  1014.  
  1015. if not ok then
  1016. -- Do not let a detached/broken monitor kill the tracker.
  1017. monitor = nil
  1018. monitorName = nil
  1019. end
  1020. end
  1021.  
  1022. local function setColors(fg, bg)
  1023. if bg then buffer.setBackgroundColor(bg) end
  1024. if fg then buffer.setTextColor(fg) end
  1025. end
  1026.  
  1027. local function writeAt(x, y, text, fg, bg)
  1028. if y < 1 or y > screenH then return end
  1029. text = tostring(text or "")
  1030. if x > screenW or x + #text - 1 < 1 then return end
  1031. if x < 1 then
  1032. text = text:sub(2 - x)
  1033. x = 1
  1034. end
  1035. if x + #text - 1 > screenW then
  1036. text = text:sub(1, screenW - x + 1)
  1037. end
  1038. if #text <= 0 then return end
  1039. setColors(fg or colors.white, bg or colors.black)
  1040. buffer.setCursorPos(x, y)
  1041. buffer.write(text)
  1042. end
  1043.  
  1044. local function fillRect(x1, y1, x2, y2, bg, ch, fg)
  1045. x1 = clamp(math.floor(x1), 1, screenW)
  1046. x2 = clamp(math.floor(x2), 1, screenW)
  1047. y1 = clamp(math.floor(y1), 1, screenH)
  1048. y2 = clamp(math.floor(y2), 1, screenH)
  1049. if x2 < x1 or y2 < y1 then return end
  1050. ch = ch or " "
  1051. local row = string.rep(ch, x2 - x1 + 1)
  1052. for y = y1, y2 do writeAt(x1, y, row, fg or colors.white, bg) end
  1053. end
  1054.  
  1055. local function addHit(x1, y1, x2, y2, action, data)
  1056. hitboxes[#hitboxes + 1] = {
  1057. x1 = x1, y1 = y1, x2 = x2, y2 = y2,
  1058. action = action, data = data,
  1059. }
  1060. end
  1061.  
  1062. local function button(x1, y, width, label, action, data, active)
  1063. if width < 1 then return end
  1064. local bg = active and colors.lightBlue or colors.gray
  1065. local fg = active and colors.black or colors.white
  1066. local text = fit("[" .. label .. "]", width)
  1067. local padded = text .. string.rep(" ", math.max(0, width - #text))
  1068. writeAt(x1, y, padded, fg, bg)
  1069. addHit(x1, y, x1 + width - 1, y, action, data)
  1070. end
  1071.  
  1072. local function getLayout()
  1073. local sideW
  1074. if screenW >= 70 then sideW = 27
  1075. elseif screenW >= 50 then sideW = 23
  1076. else sideW = math.max(18, math.floor(screenW * 0.40)) end
  1077.  
  1078. local mapW = screenW - sideW
  1079. if mapW < 15 then
  1080. sideW = math.max(12, screenW - 15)
  1081. mapW = screenW - sideW
  1082. end
  1083.  
  1084. return {
  1085. sideW = sideW,
  1086. mapW = mapW,
  1087. sideX = mapW + 1,
  1088. map = { x1 = 1, y1 = 3, x2 = mapW, y2 = screenH },
  1089. }
  1090. end
  1091.  
  1092. local function worldToScreen(x, z, rect, scale)
  1093. local cx = (rect.x1 + rect.x2) / 2
  1094. local cy = (rect.y1 + rect.y2) / 2
  1095. local sx = round(cx + (x - config.centerX) / scale)
  1096. local sy = round(cy + (z - config.centerZ) / scale)
  1097. return sx, sy
  1098. end
  1099.  
  1100. local function screenToWorld(sx, sy, rect, scale)
  1101. local cx = (rect.x1 + rect.x2) / 2
  1102. local cy = (rect.y1 + rect.y2) / 2
  1103. local wx = config.centerX + (sx - cx) * scale
  1104. local wz = config.centerZ + (sy - cy) * scale
  1105. return wx, wz
  1106. end
  1107.  
  1108. local function drawChunkGrid(rect, scale)
  1109. if scale > 32 then return end
  1110. local cx = (rect.x1 + rect.x2) / 2
  1111. local cy = (rect.y1 + rect.y2) / 2
  1112.  
  1113. local previousChunk
  1114. for sx = rect.x1, rect.x2 do
  1115. local wx = config.centerX + (sx - cx) * scale
  1116. local chunk = math.floor(wx / 16)
  1117. if previousChunk ~= nil and chunk ~= previousChunk then
  1118. for y = rect.y1, rect.y2 do
  1119. writeAt(sx, y, "|", colors.gray, colors.black)
  1120. end
  1121. end
  1122. previousChunk = chunk
  1123. end
  1124.  
  1125. previousChunk = nil
  1126. for sy = rect.y1, rect.y2 do
  1127. local wz = config.centerZ + (sy - cy) * scale
  1128. local chunk = math.floor(wz / 16)
  1129. if previousChunk ~= nil and chunk ~= previousChunk then
  1130. for x = rect.x1, rect.x2 do
  1131. writeAt(x, sy, "-", colors.gray, colors.black)
  1132. end
  1133. end
  1134. previousChunk = chunk
  1135. end
  1136. end
  1137.  
  1138. local function drawSelectedTrail(rect, scale)
  1139. if not selectedPlayer then return end
  1140. local key = tostring(selectedPlayer):lower()
  1141. local trail = trails[key]
  1142. if not trail then return end
  1143. for _, point in ipairs(trail) do
  1144. if point.dimension == config.activeDim then
  1145. local sx, sy = worldToScreen(point.x, point.z, rect, scale)
  1146. if sx >= rect.x1 and sx <= rect.x2 and sy >= rect.y1 and sy <= rect.y2 then
  1147. writeAt(sx, sy, ".", colors.lightGray, colors.black)
  1148. end
  1149. end
  1150. end
  1151. end
  1152.  
  1153. local function drawMap(layout)
  1154. local rect = layout.map
  1155. mapRect = rect
  1156. local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  1157.  
  1158. -- Deliberately blank map: no chunk grid, world-origin cross, or trail overlay.
  1159. fillRect(rect.x1, rect.y1, rect.x2, rect.y2, colors.black, " ")
  1160.  
  1161. local names = {}
  1162. for _, p in pairs(players) do
  1163. if p.dimension == config.activeDim then names[#names + 1] = p.name end
  1164. end
  1165. table.sort(names, function(a, b) return a:lower() < b:lower() end)
  1166.  
  1167. for _, name in ipairs(names) do
  1168. local p = getPlayer(name)
  1169. if p then
  1170. local sx, sy = worldToScreen(p.x, p.z, rect, scale)
  1171. if sx >= rect.x1 and sx <= rect.x2 and sy >= rect.y1 and sy <= rect.y2 then
  1172. local selected = selectedPlayer and selectedPlayer:lower() == name:lower()
  1173. local tracked = isTracked(name)
  1174. local char = selected and "X" or (tracked and "@" or "o")
  1175. local fg = selected and colors.yellow or nameColor(name)
  1176.  
  1177. -- Player marker.
  1178. writeAt(sx, sy, char, fg, colors.black)
  1179.  
  1180. -- Name above and X,Z below. Clicking any of these selects the player.
  1181. local label = tostring(name)
  1182. local coords = string.format("%.0f,%.0f", p.x, p.z)
  1183.  
  1184. local labelX = sx - math.floor(#label / 2)
  1185. local coordX = sx - math.floor(#coords / 2)
  1186. local y1, y2 = sy, sy
  1187. local x1, x2 = sx, sx
  1188.  
  1189. if sy - 1 >= rect.y1 then
  1190. local maxW = rect.x2 - rect.x1 + 1
  1191. label = fit(label, math.min(#label, maxW))
  1192. labelX = clamp(labelX, rect.x1, math.max(rect.x1, rect.x2 - #label + 1))
  1193. writeAt(labelX, sy - 1, label, fg, colors.black)
  1194. x1 = math.min(x1, labelX)
  1195. x2 = math.max(x2, labelX + #label - 1)
  1196. y1 = sy - 1
  1197. end
  1198.  
  1199. if sy + 1 <= rect.y2 then
  1200. local maxW = rect.x2 - rect.x1 + 1
  1201. coords = fit(coords, math.min(#coords, maxW))
  1202. coordX = clamp(coordX, rect.x1, math.max(rect.x1, rect.x2 - #coords + 1))
  1203. writeAt(coordX, sy + 1, coords, colors.lightGray, colors.black)
  1204. x1 = math.min(x1, coordX)
  1205. x2 = math.max(x2, coordX + #coords - 1)
  1206. y2 = sy + 1
  1207. end
  1208.  
  1209. addHit(x1, y1, x2, y2, "select_player", name)
  1210. end
  1211. end
  1212. end
  1213.  
  1214. local title = string.format(
  1215. " MAP %s %.0f,%.0f %db/c | RIGHT-DRAG pan | WHEEL zoom ",
  1216. shortDim(config.activeDim), config.centerX, config.centerZ, scale
  1217. )
  1218. writeAt(rect.x1, 2, fit(title, layout.mapW), colors.white, colors.black)
  1219. end
  1220.  
  1221. local function eventCode(kind)
  1222. local map = {
  1223. teleport = "TP",
  1224. respawn = "RESP",
  1225. dimension = "DIM",
  1226. respawn_dimension = "R-DIM",
  1227. death = "DEAD",
  1228. join = "JOIN",
  1229. leave = "LEAVE",
  1230. seen = "SEEN",
  1231. dwell = "DWELL",
  1232. area_stay = "STAY",
  1233. respawn_change = "BED",
  1234. stats_snapshot = "STAT",
  1235. track_on = "TRK+",
  1236. track_off = "TRK-",
  1237. }
  1238. return map[kind] or tostring(kind or "?"):upper()
  1239. end
  1240.  
  1241. local function filteredEvents()
  1242. local out = {}
  1243. local filter = selectedPlayer and selectedPlayer:lower() or nil
  1244. for i = #events, 1, -1 do
  1245. local ev = events[i]
  1246. if not filter or (ev.player and tostring(ev.player):lower() == filter) then
  1247. out[#out + 1] = ev
  1248. end
  1249. end
  1250. return out
  1251. end
  1252.  
  1253. local function formatEventLine(ev, width)
  1254. local stamp = "?"
  1255. if type(ev.ts) == "number" then
  1256. local ok, s = pcall(os.date, "!%d/%m %H:%M", ev.ts / 1000)
  1257. if ok then stamp = s end
  1258. end
  1259. local x, y, z = eventPosition(ev)
  1260. local coord = ""
  1261. if x then coord = string.format(" %.0f,%.0f,%.0f", x, y or 0, z or 0) end
  1262. local extra = ""
  1263. if ev.distance then extra = string.format(" %.0fb", ev.distance) end
  1264. if ev.duration then extra = " " .. formatDuration(ev.duration) end
  1265. local line = string.format("%s %-5s %-12s%s%s", stamp, eventCode(ev.type), tostring(ev.player or "?"), coord, extra)
  1266. return fit(line, width)
  1267. end
  1268.  
  1269. local function drawEvents(layout)
  1270. local rect = layout.map
  1271. mapRect = rect
  1272. fillRect(rect.x1, rect.y1, rect.x2, rect.y2, colors.black, " ")
  1273. local filter = selectedPlayer and selectedPlayer or "ALL"
  1274. writeAt(rect.x1, 2, fit(" EVENTS filter: " .. filter, layout.mapW), colors.white, colors.black)
  1275.  
  1276. local list = filteredEvents()
  1277. local row = rect.y1
  1278. local start = 1 + eventOffset
  1279. for i = start, math.min(#list, start + (rect.y2 - rect.y1)) do
  1280. local ev = list[i]
  1281. local fg = colors.white
  1282. if ev.type == "teleport" or ev.type == "respawn" then fg = colors.yellow
  1283. elseif ev.type == "death" then fg = colors.red
  1284. elseif ev.type == "dwell" or ev.type == "area_stay" then fg = colors.lime
  1285. elseif ev.type == "dimension" or ev.type == "respawn_dimension" then fg = colors.cyan
  1286. end
  1287. writeAt(rect.x1, row, formatEventLine(ev, layout.mapW), fg, colors.black)
  1288. addHit(rect.x1, row, rect.x2, row, "event_jump", ev)
  1289. row = row + 1
  1290. if row > rect.y2 then break end
  1291. end
  1292.  
  1293. if #list == 0 then
  1294. writeAt(rect.x1 + 1, rect.y1 + 1, "No matching events", colors.lightGray, colors.black)
  1295. elseif start > #list then
  1296. eventOffset = math.max(0, #list - 1)
  1297. end
  1298. end
  1299.  
  1300. local function freeSpaceText()
  1301. local ok, free = pcall(fs.getFreeSpace, "/")
  1302. if not ok then return "?" end
  1303. if type(free) == "string" then return free end
  1304. if free >= 1000000 then return string.format("%.1fM", free / 1000000) end
  1305. if free >= 1000 then return string.format("%.0fK", free / 1000) end
  1306. return tostring(free)
  1307. end
  1308.  
  1309. local function drawSidebar(layout)
  1310. local x = layout.sideX
  1311. local w = layout.sideW
  1312. fillRect(x, 1, screenW, screenH, colors.gray, " ")
  1313.  
  1314. local onlineCount = 0
  1315. for _ in pairs(players) do onlineCount = onlineCount + 1 end
  1316. writeAt(x + 1, 1, fit("PLAYER TRACKER", w - 2), colors.yellow, colors.gray)
  1317. writeAt(x + 1, 2, fit("Online " .. onlineCount .. " Free " .. freeSpaceText(), w - 2), colors.white, colors.gray)
  1318.  
  1319. local controlTop = math.max(10, screenH - 7)
  1320. local detailsRows = 4
  1321. local listEnd = math.max(4, controlTop - detailsRows - 1)
  1322. local listStart = 4
  1323. local listRows = math.max(1, listEnd - listStart + 1)
  1324.  
  1325. writeAt(x + 1, 3, fit("PLAYERS @=tracked", w - 2), colors.lightGray, colors.gray)
  1326. local names = playerNamesForSidebar()
  1327. playerListOffset = clamp(playerListOffset, 0, math.max(0, #names - listRows))
  1328. for row = 0, listRows - 1 do
  1329. local item = names[playerListOffset + row + 1]
  1330. local y = listStart + row
  1331. if item then
  1332. local selected = selectedPlayer and selectedPlayer:lower() == item.name:lower()
  1333. local prefix = (item.tracked and "@" or " ") .. (item.online and "+" or "-") .. " "
  1334. local bg = selected and colors.lightBlue or colors.gray
  1335. local fg = selected and colors.black or (item.online and colors.white or colors.lightGray)
  1336. local text = fit(prefix .. item.name, w - 2)
  1337. writeAt(x + 1, y, text .. string.rep(" ", math.max(0, w - 2 - #text)), fg, bg)
  1338. addHit(x + 1, y, screenW - 1, y, "select_player", item.name)
  1339. end
  1340. end
  1341.  
  1342. local detailY = listEnd + 1
  1343. local p = getPlayer(selectedPlayer)
  1344. if selectedPlayer then
  1345. writeAt(x + 1, detailY, fit("SEL " .. selectedPlayer, w - 2), colors.yellow, colors.gray)
  1346. if p then
  1347. 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)
  1348. writeAt(x + 1, detailY + 2, fit("DIM " .. shortDim(p.dimension), w - 2), colors.white, colors.gray)
  1349. local hp = tonumber(p.health)
  1350. local maxHp = tonumber(p.maxHealth)
  1351. local line = hp and string.format("HP %.1f/%.1f %s", hp, maxHp or 0, facingArrow(p.yaw)) or ("LOOK " .. facingArrow(p.yaw))
  1352. writeAt(x + 1, detailY + 3, fit(line, w - 2), colors.white, colors.gray)
  1353. else
  1354. writeAt(x + 1, detailY + 1, "offline", colors.lightGray, colors.gray)
  1355. end
  1356. else
  1357. writeAt(x + 1, detailY, "SEL none", colors.lightGray, colors.gray)
  1358. writeAt(x + 1, detailY + 1, "Click a player", colors.lightGray, colors.gray)
  1359. end
  1360.  
  1361. local y = controlTop
  1362. local half = math.floor((w - 3) / 2)
  1363. button(x + 1, y, half, isTracked(selectedPlayer or "") and "UNTRACK" or "TRACK", "toggle_track", nil, false)
  1364. button(x + 2 + half, y, w - 3 - half, activeView == "events" and "MAP" or "EVENTS", "toggle_view", nil, activeView == "events")
  1365. y = y + 1
  1366.  
  1367. if activeView == "map" then
  1368. local third = math.floor((w - 4) / 3)
  1369. button(x + 1, y, third, "<", "pan", { dx = -1, dz = 0 })
  1370. button(x + 2 + third, y, third, "^", "pan", { dx = 0, dz = -1 })
  1371. button(x + 3 + third * 2, y, w - 4 - third * 2, ">", "pan", { dx = 1, dz = 0 })
  1372. y = y + 1
  1373. button(x + 1, y, third, "-", "zoom", 1)
  1374. button(x + 2 + third, y, third, "v", "pan", { dx = 0, dz = 1 })
  1375. button(x + 3 + third * 2, y, w - 4 - third * 2, "+", "zoom", -1)
  1376. else
  1377. button(x + 1, y, half, "PAGE UP", "event_page", -1)
  1378. button(x + 2 + half, y, w - 3 - half, "PAGE DN", "event_page", 1)
  1379. y = y + 1
  1380. button(x + 1, y, w - 2, "CLEAR FILTER", "clear_selection", nil)
  1381. end
  1382. y = y + 1
  1383.  
  1384. button(x + 1, y, half, "CENTER", "center", nil)
  1385. button(x + 2 + half, y, w - 3 - half, "DIM >", "cycle_dim", 1)
  1386. y = y + 1
  1387. button(x + 1, y, half, "STATS", "stats", nil)
  1388. button(x + 2 + half, y, w - 3 - half, "RAW", "raw", nil)
  1389. y = y + 1
  1390.  
  1391. local status = statusUntil >= nowMs() and statusText or (monitor and "R-drag terminal | touch monitor" or "R-drag | wheel | T/E/Q")
  1392. writeAt(x + 1, y, fit(status, w - 2), colors.lightGray, colors.gray)
  1393. end
  1394.  
  1395. local function render()
  1396. local w, h = screen.getSize()
  1397. if w ~= screenW or h ~= screenH or not buffer then recreateBuffer() end
  1398.  
  1399. hitboxes = {}
  1400. buffer.setVisible(false)
  1401. buffer.setBackgroundColor(colors.black)
  1402. buffer.setTextColor(colors.white)
  1403. buffer.clear()
  1404.  
  1405. if screenW < 30 or screenH < 12 then
  1406. writeAt(1, 1, "Display too small. Enlarge terminal/monitor.", colors.red, colors.black)
  1407. buffer.setVisible(true)
  1408. mirrorBufferToMonitor()
  1409. return
  1410. end
  1411.  
  1412. local layout = getLayout()
  1413. if activeView == "events" then drawEvents(layout) else drawMap(layout) end
  1414. drawSidebar(layout)
  1415.  
  1416. buffer.setVisible(true)
  1417. mirrorBufferToMonitor()
  1418. lastRenderMs = nowMs()
  1419. end
  1420.  
  1421. local function hitAt(x, y)
  1422. for i = #hitboxes, 1, -1 do
  1423. local h = hitboxes[i]
  1424. if x >= h.x1 and x <= h.x2 and y >= h.y1 and y <= h.y2 then return h end
  1425. end
  1426. return nil
  1427. end
  1428.  
  1429. local function executeAction(action, data)
  1430. if action == "select_player" then
  1431. selectedPlayer = tostring(data)
  1432. local p = getPlayer(selectedPlayer)
  1433. if p and activeView == "map" then config.activeDim = p.dimension end
  1434. eventOffset = 0
  1435. elseif action == "toggle_track" then
  1436. if selectedPlayer then toggleTracked(selectedPlayer) else setStatus("select a player first") end
  1437. elseif action == "toggle_view" then
  1438. activeView = activeView == "map" and "events" or "map"
  1439. eventOffset = 0
  1440. elseif action == "pan" and data then
  1441. local layout = getLayout()
  1442. local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  1443. local stepX = math.max(5, math.floor(layout.mapW * 0.30)) * scale
  1444. local stepZ = math.max(5, math.floor((screenH - 2) * 0.30)) * scale
  1445. config.centerX = config.centerX + (data.dx or 0) * stepX
  1446. config.centerZ = config.centerZ + (data.dz or 0) * stepZ
  1447. config.centerInitialized = true
  1448. dirtyConfig = true
  1449. elseif action == "zoom" then
  1450. zoom(tonumber(data) or 0)
  1451. elseif action == "center" then
  1452. centerOnSelected()
  1453. elseif action == "cycle_dim" then
  1454. cycleDimension(tonumber(data) or 1)
  1455. elseif action == "event_page" then
  1456. eventOffset = math.max(0, eventOffset + (tonumber(data) or 0) * math.max(1, screenH - 4))
  1457. elseif action == "clear_selection" then
  1458. selectedPlayer = nil
  1459. eventOffset = 0
  1460. elseif action == "stats" then
  1461. captureStats(selectedPlayer)
  1462. elseif action == "raw" then
  1463. dumpRaw(selectedPlayer)
  1464. elseif action == "event_jump" and type(data) == "table" then
  1465. local x, y, z, dim = eventPosition(data)
  1466. if x and z then
  1467. config.centerX = x
  1468. config.centerZ = z
  1469. config.centerInitialized = true
  1470. if dim then config.activeDim = dim end
  1471. if data.player then selectedPlayer = tostring(data.player) end
  1472. activeView = "map"
  1473. dirtyConfig = true
  1474. else
  1475. setStatus("event has no coordinates")
  1476. end
  1477. end
  1478. end
  1479.  
  1480. local function handlePointer(x, y, source)
  1481. local h = hitAt(x, y)
  1482. if h then
  1483. executeAction(h.action, h.data)
  1484. return true
  1485. end
  1486.  
  1487. if source == "monitor" and activeView == "map" and mapRect and
  1488. x >= mapRect.x1 and x <= mapRect.x2 and y >= mapRect.y1 and y <= mapRect.y2 then
  1489. local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  1490. local wx, wz = screenToWorld(x, y, mapRect, scale)
  1491. config.centerX = wx
  1492. config.centerZ = wz
  1493. config.centerInitialized = true
  1494. dirtyConfig = true
  1495. return true
  1496. end
  1497.  
  1498. return false
  1499. end
  1500.  
  1501. local function handleKey(code)
  1502. if code == keys.q then
  1503. running = false
  1504. elseif code == keys.m then
  1505. activeView = "map"
  1506. elseif code == keys.e then
  1507. activeView = "events"
  1508. eventOffset = 0
  1509. elseif code == keys.t then
  1510. if selectedPlayer then toggleTracked(selectedPlayer) end
  1511. elseif code == keys.c then
  1512. centerOnSelected()
  1513. elseif code == keys.d then
  1514. cycleDimension(1)
  1515. elseif code == keys.s then
  1516. captureStats(selectedPlayer)
  1517. elseif code == keys.r then
  1518. dumpRaw(selectedPlayer)
  1519. elseif code == keys.equals or code == keys.numPadAdd then
  1520. zoom(-1)
  1521. elseif code == keys.minus or code == keys.numPadSubtract then
  1522. zoom(1)
  1523. elseif code == keys.left then
  1524. executeAction("pan", { dx = -1, dz = 0 })
  1525. elseif code == keys.right then
  1526. executeAction("pan", { dx = 1, dz = 0 })
  1527. elseif code == keys.up then
  1528. if activeView == "events" then
  1529. eventOffset = math.max(0, eventOffset - 1)
  1530. else
  1531. executeAction("pan", { dx = 0, dz = -1 })
  1532. end
  1533. elseif code == keys.down then
  1534. if activeView == "events" then
  1535. eventOffset = eventOffset + 1
  1536. else
  1537. executeAction("pan", { dx = 0, dz = 1 })
  1538. end
  1539. elseif code == keys.home then
  1540. selectedPlayer = nil
  1541. centerOnSelected()
  1542. end
  1543. end
  1544.  
  1545. local function handlePlayerEvent(ev)
  1546. local kind = ev[1]
  1547. local ts = nowMs()
  1548.  
  1549. if kind == "player_join" then
  1550. local _, uuid, username, dimension = table.unpack(ev)
  1551. if username then pendingJoin[tostring(username):lower()] = { ts = ts, uuid = uuid, dimension = dimension } end
  1552. elseif kind == "player_leave" then
  1553. local _, uuid, username, dimension = table.unpack(ev)
  1554. if username and isTracked(username) then
  1555. local key = tostring(username):lower()
  1556. local state = trackerState[key]
  1557. if state and state.online then
  1558. closeArea(username, state, ts, "offline", state.last)
  1559. local last = state.last
  1560. logEvent("leave", username, {
  1561. x = last and last.x,
  1562. y = last and last.y,
  1563. z = last and last.z,
  1564. dim = dimension or (last and last.dimension),
  1565. uuid = uuid,
  1566. })
  1567. state.online = false
  1568. end
  1569. end
  1570. elseif kind == "player_changed_dimension" then
  1571. local _, uuid, username, fromDim, toDim = table.unpack(ev)
  1572. if username then
  1573. pendingDimension[tostring(username):lower()] = {
  1574. ts = ts, uuid = uuid, fromDim = fromDim, toDim = toDim,
  1575. }
  1576. end
  1577. elseif kind == "player_death" then
  1578. local _, uuid, username, damageSource = table.unpack(ev)
  1579. if username and isTracked(username) then
  1580. local key = tostring(username):lower()
  1581. local state = trackerState[key] or {}
  1582. trackerState[key] = state
  1583. state.lastDeathMs = ts
  1584. local last = state.last
  1585. logEvent("death", username, {
  1586. x = last and last.x,
  1587. y = last and last.y,
  1588. z = last and last.z,
  1589. dim = last and last.dimension,
  1590. uuid = uuid,
  1591. damageSource = damageSource,
  1592. })
  1593. end
  1594. end
  1595. end
  1596.  
  1597. -- Try to expose useful detector config information in the initial event log/status.
  1598. do
  1599. local ok, cfg = pcall(detector.getConfiguration)
  1600. if ok and type(cfg) == "table" then
  1601. local encoded = jsonEncode(safeCopy(cfg))
  1602. if encoded then
  1603. local path = DATA_DIR .. "/detector_config.json"
  1604. local h = fs.open(path, "w")
  1605. if h then h.write(encoded) h.close() end
  1606. end
  1607. end
  1608. end
  1609.  
  1610. pollPlayers()
  1611. render()
  1612. setStatus(monitor and ("terminal + monitor " .. tostring(monitorName)) or "terminal mode", 5)
  1613.  
  1614. local pollTimer = os.startTimer(SETTINGS.POLL_SECONDS)
  1615. local renderTimer = os.startTimer(SETTINGS.RENDER_SECONDS)
  1616. local saveTimer = os.startTimer(10)
  1617.  
  1618. while running do
  1619. local ev = { os.pullEventRaw() }
  1620. local kind = ev[1]
  1621.  
  1622. if kind == "terminate" then
  1623. running = false
  1624.  
  1625. elseif kind == "timer" then
  1626. if ev[2] == pollTimer then
  1627. pcall(pollPlayers)
  1628. pollTimer = os.startTimer(SETTINGS.POLL_SECONDS)
  1629. elseif ev[2] == renderTimer then
  1630. pcall(render)
  1631. renderTimer = os.startTimer(SETTINGS.RENDER_SECONDS)
  1632. elseif ev[2] == saveTimer then
  1633. if dirtyConfig then
  1634. saveConfig()
  1635. dirtyConfig = false
  1636. end
  1637. saveTimer = os.startTimer(10)
  1638. end
  1639.  
  1640. elseif kind == "monitor_touch" and monitor and ev[2] == monitorName then
  1641. handlePointer(ev[3], ev[4], "monitor")
  1642. pcall(render)
  1643.  
  1644. elseif kind == "mouse_click" then
  1645. local buttonId, x, y = ev[2], ev[3], ev[4]
  1646. local inMap = activeView == "map" and mapRect and
  1647. x >= mapRect.x1 and x <= mapRect.x2 and
  1648. y >= mapRect.y1 and y <= mapRect.y2
  1649.  
  1650. -- RIGHT mouse button is reserved for map dragging. We deliberately
  1651. -- do not run hit-testing first, so you can begin a drag on top of a
  1652. -- player marker/name without accidentally selecting it.
  1653. if buttonId == 2 and inMap then
  1654. dragState = { button = 2, x = x, y = y, moved = false }
  1655. elseif buttonId == 1 then
  1656. dragState = nil
  1657. handlePointer(x, y, "terminal")
  1658. pcall(render)
  1659. else
  1660. dragState = nil
  1661. end
  1662.  
  1663. elseif kind == "mouse_drag" and dragState and activeView == "map" then
  1664. local buttonId, x, y = ev[2], ev[3], ev[4]
  1665. if buttonId == dragState.button then
  1666. local dx = x - dragState.x
  1667. local dy = y - dragState.y
  1668. if dx ~= 0 or dy ~= 0 then
  1669. local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  1670. config.centerX = config.centerX - dx * scale
  1671. config.centerZ = config.centerZ - dy * scale
  1672. config.centerInitialized = true
  1673. dirtyConfig = true
  1674. dragState.x = x
  1675. dragState.y = y
  1676. dragState.moved = true
  1677. pcall(render)
  1678. end
  1679. end
  1680.  
  1681. elseif kind == "mouse_up" then
  1682. if not dragState or ev[2] == dragState.button then
  1683. dragState = nil
  1684. end
  1685.  
  1686. elseif kind == "mouse_scroll" then
  1687. local direction, x, y = ev[2], ev[3], ev[4]
  1688. local layout = getLayout()
  1689. if x <= layout.mapW then
  1690. if activeView == "map" then
  1691. zoomAt(direction > 0 and 1 or -1, x, y, layout.map)
  1692. else
  1693. eventOffset = math.max(0, eventOffset + direction * 3)
  1694. end
  1695. else
  1696. playerListOffset = math.max(0, playerListOffset + direction)
  1697. end
  1698. pcall(render)
  1699.  
  1700. elseif kind == "key" then
  1701. handleKey(ev[2])
  1702.  
  1703. elseif kind == "term_resize" then
  1704. recreateBuffer()
  1705.  
  1706. elseif kind == "monitor_resize" and monitor and ev[2] == monitorName then
  1707. pcall(mirrorBufferToMonitor)
  1708.  
  1709. elseif kind == "peripheral_detach" and monitor and ev[2] == monitorName then
  1710. monitor = nil
  1711. monitorName = nil
  1712. setStatus("monitor detached; terminal still active", 6)
  1713.  
  1714. elseif kind == "player_join" or kind == "player_leave" or
  1715. kind == "player_changed_dimension" or kind == "player_death" then
  1716. pcall(handlePlayerEvent, ev)
  1717. end
  1718. end
  1719.  
  1720. saveConfig()
  1721. nativeTerm.setBackgroundColor(colors.black)
  1722. nativeTerm.setTextColor(colors.white)
  1723. nativeTerm.clear()
  1724. nativeTerm.setCursorPos(1, 1)
  1725. print("Player tracker stopped.")
  1726.  
Advertisement
Add Comment
Please, Sign In to add comment