AndyE7

Untitled

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