AndyE7

Untitled

Aug 29th, 2026 (edited)
17
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 68.98 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. * Full-resolution attached Advanced Monitor with large touch pan/zoom controls
  15. * Right-drag + wheel control still available from the computer terminal
  16. * Optional all-stat snapshot and raw Player Detector snapshot dumps
  17.  
  18. Edit SETTINGS.DEFAULT_TRACKED below if you want names preloaded.
  19. All persistent data is stored under /player_tracker.
  20. ]]
  21.  
  22. local SETTINGS = {
  23. -- Display
  24. -- Mouse drag/wheel events only exist on the computer terminal. The attached
  25. -- monitor has its own native UI with large touch buttons for pan/zoom/etc.
  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.25,
  32. RENDER_SECONDS = 0.50,
  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. The large values are useful
  50. -- when players are tens of thousands of blocks apart.
  51. ZOOM_LEVELS = { 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192 },
  52. DEFAULT_ZOOM_INDEX = 5,
  53.  
  54. -- Start by fitting every online player in the active dimension onto the
  55. -- main display. Any manual pan/zoom disables auto-fit until AUTO is pressed.
  56. AUTO_FIT_ON_START = true,
  57. AUTO_FIT_HORIZONTAL_MARGIN = 12,
  58. AUTO_FIT_VERTICAL_MARGIN = 4,
  59.  
  60. -- Four rows are reserved at the bottom for large touch controls.
  61. -- Monitor events do not expose drag/wheel, so these buttons are the
  62. -- monitor-native pan/zoom controls.
  63. CONTROL_BAR_ROWS = 4,
  64.  
  65. -- Names here are merged into the persistent tracked list at startup.
  66. DEFAULT_TRACKED = {
  67. -- "PlayerName",
  68. },
  69.  
  70. -- Optional: taking every stat can be relatively expensive, so this is manual
  71. -- by default (press S with a selected player).
  72. CAPTURE_STATS_ON_MAJOR_EVENT = false,
  73. }
  74.  
  75. local DATA_DIR = "/player_tracker"
  76. local CONFIG_FILE = DATA_DIR .. "/config.json"
  77. local EVENT_FILE = DATA_DIR .. "/events.jsonl"
  78. local HISTORY_DIR = DATA_DIR .. "/history"
  79. local STATS_DIR = DATA_DIR .. "/stats"
  80. local RAW_DIR = DATA_DIR .. "/raw"
  81.  
  82. local function ensureDir(path)
  83. if not fs.exists(path) then fs.makeDir(path) end
  84. end
  85.  
  86. ensureDir(DATA_DIR)
  87. ensureDir(HISTORY_DIR)
  88. ensureDir(STATS_DIR)
  89. ensureDir(RAW_DIR)
  90.  
  91. local detector = peripheral.find("player_detector")
  92. if not detector then
  93. error("No Advanced Peripherals Player Detector is connected")
  94. end
  95.  
  96. local monitor, monitorName
  97. if SETTINGS.MIRROR_TO_MONITOR then
  98. if SETTINGS.MONITOR_NAME then
  99. if peripheral.isPresent(SETTINGS.MONITOR_NAME) and peripheral.getType(SETTINGS.MONITOR_NAME) == "monitor" then
  100. monitorName = SETTINGS.MONITOR_NAME
  101. monitor = peripheral.wrap(monitorName)
  102. end
  103. else
  104. for _, name in ipairs(peripheral.getNames()) do
  105. if peripheral.getType(name) == "monitor" then
  106. monitorName = name
  107. monitor = peripheral.wrap(name)
  108. break
  109. end
  110. end
  111. end
  112. end
  113.  
  114. -- Render terminal and monitor independently. The terminal supports mouse drag
  115. -- and wheel; the monitor supports touch hitboxes and the large control bar.
  116. local nativeTerm = term.current()
  117. local screen = nativeTerm
  118. if monitor and monitor.setTextScale then
  119. pcall(monitor.setTextScale, SETTINGS.MONITOR_TEXT_SCALE)
  120. end
  121.  
  122. local function nowMs()
  123. return os.epoch("utc")
  124. end
  125.  
  126. local function safeName(name)
  127. return tostring(name or "unknown"):gsub("[^%w_%-.]", "_")
  128. end
  129.  
  130. local function round(v)
  131. if v >= 0 then return math.floor(v + 0.5) end
  132. return math.ceil(v - 0.5)
  133. end
  134.  
  135. local function clamp(v, lo, hi)
  136. if v < lo then return lo end
  137. if v > hi then return hi end
  138. return v
  139. end
  140.  
  141. local function distance3(a, b)
  142. local dx = (a.x or 0) - (b.x or 0)
  143. local dy = (a.y or 0) - (b.y or 0)
  144. local dz = (a.z or 0) - (b.z or 0)
  145. return math.sqrt(dx * dx + dy * dy + dz * dz)
  146. end
  147.  
  148. local function distance2(a, b)
  149. local dx = (a.x or 0) - (b.x or 0)
  150. local dz = (a.z or 0) - (b.z or 0)
  151. return math.sqrt(dx * dx + dz * dz)
  152. end
  153.  
  154. local function formatDuration(sec)
  155. sec = math.max(0, math.floor(sec or 0))
  156. local h = math.floor(sec / 3600)
  157. local m = math.floor((sec % 3600) / 60)
  158. local s = sec % 60
  159. if h > 0 then return string.format("%dh %02dm", h, m) end
  160. if m > 0 then return string.format("%dm %02ds", m, s) end
  161. return string.format("%ds", s)
  162. end
  163.  
  164. local function shortDim(dim)
  165. dim = tostring(dim or "?")
  166. local last = dim:match("([^:]+)$")
  167. return last or dim
  168. end
  169.  
  170. local function fit(text, width)
  171. text = tostring(text or "")
  172. if width <= 0 then return "" end
  173. if #text <= width then return text end
  174. if width <= 2 then return text:sub(1, width) end
  175. return text:sub(1, width - 1) .. "~"
  176. end
  177.  
  178. local function jsonEncode(v)
  179. local ok, out = pcall(textutils.serializeJSON, v)
  180. if ok then return out end
  181. return nil
  182. end
  183.  
  184. local function jsonDecode(s)
  185. local ok, out = pcall(textutils.unserializeJSON, s)
  186. if ok then return out end
  187. return nil
  188. end
  189.  
  190. local function safeCopy(value, depth, seen)
  191. depth = depth or 0
  192. seen = seen or {}
  193. if depth > 5 then return "<depth>" end
  194.  
  195. local t = type(value)
  196. if t == "nil" or t == "number" or t == "string" or t == "boolean" then
  197. return value
  198. elseif t ~= "table" then
  199. return tostring(value)
  200. end
  201.  
  202. if seen[value] then return "<cycle>" end
  203. seen[value] = true
  204.  
  205. local out = {}
  206. for k, v in pairs(value) do
  207. local kt = type(k)
  208. if kt == "string" or kt == "number" then
  209. out[k] = safeCopy(v, depth + 1, seen)
  210. end
  211. end
  212.  
  213. seen[value] = nil
  214. return out
  215. end
  216.  
  217. local function appendLine(path, line)
  218. local ok, result = pcall(function()
  219. local h = fs.open(path, "a")
  220. if not h then return false end
  221. h.writeLine(line)
  222. h.close()
  223. return true
  224. end)
  225. return ok and result == true
  226. end
  227.  
  228. local function rewriteTail(path, maxBytes)
  229. if not fs.exists(path) then return end
  230. local size = fs.getSize(path)
  231. if type(size) ~= "number" or size <= maxBytes then return end
  232.  
  233. local h = fs.open(path, "r")
  234. if not h then return end
  235. local data = h.readAll() or ""
  236. h.close()
  237.  
  238. local keep = math.floor(maxBytes * 0.55)
  239. local start = math.max(1, #data - keep)
  240. local nl = data:find("\n", start, true)
  241. if nl then start = nl + 1 end
  242. local tail = data:sub(start)
  243.  
  244. local tmp = path .. ".tmp"
  245. local out = fs.open(tmp, "w")
  246. if not out then return end
  247. out.write(tail)
  248. out.close()
  249. if fs.exists(path) then fs.delete(path) end
  250. fs.move(tmp, path)
  251. end
  252.  
  253. local config = {
  254. tracked = {},
  255. centerX = 0,
  256. centerZ = 0,
  257. activeDim = nil,
  258. zoomIndex = SETTINGS.DEFAULT_ZOOM_INDEX,
  259. centerInitialized = false,
  260. }
  261.  
  262. local function loadConfig()
  263. if not fs.exists(CONFIG_FILE) then return end
  264. local h = fs.open(CONFIG_FILE, "r")
  265. if not h then return end
  266. local raw = h.readAll()
  267. h.close()
  268. local data = jsonDecode(raw or "")
  269. if type(data) ~= "table" then return end
  270.  
  271. if type(data.tracked) == "table" then config.tracked = data.tracked end
  272. if type(data.centerX) == "number" then config.centerX = data.centerX end
  273. if type(data.centerZ) == "number" then config.centerZ = data.centerZ end
  274. if type(data.activeDim) == "string" then config.activeDim = data.activeDim end
  275. if type(data.zoomIndex) == "number" then
  276. config.zoomIndex = clamp(math.floor(data.zoomIndex), 1, #SETTINGS.ZOOM_LEVELS)
  277. end
  278. if data.centerInitialized == true then config.centerInitialized = true end
  279. end
  280.  
  281. local function saveConfig()
  282. local payload = {
  283. tracked = config.tracked,
  284. centerX = config.centerX,
  285. centerZ = config.centerZ,
  286. activeDim = config.activeDim,
  287. zoomIndex = config.zoomIndex,
  288. centerInitialized = config.centerInitialized,
  289. }
  290. local encoded = jsonEncode(payload)
  291. if not encoded then return end
  292.  
  293. local tmp = CONFIG_FILE .. ".tmp"
  294. local h = fs.open(tmp, "w")
  295. if not h then return end
  296. h.write(encoded)
  297. h.close()
  298. if fs.exists(CONFIG_FILE) then fs.delete(CONFIG_FILE) end
  299. fs.move(tmp, CONFIG_FILE)
  300. end
  301.  
  302. loadConfig()
  303. for _, name in ipairs(SETTINGS.DEFAULT_TRACKED) do
  304. config.tracked[tostring(name):lower()] = tostring(name)
  305. end
  306. saveConfig()
  307.  
  308. local events = {}
  309.  
  310. local function loadEvents()
  311. if not fs.exists(EVENT_FILE) then return end
  312. local h = fs.open(EVENT_FILE, "r")
  313. if not h then return end
  314. while true do
  315. local line = h.readLine()
  316. if not line then break end
  317. local ev = jsonDecode(line)
  318. if type(ev) == "table" then
  319. events[#events + 1] = ev
  320. if #events > SETTINGS.EVENT_CACHE_LIMIT then table.remove(events, 1) end
  321. end
  322. end
  323. h.close()
  324. end
  325.  
  326. loadEvents()
  327.  
  328. local statusText = "starting"
  329. local statusUntil = 0
  330. local function setStatus(text, seconds)
  331. statusText = tostring(text or "")
  332. statusUntil = nowMs() + math.floor((seconds or 4) * 1000)
  333. end
  334.  
  335. local function eventPosition(ev)
  336. if type(ev.toX) == "number" then
  337. return ev.toX, ev.toY, ev.toZ, ev.toDim or ev.dim
  338. end
  339. if type(ev.x) == "number" then
  340. return ev.x, ev.y, ev.z, ev.dim
  341. end
  342. if type(ev.anchorX) == "number" then
  343. return ev.anchorX, ev.anchorY, ev.anchorZ, ev.dim
  344. end
  345. return nil, nil, nil, ev.dim
  346. end
  347.  
  348. local function logEvent(kind, name, fields, snapshot)
  349. local ev = {
  350. ts = nowMs(),
  351. type = kind,
  352. player = name,
  353. }
  354. if type(fields) == "table" then
  355. for k, v in pairs(fields) do ev[k] = v end
  356. end
  357. if snapshot then ev.snapshot = safeCopy(snapshot) end
  358.  
  359. local encoded = jsonEncode(ev)
  360. if encoded then
  361. appendLine(EVENT_FILE, encoded)
  362. rewriteTail(EVENT_FILE, SETTINGS.EVENT_MAX_FILE_BYTES)
  363. end
  364.  
  365. events[#events + 1] = ev
  366. if #events > SETTINGS.EVENT_CACHE_LIMIT then table.remove(events, 1) end
  367. return ev
  368. end
  369.  
  370. local players = {}
  371. local trackerState = {}
  372. local trails = {}
  373. local lastHistory = {}
  374. local pendingJoin = {}
  375. local pendingDimension = {}
  376. local selectedPlayer = nil
  377. local activeView = "map"
  378. local autoFit = SETTINGS.AUTO_FIT_ON_START ~= false
  379. local eventOffset = 0
  380. local playerListOffset = 0
  381. local dirtyConfig = false
  382. local running = true
  383. local dragState = nil
  384. local lastPollMs = nowMs()
  385. local lastGoodPollMs = 0
  386. local lastLookupFailureCount = 0
  387. local lastPollError = nil
  388. local lastRenderMs = 0
  389.  
  390. local function isTracked(name)
  391. return config.tracked[tostring(name):lower()] ~= nil
  392. end
  393.  
  394. local function canonicalTrackedName(name)
  395. return config.tracked[tostring(name):lower()]
  396. end
  397.  
  398. local function setTracked(name, yes)
  399. if not name or name == "" then return end
  400. local key = tostring(name):lower()
  401. if yes then
  402. config.tracked[key] = tostring(name)
  403. trackerState[key] = trackerState[key] or {}
  404. trails[key] = trails[key] or {}
  405.  
  406. -- Seed the previous-position sample immediately from the live map.
  407. -- Without this there is a small race where a player can teleport after
  408. -- TRACK is pressed but before the next poll, making that jump the first
  409. -- sample and therefore impossible to compare against anything.
  410. local current = players[key]
  411. if current and type(current.x) == "number" and type(current.z) == "number" then
  412. local ts = nowMs()
  413. local state = trackerState[key]
  414. state.online = true
  415. state.last = {
  416. x = current.x, y = current.y, z = current.z,
  417. dimension = current.dimension, yaw = current.yaw, pitch = current.pitch,
  418. health = current.health, maxHealth = current.maxHealth,
  419. airSupply = current.airSupply, ts = ts,
  420. }
  421. end
  422.  
  423. logEvent("track_on", tostring(name), {})
  424. setStatus("tracking " .. tostring(name))
  425. else
  426. local old = config.tracked[key] or tostring(name)
  427. config.tracked[key] = nil
  428. logEvent("track_off", old, {})
  429. setStatus("stopped tracking " .. old)
  430. end
  431. dirtyConfig = true
  432. saveConfig()
  433. end
  434.  
  435. local function toggleTracked(name)
  436. setTracked(name, not isTracked(name))
  437. end
  438.  
  439. local function copyPosition(p, ts)
  440. return {
  441. x = p.x,
  442. y = p.y,
  443. z = p.z,
  444. dimension = p.dimension,
  445. yaw = p.yaw,
  446. pitch = p.pitch,
  447. health = p.health,
  448. maxHealth = p.maxHealth,
  449. airSupply = p.airSupply,
  450. respawnPosition = safeCopy(p.respawnPosition),
  451. respawnDimension = safeCopy(p.respawnDimension),
  452. respawnAngle = safeCopy(p.respawnAngle),
  453. ts = ts or nowMs(),
  454. }
  455. end
  456.  
  457. local function respawnFingerprint(p)
  458. if p.respawnPosition == nil and p.respawnDimension == nil and p.respawnAngle == nil then
  459. return nil
  460. end
  461. return jsonEncode({
  462. pos = safeCopy(p.respawnPosition),
  463. dim = safeCopy(p.respawnDimension),
  464. angle = safeCopy(p.respawnAngle),
  465. })
  466. end
  467.  
  468. local function appendBreadcrumb(name, p, ts)
  469. local okFree, free = pcall(fs.getFreeSpace, "/")
  470. if okFree and type(free) == "number" and free < SETTINGS.MIN_FREE_FOR_HISTORY_BYTES then
  471. return
  472. end
  473.  
  474. local key = tostring(name):lower()
  475. local last = lastHistory[key]
  476. local shouldWrite = false
  477. if not last then
  478. shouldWrite = true
  479. else
  480. local age = (ts - last.ts) / 1000
  481. local moved = distance3(last, p)
  482. if age >= SETTINGS.HISTORY_FORCE_SAMPLE_SECONDS then
  483. shouldWrite = true
  484. elseif age >= SETTINGS.HISTORY_SAMPLE_SECONDS and moved >= SETTINGS.HISTORY_MIN_MOVE then
  485. shouldWrite = true
  486. end
  487. end
  488.  
  489. if not shouldWrite then return end
  490.  
  491. local path = HISTORY_DIR .. "/" .. safeName(name) .. ".csv"
  492. if not fs.exists(path) then
  493. appendLine(path, "timestamp_ms,dimension,x,y,z,yaw,pitch,health,max_health,air")
  494. end
  495.  
  496. local line = string.format(
  497. "%d,%s,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f,%.2f",
  498. ts,
  499. tostring(p.dimension or "?"):gsub(",", "_"),
  500. tonumber(p.x) or 0,
  501. tonumber(p.y) or 0,
  502. tonumber(p.z) or 0,
  503. tonumber(p.yaw) or 0,
  504. tonumber(p.pitch) or 0,
  505. tonumber(p.health) or 0,
  506. tonumber(p.maxHealth) or 0,
  507. tonumber(p.airSupply) or 0
  508. )
  509. appendLine(path, line)
  510. rewriteTail(path, SETTINGS.HISTORY_MAX_BYTES_PER_PLAYER)
  511. lastHistory[key] = copyPosition(p, ts)
  512. end
  513.  
  514. local function addTrailPoint(name, p, ts)
  515. local key = tostring(name):lower()
  516. local trail = trails[key] or {}
  517. trails[key] = trail
  518. local last = trail[#trail]
  519. if last and last.dimension == p.dimension and distance2(last, p) < 2 and (ts - last.ts) < 5000 then
  520. return
  521. end
  522. trail[#trail + 1] = {
  523. x = p.x, y = p.y, z = p.z,
  524. dimension = p.dimension,
  525. ts = ts,
  526. }
  527. while #trail > 220 do table.remove(trail, 1) end
  528. end
  529.  
  530. local function startArea(state, p, ts, reason)
  531. state.area = {
  532. x = p.x,
  533. y = p.y,
  534. z = p.z,
  535. dimension = p.dimension,
  536. startMs = ts,
  537. lastMs = ts,
  538. reason = reason or "movement",
  539. milestones = {},
  540. }
  541. end
  542.  
  543. local function closeArea(name, state, ts, reason, p)
  544. local area = state.area
  545. if not area then return end
  546. local endMs = area.lastMs or ts
  547. local duration = math.max(0, (endMs - area.startMs) / 1000)
  548.  
  549. -- A teleport/dimension destination is useful even when the player only
  550. -- remains there briefly. Normal wandering areas retain the minimum dwell
  551. -- threshold so the event log does not fill with noise.
  552. local importantOrigin = area.reason == "teleport"
  553. or area.reason == "respawn"
  554. or area.reason == "dimension"
  555. or area.reason == "respawn_dimension"
  556.  
  557. if importantOrigin or duration >= SETTINGS.DWELL_MIN_LOG_SECONDS then
  558. logEvent("area_stay", name, {
  559. anchorX = area.x,
  560. anchorY = area.y,
  561. anchorZ = area.z,
  562. x = p and p.x or state.last and state.last.x,
  563. y = p and p.y or state.last and state.last.y,
  564. z = p and p.z or state.last and state.last.z,
  565. dim = area.dimension,
  566. duration = duration,
  567. radius = SETTINGS.DWELL_RADIUS,
  568. reason = reason,
  569. startedBy = area.reason,
  570. }, p)
  571. end
  572. state.area = nil
  573. end
  574. local function updateArea(name, state, p, ts)
  575. local area = state.area
  576. if not area then
  577. startArea(state, p, ts, "movement")
  578. return
  579. end
  580.  
  581. if area.dimension ~= p.dimension then
  582. closeArea(name, state, ts, "dimension", p)
  583. startArea(state, p, ts, "dimension")
  584. return
  585. end
  586.  
  587. if distance2(area, p) > SETTINGS.DWELL_RADIUS then
  588. closeArea(name, state, ts, "left_radius", p)
  589. startArea(state, p, ts, "movement")
  590. return
  591. end
  592.  
  593. area.lastMs = ts
  594. local duration = (ts - area.startMs) / 1000
  595. for _, milestone in ipairs(SETTINGS.DWELL_MILESTONES) do
  596. if duration >= milestone and not area.milestones[tostring(milestone)] then
  597. area.milestones[tostring(milestone)] = true
  598. logEvent("dwell", name, {
  599. anchorX = area.x,
  600. anchorY = area.y,
  601. anchorZ = area.z,
  602. x = p.x,
  603. y = p.y,
  604. z = p.z,
  605. dim = p.dimension,
  606. duration = duration,
  607. radius = SETTINGS.DWELL_RADIUS,
  608. milestone = milestone,
  609. }, p)
  610. end
  611. end
  612. end
  613.  
  614. local function captureStats(name)
  615. if not name then
  616. setStatus("select a player first")
  617. return false
  618. end
  619. if not advancedperipherals or not advancedperipherals.iterPlayerStatKeys then
  620. setStatus("Advanced Peripherals stat iterator unavailable", 6)
  621. return false
  622. end
  623.  
  624. setStatus("capturing stats for " .. name, 10)
  625. local ids = {}
  626. for id in advancedperipherals.iterPlayerStatKeys() do
  627. ids[#ids + 1] = id
  628. end
  629.  
  630. local result = {
  631. ts = nowMs(),
  632. player = name,
  633. stats = {},
  634. }
  635.  
  636. local batchSize = 16
  637. local i = 1
  638. while i <= #ids do
  639. local batch = {}
  640. for j = i, math.min(i + batchSize - 1, #ids) do
  641. batch[#batch + 1] = ids[j]
  642. end
  643. local packed = { pcall(detector.getPlayerStat, name, table.unpack(batch)) }
  644. if packed[1] then
  645. for j = 1, #batch do
  646. result.stats[batch[j]] = packed[j + 1]
  647. end
  648. else
  649. result.errors = result.errors or {}
  650. result.errors[#result.errors + 1] = tostring(packed[2])
  651. end
  652. i = i + batchSize
  653. end
  654.  
  655. local encoded = jsonEncode(result)
  656. if encoded then
  657. local path = STATS_DIR .. "/" .. safeName(name) .. ".jsonl"
  658. appendLine(path, encoded)
  659. rewriteTail(path, 160000)
  660. logEvent("stats_snapshot", name, { count = #ids })
  661. setStatus("saved " .. tostring(#ids) .. " stats for " .. name, 6)
  662. return true
  663. end
  664.  
  665. setStatus("failed to encode stat snapshot", 6)
  666. return false
  667. end
  668.  
  669. local function dumpRaw(name)
  670. if not name then
  671. setStatus("select a player first")
  672. return
  673. end
  674. local p = players[tostring(name):lower()]
  675. if not p then
  676. local ok, fetched = pcall(detector.getPlayer, name)
  677. if ok then p = fetched end
  678. end
  679. if type(p) ~= "table" then
  680. setStatus("player data unavailable")
  681. return
  682. end
  683.  
  684. local data = safeCopy(p)
  685. data._dumpedAt = nowMs()
  686. local encoded = jsonEncode(data)
  687. if not encoded then
  688. setStatus("raw snapshot could not be encoded")
  689. return
  690. end
  691.  
  692. local path = RAW_DIR .. "/" .. safeName(name) .. "_" .. tostring(nowMs()) .. ".json"
  693. local h = fs.open(path, "w")
  694. if h then
  695. h.write(encoded)
  696. h.close()
  697. setStatus("raw snapshot saved", 5)
  698. end
  699. end
  700.  
  701. local function majorEventStats(name)
  702. if SETTINGS.CAPTURE_STATS_ON_MAJOR_EVENT then
  703. pcall(captureStats, name)
  704. end
  705. end
  706.  
  707. local function processTrackedPlayer(name, p, ts)
  708. local key = tostring(name):lower()
  709. local state = trackerState[key] or {}
  710. trackerState[key] = state
  711.  
  712. local firstThisSession = not state.online
  713. if firstThisSession then
  714. state.online = true
  715. state.last = copyPosition(p, ts)
  716. state.respawnFingerprint = respawnFingerprint(p)
  717. startArea(state, p, ts, pendingJoin[key] and "join" or "seen")
  718. addTrailPoint(name, p, ts)
  719. appendBreadcrumb(name, p, ts)
  720.  
  721. local join = pendingJoin[key]
  722. if join then
  723. logEvent("join", name, {
  724. x = p.x, y = p.y, z = p.z,
  725. dim = p.dimension or join.dimension,
  726. }, p)
  727. pendingJoin[key] = nil
  728. else
  729. logEvent("seen", name, {
  730. x = p.x, y = p.y, z = p.z,
  731. dim = p.dimension,
  732. }, p)
  733. end
  734. return
  735. end
  736.  
  737. local last = state.last
  738. if last then
  739. local dt = math.max(0.001, (ts - (last.ts or ts)) / 1000)
  740. local moved = distance3(last, p)
  741. p._speed = moved / dt
  742.  
  743. if last.dimension ~= p.dimension then
  744. closeArea(name, state, ts, "dimension", p)
  745. local recentDeath = state.lastDeathMs and (ts - state.lastDeathMs) <= 15000
  746. local pending = pendingDimension[key]
  747. local kind = recentDeath and "respawn_dimension" or "dimension"
  748. logEvent(kind, name, {
  749. fromDim = pending and pending.fromDim or last.dimension,
  750. toDim = pending and pending.toDim or p.dimension,
  751. fromX = last.x, fromY = last.y, fromZ = last.z,
  752. toX = p.x, toY = p.y, toZ = p.z,
  753. dim = p.dimension,
  754. }, p)
  755. pendingDimension[key] = nil
  756. startArea(state, p, ts, kind)
  757. majorEventStats(name)
  758. elseif moved >= SETTINGS.TELEPORT_DISTANCE then
  759. closeArea(name, state, ts, "teleport", p)
  760. local recentDeath = state.lastDeathMs and (ts - state.lastDeathMs) <= 15000
  761. local kind = recentDeath and "respawn" or "teleport"
  762. logEvent(kind, name, {
  763. fromX = last.x, fromY = last.y, fromZ = last.z,
  764. toX = p.x, toY = p.y, toZ = p.z,
  765. dim = p.dimension,
  766. distance = moved,
  767. seconds = dt,
  768. }, p)
  769. setStatus(string.format("%s %s -> %.0f %.0f %.0f (%.0fb)",
  770. kind:upper(), name, p.x, p.y or 0, p.z, moved), 6)
  771. startArea(state, p, ts, kind)
  772. majorEventStats(name)
  773. else
  774. updateArea(name, state, p, ts)
  775. end
  776. else
  777. startArea(state, p, ts, "seen")
  778. end
  779.  
  780. local fp = respawnFingerprint(p)
  781. if fp and state.respawnFingerprint and fp ~= state.respawnFingerprint then
  782. logEvent("respawn_change", name, {
  783. x = p.x, y = p.y, z = p.z,
  784. dim = p.dimension,
  785. respawnPosition = safeCopy(p.respawnPosition),
  786. respawnDimension = safeCopy(p.respawnDimension),
  787. respawnAngle = safeCopy(p.respawnAngle),
  788. }, p)
  789. end
  790. if fp then state.respawnFingerprint = fp end
  791.  
  792. addTrailPoint(name, p, ts)
  793. appendBreadcrumb(name, p, ts)
  794. state.last = copyPosition(p, ts)
  795. state.online = true
  796. end
  797.  
  798. local function processOfflineTracked(name, ts, reason)
  799. local key = tostring(name):lower()
  800. local state = trackerState[key]
  801. if not state or not state.online then return end
  802. closeArea(name, state, ts, reason or "offline", state.last)
  803. if reason ~= "event_leave" then
  804. local last = state.last
  805. logEvent("leave", name, {
  806. x = last and last.x,
  807. y = last and last.y,
  808. z = last and last.z,
  809. dim = last and last.dimension,
  810. })
  811. end
  812. state.online = false
  813. end
  814.  
  815. local function normalizeOnlineEntry(v)
  816. if type(v) == "string" then return v end
  817. if type(v) == "table" then return v.name or v.username or v.uuid end
  818. if v ~= nil then return tostring(v) end
  819. return nil
  820. end
  821.  
  822. local function pollPlayers()
  823. local ts = nowMs()
  824. local ok, list = pcall(detector.getOnlinePlayers)
  825. if not ok or type(list) ~= "table" then
  826. lastPollError = "getOnlinePlayers: " .. tostring(list)
  827. setStatus("Player Detector getOnlinePlayers failed: " .. tostring(list), 6)
  828. return false
  829. end
  830.  
  831. lastPollError = nil
  832. local fresh = {}
  833. local seenOnline = {}
  834. local lookupFailures = 0
  835.  
  836. -- The online list is the authority for whether somebody is online. A
  837. -- transient getPlayer() failure must NOT be treated as a disconnect: doing
  838. -- so discards the previous position and causes the next teleport to be
  839. -- missed. This was the main reliability bug in v4.
  840. for _, entry in pairs(list) do
  841. local listedName = normalizeOnlineEntry(entry)
  842. if listedName and listedName ~= "" then
  843. local listedKey = tostring(listedName):lower()
  844. seenOnline[listedKey] = tostring(listedName)
  845.  
  846. local pok, p = pcall(detector.getPlayer, listedName)
  847. if pok and type(p) == "table" and type(p.x) == "number" and type(p.z) == "number" then
  848. local canonical = tostring(p.name or listedName)
  849. local key = canonical:lower()
  850. p.name = canonical
  851. p._stale = nil
  852. p._sampleMs = ts
  853. fresh[key] = p
  854. seenOnline[key] = canonical
  855.  
  856. if isTracked(canonical) then
  857. local trackOk, trackErr = pcall(processTrackedPlayer, canonical, p, ts)
  858. if not trackOk then
  859. setStatus("tracking error " .. canonical .. ": " .. tostring(trackErr), 8)
  860. end
  861. end
  862. else
  863. lookupFailures = lookupFailures + 1
  864.  
  865. -- Keep the last known position visible while a single lookup
  866. -- fails. The next successful sample is compared against the old
  867. -- successful sample, preserving teleport detection.
  868. local previous = players[listedKey]
  869. if previous then
  870. previous._stale = true
  871. previous._lookupError = tostring(p)
  872. fresh[listedKey] = previous
  873. end
  874. end
  875. end
  876. end
  877.  
  878. -- Only a name missing from getOnlinePlayers() is an actual offline signal.
  879. -- A failed coordinate lookup above no longer resets tracking state.
  880. for key, trackedName in pairs(config.tracked) do
  881. if not seenOnline[key] then
  882. processOfflineTracked(trackedName, ts, "poll_offline")
  883. end
  884. end
  885.  
  886. players = fresh
  887. lastPollMs = ts
  888. lastGoodPollMs = ts
  889. lastLookupFailureCount = lookupFailures
  890.  
  891. if lookupFailures > 0 then
  892. setStatus(tostring(lookupFailures) .. " player lookup(s) temporarily failed; retaining last positions", 3)
  893. end
  894.  
  895. if not config.centerInitialized then
  896. local sumX, sumZ, count = 0, 0, 0
  897. local chosenDim = config.activeDim
  898. if not chosenDim then
  899. for _, p in pairs(players) do
  900. chosenDim = p.dimension
  901. break
  902. end
  903. end
  904. if chosenDim then
  905. for _, p in pairs(players) do
  906. if p.dimension == chosenDim then
  907. sumX = sumX + p.x
  908. sumZ = sumZ + p.z
  909. count = count + 1
  910. end
  911. end
  912. end
  913. if count > 0 then
  914. config.centerX = sumX / count
  915. config.centerZ = sumZ / count
  916. config.activeDim = chosenDim
  917. config.centerInitialized = true
  918. dirtyConfig = true
  919. end
  920. end
  921.  
  922. return true
  923. end
  924. local function getPlayer(name)
  925. if not name then return nil end
  926. return players[tostring(name):lower()]
  927. end
  928.  
  929. local function playerNamesForSidebar()
  930. local seen = {}
  931. local out = {}
  932.  
  933. for key, name in pairs(config.tracked) do
  934. local p = players[key]
  935. out[#out + 1] = { name = p and p.name or name, online = p ~= nil, tracked = true }
  936. seen[key] = true
  937. end
  938. for key, p in pairs(players) do
  939. if not seen[key] then
  940. out[#out + 1] = { name = p.name or key, online = true, tracked = false }
  941. end
  942. end
  943.  
  944. table.sort(out, function(a, b)
  945. if a.online ~= b.online then return a.online end
  946. if a.tracked ~= b.tracked then return a.tracked end
  947. return a.name:lower() < b.name:lower()
  948. end)
  949. return out
  950. end
  951.  
  952. local function getDimensions()
  953. local set = {}
  954. local out = {}
  955. if config.activeDim then set[config.activeDim] = true end
  956. for _, p in pairs(players) do if p.dimension then set[p.dimension] = true end end
  957. for i = math.max(1, #events - 100), #events do
  958. local ev = events[i]
  959. if ev then
  960. if ev.dim then set[ev.dim] = true end
  961. if ev.toDim then set[ev.toDim] = true end
  962. if ev.fromDim then set[ev.fromDim] = true end
  963. end
  964. end
  965. for dim in pairs(set) do out[#out + 1] = dim end
  966. table.sort(out)
  967. return out
  968. end
  969.  
  970. local function cycleDimension(direction)
  971. local dims = getDimensions()
  972. if #dims == 0 then return end
  973. local current = 1
  974. for i, d in ipairs(dims) do
  975. if d == config.activeDim then current = i break end
  976. end
  977. current = current + (direction or 1)
  978. if current > #dims then current = 1 end
  979. if current < 1 then current = #dims end
  980. config.activeDim = dims[current]
  981. dirtyConfig = true
  982. saveConfig()
  983. end
  984.  
  985. local function centerOnSelected()
  986. local p = getPlayer(selectedPlayer)
  987. if p then
  988. config.centerX = p.x
  989. config.centerZ = p.z
  990. config.activeDim = p.dimension
  991. config.centerInitialized = true
  992. dirtyConfig = true
  993. saveConfig()
  994. return
  995. end
  996.  
  997. local sumX, sumZ, count = 0, 0, 0
  998. for _, pl in pairs(players) do
  999. if not config.activeDim or pl.dimension == config.activeDim then
  1000. sumX = sumX + pl.x
  1001. sumZ = sumZ + pl.z
  1002. count = count + 1
  1003. end
  1004. end
  1005. if count > 0 then
  1006. config.centerX = sumX / count
  1007. config.centerZ = sumZ / count
  1008. config.centerInitialized = true
  1009. dirtyConfig = true
  1010. saveConfig()
  1011. end
  1012. end
  1013.  
  1014. local function zoom(delta)
  1015. config.zoomIndex = clamp(config.zoomIndex + delta, 1, #SETTINGS.ZOOM_LEVELS)
  1016. dirtyConfig = true
  1017. end
  1018.  
  1019. local function zoomAt(delta, sx, sy, mapRect)
  1020. local oldScale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  1021. local oldIndex = config.zoomIndex
  1022. config.zoomIndex = clamp(config.zoomIndex + delta, 1, #SETTINGS.ZOOM_LEVELS)
  1023. if config.zoomIndex == oldIndex then return end
  1024. local newScale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  1025.  
  1026. if mapRect and sx and sy then
  1027. local cx = (mapRect.x1 + mapRect.x2) / 2
  1028. local cy = (mapRect.y1 + mapRect.y2) / 2
  1029. local worldX = config.centerX + (sx - cx) * oldScale
  1030. local worldZ = config.centerZ + (sy - cy) * oldScale
  1031. config.centerX = worldX - (sx - cx) * newScale
  1032. config.centerZ = worldZ - (sy - cy) * newScale
  1033. end
  1034. dirtyConfig = true
  1035. end
  1036.  
  1037. local function facingArrow(yaw)
  1038. yaw = tonumber(yaw)
  1039. if not yaw then return "?" end
  1040. yaw = ((yaw % 360) + 360) % 360
  1041. if yaw >= 315 or yaw < 45 then return "v" end -- +Z south
  1042. if yaw < 135 then return "<" end -- west
  1043. if yaw < 225 then return "^" end -- north
  1044. return ">" -- east
  1045. end
  1046.  
  1047. local palette = {
  1048. colors.red, colors.orange, colors.yellow, colors.lime,
  1049. colors.cyan, colors.lightBlue, colors.blue, colors.purple,
  1050. colors.magenta, colors.pink, colors.white,
  1051. }
  1052.  
  1053. local function nameColor(name)
  1054. local hash = 0
  1055. name = tostring(name or "")
  1056. for i = 1, #name do hash = (hash * 33 + name:byte(i)) % 2147483647 end
  1057. return palette[(hash % #palette) + 1]
  1058. end
  1059.  
  1060. local buffer
  1061. local screenW, screenH = 0, 0
  1062. local currentView = nil
  1063. local hitboxes = {}
  1064. local mapRect = nil
  1065.  
  1066. -- Render the terminal and the monitor independently at their own native
  1067. -- character resolutions. v3 copied the small computer-terminal framebuffer
  1068. -- into the monitor, which is why the UI only occupied the top-left corner.
  1069. local terminalView = {
  1070. name = "terminal",
  1071. target = nativeTerm,
  1072. buffer = nil,
  1073. width = 0,
  1074. height = 0,
  1075. hitboxes = {},
  1076. mapRect = nil,
  1077. }
  1078.  
  1079. local monitorView = nil
  1080. if monitor then
  1081. monitorView = {
  1082. name = "monitor",
  1083. target = monitor,
  1084. buffer = nil,
  1085. width = 0,
  1086. height = 0,
  1087. hitboxes = {},
  1088. mapRect = nil,
  1089. }
  1090. end
  1091.  
  1092. local function ensureView(view)
  1093. if not view or not view.target then return false end
  1094. local w, h = view.target.getSize()
  1095. if not view.buffer or w ~= view.width or h ~= view.height then
  1096. view.width = w
  1097. view.height = h
  1098. view.buffer = window.create(view.target, 1, 1, w, h, false)
  1099. view.hitboxes = {}
  1100. view.mapRect = nil
  1101. end
  1102. return true
  1103. end
  1104.  
  1105. local function useView(view)
  1106. if not ensureView(view) then return false end
  1107. currentView = view
  1108. screen = view.target
  1109. buffer = view.buffer
  1110. screenW = view.width
  1111. screenH = view.height
  1112. hitboxes = view.hitboxes or {}
  1113. mapRect = view.mapRect
  1114. return true
  1115. end
  1116.  
  1117. local function storeViewState(view)
  1118. view.hitboxes = hitboxes
  1119. view.mapRect = mapRect
  1120. end
  1121.  
  1122. useView(terminalView)
  1123.  
  1124. local function setColors(fg, bg)
  1125. if bg then buffer.setBackgroundColor(bg) end
  1126. if fg then buffer.setTextColor(fg) end
  1127. end
  1128.  
  1129. local function writeAt(x, y, text, fg, bg)
  1130. if y < 1 or y > screenH then return end
  1131. text = tostring(text or "")
  1132. if x > screenW or x + #text - 1 < 1 then return end
  1133. if x < 1 then
  1134. text = text:sub(2 - x)
  1135. x = 1
  1136. end
  1137. if x + #text - 1 > screenW then
  1138. text = text:sub(1, screenW - x + 1)
  1139. end
  1140. if #text <= 0 then return end
  1141. setColors(fg or colors.white, bg or colors.black)
  1142. buffer.setCursorPos(x, y)
  1143. buffer.write(text)
  1144. end
  1145.  
  1146. local function fillRect(x1, y1, x2, y2, bg, ch, fg)
  1147. x1 = clamp(math.floor(x1), 1, screenW)
  1148. x2 = clamp(math.floor(x2), 1, screenW)
  1149. y1 = clamp(math.floor(y1), 1, screenH)
  1150. y2 = clamp(math.floor(y2), 1, screenH)
  1151. if x2 < x1 or y2 < y1 then return end
  1152. ch = ch or " "
  1153. local row = string.rep(ch, x2 - x1 + 1)
  1154. for y = y1, y2 do writeAt(x1, y, row, fg or colors.white, bg) end
  1155. end
  1156.  
  1157. local function addHit(x1, y1, x2, y2, action, data)
  1158. hitboxes[#hitboxes + 1] = {
  1159. x1 = x1, y1 = y1, x2 = x2, y2 = y2,
  1160. action = action, data = data,
  1161. }
  1162. end
  1163.  
  1164. local function buttonBox(x1, y1, width, height, label, action, data, active)
  1165. width = math.floor(width or 0)
  1166. height = math.floor(height or 1)
  1167. if width < 1 or height < 1 then return end
  1168.  
  1169. local bg = active and colors.lightBlue or colors.gray
  1170. local fg = active and colors.black or colors.white
  1171. fillRect(x1, y1, x1 + width - 1, y1 + height - 1, bg, " ")
  1172.  
  1173. local text = fit("[" .. tostring(label) .. "]", width)
  1174. local tx = x1 + math.max(0, math.floor((width - #text) / 2))
  1175. local ty = y1 + math.floor((height - 1) / 2)
  1176. writeAt(tx, ty, text, fg, bg)
  1177. addHit(x1, y1, x1 + width - 1, y1 + height - 1, action, data)
  1178. end
  1179.  
  1180. local function button(x1, y, width, label, action, data, active)
  1181. buttonBox(x1, y, width, 1, label, action, data, active)
  1182. end
  1183.  
  1184. local function getLayout()
  1185. local sideW
  1186. if screenW >= 90 then sideW = 30
  1187. elseif screenW >= 70 then sideW = 27
  1188. elseif screenW >= 50 then sideW = 23
  1189. else sideW = math.max(18, math.floor(screenW * 0.40)) end
  1190.  
  1191. local mapW = screenW - sideW
  1192. if mapW < 15 then
  1193. sideW = math.max(12, screenW - 15)
  1194. mapW = screenW - sideW
  1195. end
  1196.  
  1197. local controlH = clamp(SETTINGS.CONTROL_BAR_ROWS or 4, 2, math.max(2, screenH - 10))
  1198. local contentBottom = math.max(8, screenH - controlH)
  1199.  
  1200. return {
  1201. sideW = sideW,
  1202. mapW = mapW,
  1203. sideX = mapW + 1,
  1204. controlH = controlH,
  1205. controlY = contentBottom + 1,
  1206. contentBottom = contentBottom,
  1207. map = { x1 = 1, y1 = 3, x2 = mapW, y2 = contentBottom },
  1208. }
  1209. end
  1210. local function worldToScreen(x, z, rect, scale)
  1211. local cx = (rect.x1 + rect.x2) / 2
  1212. local cy = (rect.y1 + rect.y2) / 2
  1213. local sx = round(cx + (x - config.centerX) / scale)
  1214. local sy = round(cy + (z - config.centerZ) / scale)
  1215. return sx, sy
  1216. end
  1217.  
  1218. local function screenToWorld(sx, sy, rect, scale)
  1219. local cx = (rect.x1 + rect.x2) / 2
  1220. local cy = (rect.y1 + rect.y2) / 2
  1221. local wx = config.centerX + (sx - cx) * scale
  1222. local wz = config.centerZ + (sy - cy) * scale
  1223. return wx, wz
  1224. end
  1225.  
  1226. local function primaryMapView()
  1227. if monitorView and monitor then return monitorView end
  1228. return terminalView
  1229. end
  1230.  
  1231. local function choosePopulatedDimension()
  1232. local counts = {}
  1233. for _, p in pairs(players) do
  1234. if p.dimension then
  1235. counts[p.dimension] = (counts[p.dimension] or 0) + 1
  1236. end
  1237. end
  1238.  
  1239. if config.activeDim and (counts[config.activeDim] or 0) > 0 then
  1240. return config.activeDim
  1241. end
  1242.  
  1243. local bestDim, bestCount = nil, -1
  1244. -- Prefer overworld on ties, otherwise use the dimension containing most players.
  1245. for dim, count in pairs(counts) do
  1246. local better = count > bestCount
  1247. if count == bestCount and dim == "minecraft:overworld" then better = true end
  1248. if better then
  1249. bestDim = dim
  1250. bestCount = count
  1251. end
  1252. end
  1253. return bestDim
  1254. end
  1255.  
  1256. local function fitAllPlayers()
  1257. local dim = choosePopulatedDimension()
  1258. if not dim then return false end
  1259. config.activeDim = dim
  1260.  
  1261. local minX, maxX, minZ, maxZ, count
  1262. count = 0
  1263. for _, p in pairs(players) do
  1264. if p.dimension == dim then
  1265. if not minX then
  1266. minX, maxX = p.x, p.x
  1267. minZ, maxZ = p.z, p.z
  1268. else
  1269. minX = math.min(minX, p.x)
  1270. maxX = math.max(maxX, p.x)
  1271. minZ = math.min(minZ, p.z)
  1272. maxZ = math.max(maxZ, p.z)
  1273. end
  1274. count = count + 1
  1275. end
  1276. end
  1277. if count == 0 then return false end
  1278.  
  1279. local view = primaryMapView()
  1280. if not useView(view) then return false end
  1281. local layout = getLayout()
  1282. local rect = layout.map
  1283.  
  1284. config.centerX = (minX + maxX) / 2
  1285. config.centerZ = (minZ + maxZ) / 2
  1286.  
  1287. if count == 1 then
  1288. config.zoomIndex = clamp(SETTINGS.DEFAULT_ZOOM_INDEX, 1, #SETTINGS.ZOOM_LEVELS)
  1289. else
  1290. local mapWidth = rect.x2 - rect.x1 + 1
  1291. local mapHeight = rect.y2 - rect.y1 + 1
  1292. local usableW = math.max(2, mapWidth - SETTINGS.AUTO_FIT_HORIZONTAL_MARGIN * 2)
  1293. local usableH = math.max(2, mapHeight - SETTINGS.AUTO_FIT_VERTICAL_MARGIN * 2)
  1294. local spanX = math.max(1, maxX - minX)
  1295. local spanZ = math.max(1, maxZ - minZ)
  1296. local requiredScale = math.max(spanX / usableW, spanZ / usableH) * 1.08
  1297.  
  1298. local chosen = #SETTINGS.ZOOM_LEVELS
  1299. for i, scale in ipairs(SETTINGS.ZOOM_LEVELS) do
  1300. if scale >= requiredScale then
  1301. chosen = i
  1302. break
  1303. end
  1304. end
  1305. config.zoomIndex = chosen
  1306. end
  1307.  
  1308. config.centerInitialized = true
  1309. dirtyConfig = true
  1310. useView(terminalView)
  1311. return true
  1312. end
  1313.  
  1314. local function drawChunkGrid(rect, scale)
  1315. if scale > 32 then return end
  1316. local cx = (rect.x1 + rect.x2) / 2
  1317. local cy = (rect.y1 + rect.y2) / 2
  1318.  
  1319. local previousChunk
  1320. for sx = rect.x1, rect.x2 do
  1321. local wx = config.centerX + (sx - cx) * scale
  1322. local chunk = math.floor(wx / 16)
  1323. if previousChunk ~= nil and chunk ~= previousChunk then
  1324. for y = rect.y1, rect.y2 do
  1325. writeAt(sx, y, "|", colors.gray, colors.black)
  1326. end
  1327. end
  1328. previousChunk = chunk
  1329. end
  1330.  
  1331. previousChunk = nil
  1332. for sy = rect.y1, rect.y2 do
  1333. local wz = config.centerZ + (sy - cy) * scale
  1334. local chunk = math.floor(wz / 16)
  1335. if previousChunk ~= nil and chunk ~= previousChunk then
  1336. for x = rect.x1, rect.x2 do
  1337. writeAt(x, sy, "-", colors.gray, colors.black)
  1338. end
  1339. end
  1340. previousChunk = chunk
  1341. end
  1342. end
  1343.  
  1344. local function drawSelectedTrail(rect, scale)
  1345. if not selectedPlayer then return end
  1346. local key = tostring(selectedPlayer):lower()
  1347. local trail = trails[key]
  1348. if not trail then return end
  1349. for _, point in ipairs(trail) do
  1350. if point.dimension == config.activeDim then
  1351. local sx, sy = worldToScreen(point.x, point.z, rect, scale)
  1352. if sx >= rect.x1 and sx <= rect.x2 and sy >= rect.y1 and sy <= rect.y2 then
  1353. writeAt(sx, sy, ".", colors.lightGray, colors.black)
  1354. end
  1355. end
  1356. end
  1357. end
  1358.  
  1359. local function drawMap(layout)
  1360. local rect = layout.map
  1361. mapRect = rect
  1362. local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  1363.  
  1364. -- Deliberately blank map: no chunk grid, world-origin cross, or trail overlay.
  1365. fillRect(rect.x1, rect.y1, rect.x2, rect.y2, colors.black, " ")
  1366.  
  1367. local names = {}
  1368. for _, p in pairs(players) do
  1369. if p.dimension == config.activeDim then names[#names + 1] = p.name end
  1370. end
  1371. table.sort(names, function(a, b) return a:lower() < b:lower() end)
  1372.  
  1373. for _, name in ipairs(names) do
  1374. local p = getPlayer(name)
  1375. if p then
  1376. local sx, sy = worldToScreen(p.x, p.z, rect, scale)
  1377. if sx >= rect.x1 and sx <= rect.x2 and sy >= rect.y1 and sy <= rect.y2 then
  1378. local selected = selectedPlayer and selectedPlayer:lower() == name:lower()
  1379. local tracked = isTracked(name)
  1380. local char = selected and "X" or (tracked and "@" or "o")
  1381. local fg = selected and colors.yellow or nameColor(name)
  1382.  
  1383. -- Player marker.
  1384. writeAt(sx, sy, char, fg, colors.black)
  1385.  
  1386. -- Name above and X,Z below. Clicking any of these selects the player.
  1387. local label = tostring(name)
  1388. local coords = string.format("%.0f,%.0f", p.x, p.z)
  1389.  
  1390. local labelX = sx - math.floor(#label / 2)
  1391. local coordX = sx - math.floor(#coords / 2)
  1392. local y1, y2 = sy, sy
  1393. local x1, x2 = sx, sx
  1394.  
  1395. if sy - 1 >= rect.y1 then
  1396. local maxW = rect.x2 - rect.x1 + 1
  1397. label = fit(label, math.min(#label, maxW))
  1398. labelX = clamp(labelX, rect.x1, math.max(rect.x1, rect.x2 - #label + 1))
  1399. writeAt(labelX, sy - 1, label, fg, colors.black)
  1400. x1 = math.min(x1, labelX)
  1401. x2 = math.max(x2, labelX + #label - 1)
  1402. y1 = sy - 1
  1403. end
  1404.  
  1405. if sy + 1 <= rect.y2 then
  1406. local maxW = rect.x2 - rect.x1 + 1
  1407. coords = fit(coords, math.min(#coords, maxW))
  1408. coordX = clamp(coordX, rect.x1, math.max(rect.x1, rect.x2 - #coords + 1))
  1409. writeAt(coordX, sy + 1, coords, colors.lightGray, colors.black)
  1410. x1 = math.min(x1, coordX)
  1411. x2 = math.max(x2, coordX + #coords - 1)
  1412. y2 = sy + 1
  1413. end
  1414.  
  1415. addHit(x1, y1, x2, y2, "select_player", name)
  1416. end
  1417. end
  1418. end
  1419.  
  1420. local modeLabel = autoFit and "AUTO-FIT" or "MANUAL"
  1421. local controlsHint
  1422. if currentView and currentView.name == "monitor" then
  1423. controlsHint = "TOUCH CONTROLS BELOW"
  1424. else
  1425. controlsHint = "RIGHT-DRAG pan | WHEEL zoom"
  1426. end
  1427. local title = string.format(
  1428. " MAP %s %.0f,%.0f %db/c [%s] | %s ",
  1429. shortDim(config.activeDim), config.centerX, config.centerZ, scale, modeLabel, controlsHint
  1430. )
  1431. writeAt(rect.x1, 2, fit(title, layout.mapW), colors.white, colors.black)
  1432. end
  1433.  
  1434. local function eventCode(kind)
  1435. local map = {
  1436. teleport = "TP",
  1437. respawn = "RESP",
  1438. dimension = "DIM",
  1439. respawn_dimension = "R-DIM",
  1440. death = "DEAD",
  1441. join = "JOIN",
  1442. leave = "LEAVE",
  1443. seen = "SEEN",
  1444. dwell = "DWELL",
  1445. area_stay = "STAY",
  1446. respawn_change = "BED",
  1447. stats_snapshot = "STAT",
  1448. track_on = "TRK+",
  1449. track_off = "TRK-",
  1450. lookup_gap = "GAP",
  1451. }
  1452. return map[kind] or tostring(kind or "?"):upper()
  1453. end
  1454.  
  1455. local function filteredEvents()
  1456. local out = {}
  1457. local filter = selectedPlayer and selectedPlayer:lower() or nil
  1458. for i = #events, 1, -1 do
  1459. local ev = events[i]
  1460. if not filter or (ev.player and tostring(ev.player):lower() == filter) then
  1461. out[#out + 1] = ev
  1462. end
  1463. end
  1464. return out
  1465. end
  1466.  
  1467. local function formatEventLine(ev, width)
  1468. local stamp = "?"
  1469. if type(ev.ts) == "number" then
  1470. local ok, s = pcall(os.date, "!%d/%m %H:%M:%S", ev.ts / 1000)
  1471. if ok then stamp = s end
  1472. end
  1473.  
  1474. local kind = tostring(ev.type or "?")
  1475. local player = tostring(ev.player or "?")
  1476. local line
  1477.  
  1478. if kind == "teleport" or kind == "respawn" then
  1479. line = string.format(
  1480. "%s %-4s %-12s %.0f,%.0f,%.0f -> %.0f,%.0f,%.0f %.0fb in %.2fs",
  1481. stamp, eventCode(kind), player,
  1482. tonumber(ev.fromX) or 0, tonumber(ev.fromY) or 0, tonumber(ev.fromZ) or 0,
  1483. tonumber(ev.toX) or 0, tonumber(ev.toY) or 0, tonumber(ev.toZ) or 0,
  1484. tonumber(ev.distance) or 0, tonumber(ev.seconds) or 0
  1485. )
  1486. elseif kind == "dimension" or kind == "respawn_dimension" then
  1487. line = string.format(
  1488. "%s %-5s %-12s %s -> %s @ %.0f,%.0f,%.0f",
  1489. stamp, eventCode(kind), player,
  1490. shortDim(ev.fromDim), shortDim(ev.toDim),
  1491. tonumber(ev.toX) or 0, tonumber(ev.toY) or 0, tonumber(ev.toZ) or 0
  1492. )
  1493. elseif kind == "area_stay" or kind == "dwell" then
  1494. local x, y, z = eventPosition(ev)
  1495. line = string.format(
  1496. "%s %-5s %-12s @ %.0f,%.0f,%.0f %s (%s)",
  1497. stamp, eventCode(kind), player,
  1498. tonumber(x) or 0, tonumber(y) or 0, tonumber(z) or 0,
  1499. formatDuration(ev.duration), tostring(ev.startedBy or ev.reason or "area")
  1500. )
  1501. else
  1502. local x, y, z = eventPosition(ev)
  1503. local coord = x and string.format(" @ %.0f,%.0f,%.0f", x, y or 0, z or 0) or ""
  1504. line = string.format("%s %-5s %-12s%s", stamp, eventCode(kind), player, coord)
  1505. end
  1506.  
  1507. return fit(line, width)
  1508. end
  1509. local function drawEvents(layout)
  1510. local rect = layout.map
  1511. mapRect = rect
  1512. fillRect(rect.x1, rect.y1, rect.x2, rect.y2, colors.black, " ")
  1513. local filter = selectedPlayer and selectedPlayer or "ALL"
  1514. writeAt(rect.x1, 2, fit(" EVENTS filter: " .. filter, layout.mapW), colors.white, colors.black)
  1515.  
  1516. local list = filteredEvents()
  1517. local row = rect.y1
  1518. local start = 1 + eventOffset
  1519. for i = start, math.min(#list, start + (rect.y2 - rect.y1)) do
  1520. local ev = list[i]
  1521. local fg = colors.white
  1522. if ev.type == "teleport" or ev.type == "respawn" then fg = colors.yellow
  1523. elseif ev.type == "death" then fg = colors.red
  1524. elseif ev.type == "dwell" or ev.type == "area_stay" then fg = colors.lime
  1525. elseif ev.type == "dimension" or ev.type == "respawn_dimension" then fg = colors.cyan
  1526. end
  1527. writeAt(rect.x1, row, formatEventLine(ev, layout.mapW), fg, colors.black)
  1528. addHit(rect.x1, row, rect.x2, row, "event_jump", ev)
  1529. row = row + 1
  1530. if row > rect.y2 then break end
  1531. end
  1532.  
  1533. if #list == 0 then
  1534. writeAt(rect.x1 + 1, rect.y1 + 1, "No matching events", colors.lightGray, colors.black)
  1535. elseif start > #list then
  1536. eventOffset = math.max(0, #list - 1)
  1537. end
  1538. end
  1539.  
  1540. local function freeSpaceText()
  1541. local ok, free = pcall(fs.getFreeSpace, "/")
  1542. if not ok then return "?" end
  1543. if type(free) == "string" then return free end
  1544. if free >= 1000000 then return string.format("%.1fM", free / 1000000) end
  1545. if free >= 1000 then return string.format("%.0fK", free / 1000) end
  1546. return tostring(free)
  1547. end
  1548.  
  1549. local function drawSidebar(layout)
  1550. local x = layout.sideX
  1551. local w = layout.sideW
  1552. local bottom = layout.contentBottom
  1553. fillRect(x, 1, screenW, bottom, colors.gray, " ")
  1554.  
  1555. local onlineCount = 0
  1556. for _ in pairs(players) do onlineCount = onlineCount + 1 end
  1557. writeAt(x + 1, 1, fit("PLAYER TRACKER", w - 2), colors.yellow, colors.gray)
  1558.  
  1559. local age = lastGoodPollMs > 0 and ((nowMs() - lastGoodPollMs) / 1000) or -1
  1560. local pollText
  1561. if lastPollError then
  1562. pollText = "POLL ERROR"
  1563. elseif age >= 0 then
  1564. pollText = string.format("Online %d upd %.1fs", onlineCount, age)
  1565. else
  1566. pollText = "Online " .. tostring(onlineCount)
  1567. end
  1568. writeAt(x + 1, 2, fit(pollText, w - 2), lastPollError and colors.red or colors.white, colors.gray)
  1569.  
  1570. local detailsRows = 6
  1571. local listStart = 4
  1572. local listEnd = math.max(listStart, bottom - detailsRows)
  1573. local listRows = math.max(1, listEnd - listStart + 1)
  1574.  
  1575. writeAt(x + 1, 3, fit("PLAYERS @=tracked", w - 2), colors.lightGray, colors.gray)
  1576. local names = playerNamesForSidebar()
  1577. playerListOffset = clamp(playerListOffset, 0, math.max(0, #names - listRows))
  1578.  
  1579. for row = 0, listRows - 1 do
  1580. local item = names[playerListOffset + row + 1]
  1581. local y = listStart + row
  1582. if item then
  1583. local selected = selectedPlayer and selectedPlayer:lower() == item.name:lower()
  1584. local prefix = (item.tracked and "@" or " ") .. (item.online and "+" or "-") .. " "
  1585. local bg = selected and colors.lightBlue or colors.gray
  1586. local fg = selected and colors.black or (item.online and colors.white or colors.lightGray)
  1587. local text = fit(prefix .. item.name, w - 2)
  1588. writeAt(x + 1, y, text .. string.rep(" ", math.max(0, w - 2 - #text)), fg, bg)
  1589. -- Make each player row easy to touch across the full sidebar width.
  1590. addHit(x, y, screenW, y, "select_player", item.name)
  1591. end
  1592. end
  1593.  
  1594. local detailY = listEnd + 1
  1595. local p = getPlayer(selectedPlayer)
  1596. if selectedPlayer then
  1597. writeAt(x + 1, detailY, fit("SEL " .. selectedPlayer, w - 2), colors.yellow, colors.gray)
  1598. if p then
  1599. writeAt(x + 1, detailY + 1, fit(string.format("XYZ %.0f %.0f %.0f", p.x, p.y or 0, p.z), w - 2), colors.white, colors.gray)
  1600. writeAt(x + 1, detailY + 2, fit("DIM " .. shortDim(p.dimension), w - 2), colors.white, colors.gray)
  1601. local hp = tonumber(p.health)
  1602. local maxHp = tonumber(p.maxHealth)
  1603. local line = hp and string.format("HP %.1f/%.1f %s", hp, maxHp or 0, facingArrow(p.yaw)) or ("LOOK " .. facingArrow(p.yaw))
  1604. writeAt(x + 1, detailY + 3, fit(line, w - 2), colors.white, colors.gray)
  1605. writeAt(x + 1, detailY + 4, fit(isTracked(selectedPlayer) and "TRACKED" or "not tracked", w - 2), isTracked(selectedPlayer) and colors.lime or colors.lightGray, colors.gray)
  1606. if p._stale then
  1607. writeAt(x + 1, detailY + 5, fit("position temporarily stale", w - 2), colors.orange, colors.gray)
  1608. end
  1609. else
  1610. writeAt(x + 1, detailY + 1, "offline", colors.lightGray, colors.gray)
  1611. end
  1612. else
  1613. writeAt(x + 1, detailY, "SEL none", colors.lightGray, colors.gray)
  1614. writeAt(x + 1, detailY + 1, "Touch/click a player", colors.lightGray, colors.gray)
  1615. end
  1616. end
  1617.  
  1618. local function drawButtonRow(y, height, specs)
  1619. local count = #specs
  1620. if count == 0 then return end
  1621. local gap = 1
  1622. local usable = screenW - gap * (count - 1)
  1623. local base = math.floor(usable / count)
  1624. local remainder = usable - base * count
  1625. local x = 1
  1626.  
  1627. for i, spec in ipairs(specs) do
  1628. local width = base + (i <= remainder and 1 or 0)
  1629. buttonBox(x, y, width, height, spec[1], spec[2], spec[3], spec[4])
  1630. x = x + width + gap
  1631. end
  1632. end
  1633.  
  1634. local function drawBottomControls(layout)
  1635. local y = layout.controlY
  1636. local h = layout.controlH
  1637. fillRect(1, y, screenW, screenH, colors.black, " ")
  1638.  
  1639. -- Two large touch rows. Advanced Monitors only report monitor_touch, so
  1640. -- these are intentionally much larger than the old one-character buttons.
  1641. local row1H = math.max(1, math.floor(h / 2))
  1642. local row2H = h - row1H
  1643.  
  1644. if activeView == "map" then
  1645. drawButtonRow(y, row1H, {
  1646. {"< LEFT", "pan", {dx=-1, dz=0}},
  1647. {"^ UP", "pan", {dx=0, dz=-1}},
  1648. {"DOWN v", "pan", {dx=0, dz=1}},
  1649. {"RIGHT >", "pan", {dx=1, dz=0}},
  1650. {"ZOOM -", "zoom", 1},
  1651. {"ZOOM +", "zoom", -1},
  1652. })
  1653. drawButtonRow(y + row1H, row2H, {
  1654. {autoFit and "AUTO FIT*" or "AUTO FIT", "auto_fit", nil, autoFit},
  1655. {"CENTER", "center"},
  1656. {"DIM <", "cycle_dim", -1},
  1657. {"DIM >", "cycle_dim", 1},
  1658. {"EVENTS", "toggle_view"},
  1659. {isTracked(selectedPlayer or "") and "UNTRACK" or "TRACK", "toggle_track"},
  1660. })
  1661. else
  1662. drawButtonRow(y, row1H, {
  1663. {"MAP", "toggle_view"},
  1664. {"PAGE UP", "event_page", -1},
  1665. {"PAGE DOWN", "event_page", 1},
  1666. {"NEWEST", "event_newest"},
  1667. {"CLEAR FILTER", "clear_selection"},
  1668. })
  1669. drawButtonRow(y + row1H, row2H, {
  1670. {isTracked(selectedPlayer or "") and "UNTRACK" or "TRACK", "toggle_track"},
  1671. {"CENTER", "center"},
  1672. {"DIM <", "cycle_dim", -1},
  1673. {"DIM >", "cycle_dim", 1},
  1674. {"STATS", "stats"},
  1675. {"RAW", "raw"},
  1676. })
  1677. end
  1678. end
  1679. local function renderView(view)
  1680. if not useView(view) then return end
  1681.  
  1682. hitboxes = {}
  1683. mapRect = nil
  1684. buffer.setVisible(false)
  1685. buffer.setBackgroundColor(colors.black)
  1686. buffer.setTextColor(colors.white)
  1687. buffer.clear()
  1688.  
  1689. if screenW < 30 or screenH < 12 then
  1690. writeAt(1, 1, "Display too small. Enlarge this display.", colors.red, colors.black)
  1691. storeViewState(view)
  1692. buffer.setVisible(true)
  1693. return
  1694. end
  1695.  
  1696. local layout = getLayout()
  1697. if activeView == "events" then drawEvents(layout) else drawMap(layout) end
  1698. drawSidebar(layout)
  1699. drawBottomControls(layout)
  1700.  
  1701. storeViewState(view)
  1702. buffer.setVisible(true)
  1703. end
  1704.  
  1705. local function render()
  1706. -- Each display gets a full render using its own getSize(). This makes a
  1707. -- wall-sized monitor use all of its cells instead of receiving a tiny
  1708. -- 51x19 terminal copy in the top-left corner.
  1709. renderView(terminalView)
  1710. if monitorView and monitor then renderView(monitorView) end
  1711. useView(terminalView)
  1712. lastRenderMs = nowMs()
  1713. end
  1714.  
  1715. local function hitAt(x, y)
  1716. for i = #hitboxes, 1, -1 do
  1717. local h = hitboxes[i]
  1718. if x >= h.x1 and x <= h.x2 and y >= h.y1 and y <= h.y2 then return h end
  1719. end
  1720. return nil
  1721. end
  1722.  
  1723. local function executeAction(action, data)
  1724. if action == "select_player" then
  1725. selectedPlayer = tostring(data)
  1726. local p = getPlayer(selectedPlayer)
  1727. if p and activeView == "map" then
  1728. config.activeDim = p.dimension
  1729. if autoFit then fitAllPlayers() end
  1730. end
  1731. eventOffset = 0
  1732. elseif action == "toggle_track" then
  1733. if selectedPlayer then toggleTracked(selectedPlayer) else setStatus("select a player first") end
  1734. elseif action == "toggle_view" then
  1735. activeView = activeView == "map" and "events" or "map"
  1736. eventOffset = 0
  1737. elseif action == "pan" and data then
  1738. autoFit = false
  1739. local layout = getLayout()
  1740. local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  1741. local stepX = math.max(5, math.floor(layout.mapW * 0.30)) * scale
  1742. local stepZ = math.max(5, math.floor((screenH - 2) * 0.30)) * scale
  1743. config.centerX = config.centerX + (data.dx or 0) * stepX
  1744. config.centerZ = config.centerZ + (data.dz or 0) * stepZ
  1745. config.centerInitialized = true
  1746. dirtyConfig = true
  1747. elseif action == "zoom" then
  1748. autoFit = false
  1749. zoom(tonumber(data) or 0)
  1750. elseif action == "auto_fit" then
  1751. autoFit = true
  1752. fitAllPlayers()
  1753. setStatus("auto-fit enabled; drag or zoom for manual mode", 5)
  1754. elseif action == "center" then
  1755. autoFit = false
  1756. centerOnSelected()
  1757. elseif action == "cycle_dim" then
  1758. cycleDimension(tonumber(data) or 1)
  1759. if autoFit then fitAllPlayers() end
  1760. elseif action == "event_page" then
  1761. local layout = getLayout()
  1762. local page = math.max(1, layout.map.y2 - layout.map.y1)
  1763. eventOffset = math.max(0, eventOffset + (tonumber(data) or 0) * page)
  1764. elseif action == "event_newest" then
  1765. eventOffset = 0
  1766. elseif action == "clear_selection" then
  1767. selectedPlayer = nil
  1768. eventOffset = 0
  1769. elseif action == "stats" then
  1770. captureStats(selectedPlayer)
  1771. elseif action == "raw" then
  1772. dumpRaw(selectedPlayer)
  1773. elseif action == "event_jump" and type(data) == "table" then
  1774. local x, y, z, dim = eventPosition(data)
  1775. if x and z then
  1776. autoFit = false
  1777. config.centerX = x
  1778. config.centerZ = z
  1779. config.centerInitialized = true
  1780. if dim then config.activeDim = dim end
  1781. if data.player then selectedPlayer = tostring(data.player) end
  1782. activeView = "map"
  1783. dirtyConfig = true
  1784. else
  1785. setStatus("event has no coordinates")
  1786. end
  1787. end
  1788. end
  1789.  
  1790. local function handlePointer(x, y, source)
  1791. local view = source == "monitor" and monitorView or terminalView
  1792. if view then useView(view) end
  1793.  
  1794. local h = hitAt(x, y)
  1795. if h then
  1796. executeAction(h.action, h.data)
  1797. return true
  1798. end
  1799.  
  1800. if source == "monitor" and activeView == "map" and mapRect and
  1801. x >= mapRect.x1 and x <= mapRect.x2 and y >= mapRect.y1 and y <= mapRect.y2 then
  1802. -- Advanced Monitors only emit monitor_touch. Empty-map touches are
  1803. -- deliberately ignored: accidental recentering made the old monitor
  1804. -- controls feel broken. Use the large bottom pan/zoom buttons instead.
  1805. return true
  1806. end
  1807.  
  1808. return false
  1809. end
  1810.  
  1811. local function handleKey(code)
  1812. if code == keys.q then
  1813. running = false
  1814. elseif code == keys.m then
  1815. activeView = "map"
  1816. elseif code == keys.e then
  1817. activeView = "events"
  1818. eventOffset = 0
  1819. elseif code == keys.t then
  1820. if selectedPlayer then toggleTracked(selectedPlayer) end
  1821. elseif code == keys.c then
  1822. autoFit = false
  1823. centerOnSelected()
  1824. elseif code == keys.d then
  1825. cycleDimension(1)
  1826. elseif code == keys.s then
  1827. captureStats(selectedPlayer)
  1828. elseif code == keys.r then
  1829. dumpRaw(selectedPlayer)
  1830. elseif code == keys.equals or code == keys.numPadAdd then
  1831. autoFit = false
  1832. zoom(-1)
  1833. elseif code == keys.minus or code == keys.numPadSubtract then
  1834. autoFit = false
  1835. zoom(1)
  1836. elseif code == keys.left then
  1837. executeAction("pan", { dx = -1, dz = 0 })
  1838. elseif code == keys.right then
  1839. executeAction("pan", { dx = 1, dz = 0 })
  1840. elseif code == keys.up then
  1841. if activeView == "events" then
  1842. eventOffset = math.max(0, eventOffset - 1)
  1843. else
  1844. executeAction("pan", { dx = 0, dz = -1 })
  1845. end
  1846. elseif code == keys.down then
  1847. if activeView == "events" then
  1848. eventOffset = eventOffset + 1
  1849. else
  1850. executeAction("pan", { dx = 0, dz = 1 })
  1851. end
  1852. elseif code == keys.home then
  1853. selectedPlayer = nil
  1854. centerOnSelected()
  1855. end
  1856. end
  1857.  
  1858. local function handlePlayerEvent(ev)
  1859. local kind = ev[1]
  1860. local ts = nowMs()
  1861.  
  1862. if kind == "player_join" then
  1863. local _, uuid, username, dimension = table.unpack(ev)
  1864. if username then pendingJoin[tostring(username):lower()] = { ts = ts, uuid = uuid, dimension = dimension } end
  1865. elseif kind == "player_leave" then
  1866. local _, uuid, username, dimension = table.unpack(ev)
  1867. if username and isTracked(username) then
  1868. local key = tostring(username):lower()
  1869. local state = trackerState[key]
  1870. if state and state.online then
  1871. closeArea(username, state, ts, "offline", state.last)
  1872. local last = state.last
  1873. logEvent("leave", username, {
  1874. x = last and last.x,
  1875. y = last and last.y,
  1876. z = last and last.z,
  1877. dim = dimension or (last and last.dimension),
  1878. uuid = uuid,
  1879. })
  1880. state.online = false
  1881. end
  1882. end
  1883. elseif kind == "player_changed_dimension" then
  1884. local _, uuid, username, fromDim, toDim = table.unpack(ev)
  1885. if username then
  1886. pendingDimension[tostring(username):lower()] = {
  1887. ts = ts, uuid = uuid, fromDim = fromDim, toDim = toDim,
  1888. }
  1889. end
  1890. elseif kind == "player_death" then
  1891. local _, uuid, username, damageSource = table.unpack(ev)
  1892. if username and isTracked(username) then
  1893. local key = tostring(username):lower()
  1894. local state = trackerState[key] or {}
  1895. trackerState[key] = state
  1896. state.lastDeathMs = ts
  1897. local last = state.last
  1898. logEvent("death", username, {
  1899. x = last and last.x,
  1900. y = last and last.y,
  1901. z = last and last.z,
  1902. dim = last and last.dimension,
  1903. uuid = uuid,
  1904. damageSource = damageSource,
  1905. })
  1906. end
  1907. end
  1908. end
  1909.  
  1910. -- Try to expose useful detector config information in the initial event log/status.
  1911. do
  1912. local ok, cfg = pcall(detector.getConfiguration)
  1913. if ok and type(cfg) == "table" then
  1914. local encoded = jsonEncode(safeCopy(cfg))
  1915. if encoded then
  1916. local path = DATA_DIR .. "/detector_config.json"
  1917. local h = fs.open(path, "w")
  1918. if h then h.write(encoded) h.close() end
  1919. end
  1920. end
  1921. end
  1922.  
  1923. pollPlayers()
  1924. if autoFit then fitAllPlayers() end
  1925. render()
  1926. setStatus(monitor and ("monitor touch controls active: " .. tostring(monitorName)) or "terminal mode", 5)
  1927.  
  1928. local pollTimer = os.startTimer(SETTINGS.POLL_SECONDS)
  1929. local renderTimer = os.startTimer(SETTINGS.RENDER_SECONDS)
  1930. local saveTimer = os.startTimer(10)
  1931.  
  1932. while running do
  1933. local ev = { os.pullEventRaw() }
  1934. local kind = ev[1]
  1935.  
  1936. if kind == "terminate" then
  1937. running = false
  1938.  
  1939. elseif kind == "timer" then
  1940. if ev[2] == pollTimer then
  1941. local okPoll, pollResult = pcall(pollPlayers)
  1942. if not okPoll then
  1943. lastPollError = tostring(pollResult)
  1944. setStatus("poll crashed: " .. tostring(pollResult), 8)
  1945. end
  1946. if autoFit then pcall(fitAllPlayers) end
  1947. -- Render immediately after every coordinate sample. This removes
  1948. -- the stale-looking map behaviour from v4 where polling and drawing
  1949. -- were on independent timers.
  1950. pcall(render)
  1951. pollTimer = os.startTimer(SETTINGS.POLL_SECONDS)
  1952. elseif ev[2] == renderTimer then
  1953. pcall(render)
  1954. renderTimer = os.startTimer(SETTINGS.RENDER_SECONDS)
  1955. elseif ev[2] == saveTimer then
  1956. if dirtyConfig then
  1957. saveConfig()
  1958. dirtyConfig = false
  1959. end
  1960. saveTimer = os.startTimer(10)
  1961. end
  1962.  
  1963. elseif kind == "monitor_touch" and monitor and monitorView and ev[2] == monitorName then
  1964. useView(monitorView)
  1965. handlePointer(ev[3], ev[4], "monitor")
  1966. pcall(render)
  1967.  
  1968. elseif kind == "mouse_click" then
  1969. useView(terminalView)
  1970. local buttonId, x, y = ev[2], ev[3], ev[4]
  1971. local inMap = activeView == "map" and mapRect and
  1972. x >= mapRect.x1 and x <= mapRect.x2 and
  1973. y >= mapRect.y1 and y <= mapRect.y2
  1974.  
  1975. -- RIGHT mouse button is reserved for map dragging. We deliberately
  1976. -- do not run hit-testing first, so you can begin a drag on top of a
  1977. -- player marker/name without accidentally selecting it.
  1978. if buttonId == 2 and inMap then
  1979. dragState = { button = 2, x = x, y = y, moved = false }
  1980. elseif buttonId == 1 then
  1981. dragState = nil
  1982. handlePointer(x, y, "terminal")
  1983. pcall(render)
  1984. else
  1985. dragState = nil
  1986. end
  1987.  
  1988. elseif kind == "mouse_drag" and dragState and activeView == "map" then
  1989. useView(terminalView)
  1990. local buttonId, x, y = ev[2], ev[3], ev[4]
  1991. if buttonId == dragState.button then
  1992. local dx = x - dragState.x
  1993. local dy = y - dragState.y
  1994. if dx ~= 0 or dy ~= 0 then
  1995. autoFit = false
  1996. local scale = SETTINGS.ZOOM_LEVELS[config.zoomIndex]
  1997. config.centerX = config.centerX - dx * scale
  1998. config.centerZ = config.centerZ - dy * scale
  1999. config.centerInitialized = true
  2000. dirtyConfig = true
  2001. dragState.x = x
  2002. dragState.y = y
  2003. dragState.moved = true
  2004. pcall(render)
  2005. end
  2006. end
  2007.  
  2008. elseif kind == "mouse_up" then
  2009. if not dragState or ev[2] == dragState.button then
  2010. dragState = nil
  2011. end
  2012.  
  2013. elseif kind == "mouse_scroll" then
  2014. useView(terminalView)
  2015. local direction, x, y = ev[2], ev[3], ev[4]
  2016. local layout = getLayout()
  2017. if x <= layout.mapW then
  2018. if activeView == "map" then
  2019. autoFit = false
  2020. zoomAt(direction > 0 and 1 or -1, x, y, layout.map)
  2021. else
  2022. eventOffset = math.max(0, eventOffset + direction * 3)
  2023. end
  2024. else
  2025. playerListOffset = math.max(0, playerListOffset + direction)
  2026. end
  2027. pcall(render)
  2028.  
  2029. elseif kind == "key" then
  2030. handleKey(ev[2])
  2031.  
  2032. elseif kind == "term_resize" then
  2033. terminalView.buffer = nil
  2034. pcall(render)
  2035.  
  2036. elseif kind == "monitor_resize" and monitor and monitorView and ev[2] == monitorName then
  2037. monitorView.buffer = nil
  2038. if autoFit then pcall(fitAllPlayers) end
  2039. pcall(render)
  2040.  
  2041. elseif kind == "peripheral_detach" and monitor and ev[2] == monitorName then
  2042. monitor = nil
  2043. monitorName = nil
  2044. monitorView = nil
  2045. useView(terminalView)
  2046. if autoFit then pcall(fitAllPlayers) end
  2047. setStatus("monitor detached; terminal still active", 6)
  2048.  
  2049. elseif kind == "player_join" or kind == "player_leave" or
  2050. kind == "player_changed_dimension" or kind == "player_death" then
  2051. pcall(handlePlayerEvent, ev)
  2052. end
  2053. end
  2054.  
  2055. saveConfig()
  2056. nativeTerm.setBackgroundColor(colors.black)
  2057. nativeTerm.setTextColor(colors.white)
  2058. nativeTerm.clear()
  2059. nativeTerm.setCursorPos(1, 1)
  2060. print("Player tracker stopped.")
  2061.  
Advertisement
Add Comment
Please, Sign In to add comment