AndyE7

Untitled

Aug 29th, 2026
17
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 54.78 KB | None | 0 0
  1. --[[
  2. MD_Sky Persistent Player Tracker
  3. Minecraft 1.21.1 / CC:Tweaked / Advanced Peripherals
  4.  
  5. Supports Advanced Peripherals 0.7.x and 0.8.x naming where practical.
  6.  
  7. Data directory:
  8. /md_sky_tracker/events.log high-level append-only timeline (JSONL)
  9. /md_sky_tracker/movements.log compact every-block movement stream
  10. /md_sky_tracker/locations.json discovered location database
  11. /md_sky_tracker/state.json crash/reboot recovery state
  12. /md_sky_tracker/player_debug.json last raw player object seen
  13.  
  14. Important server-side Advanced Peripherals settings for worldwide tracking:
  15. playerDetMaxRange = -1
  16. enablePlayerPosFunction = true
  17. morePlayerInformation = true
  18. multidimensional player detector setting = true
  19. enablePlayerPosRandomError = false -- if your AP version has this option
  20.  
  21. On some 1.21.1 0.7.x configs the multidimensional option under
  22. [Peripherals.Player_Detector] is unfortunately named chatBoxMultiDimensional.
  23. The AP docs call the setting playerDetMultiDimensional.
  24.  
  25. The program deliberately does NOT log yaw/pitch camera rotation.
  26. It logs a movement sample only when the player's integer block changes.
  27. --]]
  28.  
  29. local CONFIG = {
  30. TARGET = "MD_Sky",
  31.  
  32. -- Tracking behaviour
  33. POLL_SECONDS = 0.10,
  34. TELEPORT_DISTANCE = 50, -- blocks between consecutive valid samples
  35. TELEPORT_MAX_GAP_MS = 1000, -- don't call a long observation gap a teleport
  36. TELEPORT_MIN_SPEED = 200, -- blocks/second; reduces false positives from fast flight
  37. LOCATION_RADIUS = 150, -- player is considered "at" a location inside this radius
  38. LOCATION_MERGE_RADIUS = 150, -- teleport destinations this close share the same location
  39. DWELL_DISCOVERY_SECONDS = 300, -- create a candidate location after 5 min inside a 150b area
  40. ENABLE_DWELL_DISCOVERY = true,
  41. LOG_HEALTH_CHANGES = true,
  42.  
  43. -- Persistence/UI
  44. DATA_DIR = "/md_sky_tracker",
  45. MONITOR_TEXT_SCALE = 0.5,
  46. STATE_SAVE_SECONDS = 5,
  47. MOVEMENT_ANCHOR_EVERY = 256, -- absolute movement record every N samples
  48. MIN_FREE_SPACE = 50000, -- stop movement logging before the computer disk is totally full
  49. STORAGE_RECOVER_SPACE = 100000,
  50. DEBUG_PLAYER_DUMP_SECONDS = 60,
  51. }
  52.  
  53. local PATH = {
  54. events = CONFIG.DATA_DIR .. "/events.log",
  55. movements = CONFIG.DATA_DIR .. "/movements.log",
  56. locations = CONFIG.DATA_DIR .. "/locations.json",
  57. state = CONFIG.DATA_DIR .. "/state.json",
  58. debugPlayer = CONFIG.DATA_DIR .. "/player_debug.json",
  59. }
  60.  
  61. -- ---------------------------------------------------------------------------
  62. -- Basic helpers
  63. -- ---------------------------------------------------------------------------
  64.  
  65. local function nowMs()
  66. return os.epoch("utc")
  67. end
  68.  
  69. local function formatTimestamp(ms, short)
  70. if not ms then return "?" end
  71. local seconds = math.floor(ms / 1000)
  72. if short then
  73. return os.date("%m-%d %H:%M:%S", seconds)
  74. end
  75. return os.date("%Y-%m-%d %H:%M:%S", seconds)
  76. end
  77.  
  78. local function formatDuration(ms)
  79. if not ms or ms < 0 then return "?" end
  80. local s = math.floor(ms / 1000)
  81. local d = math.floor(s / 86400)
  82. s = s % 86400
  83. local h = math.floor(s / 3600)
  84. s = s % 3600
  85. local m = math.floor(s / 60)
  86. s = s % 60
  87.  
  88. if d > 0 then
  89. return string.format("%dd %02dh %02dm", d, h, m)
  90. elseif h > 0 then
  91. return string.format("%dh %02dm %02ds", h, m, s)
  92. elseif m > 0 then
  93. return string.format("%dm %02ds", m, s)
  94. else
  95. return string.format("%ds", s)
  96. end
  97. end
  98.  
  99. local function floorCoord(n)
  100. if type(n) ~= "number" then return nil end
  101. return math.floor(n)
  102. end
  103.  
  104. local function sameBlock(a, b)
  105. if not a or not b then return false end
  106. return a.dim == b.dim
  107. and floorCoord(a.x) == floorCoord(b.x)
  108. and floorCoord(a.y) == floorCoord(b.y)
  109. and floorCoord(a.z) == floorCoord(b.z)
  110. end
  111.  
  112. local function distance3(a, b)
  113. if not a or not b or a.dim ~= b.dim then return math.huge end
  114. local dx = a.x - b.x
  115. local dy = a.y - b.y
  116. local dz = a.z - b.z
  117. return math.sqrt(dx * dx + dy * dy + dz * dz)
  118. end
  119.  
  120. local function posString(p)
  121. if not p then return "?,?,?" end
  122. local x, y, z = floorCoord(p.x), floorCoord(p.y), floorCoord(p.z)
  123. if x == nil or y == nil or z == nil then return "?,?,?" end
  124. return string.format("%d,%d,%d", x, y, z)
  125. end
  126.  
  127. local function dimShort(dim)
  128. if type(dim) ~= "string" then return tostring(dim or "?") end
  129. local namespace, path = dim:match("^([^:]+):(.+)$")
  130. if namespace == "minecraft" then return path end
  131. return dim
  132. end
  133.  
  134. local function safeJsonEncode(value)
  135. local ok, encoded = pcall(textutils.serializeJSON, value)
  136. if ok then return encoded end
  137. return nil
  138. end
  139.  
  140. local function safeJsonDecode(line)
  141. local ok, decoded = pcall(textutils.unserializeJSON, line)
  142. if ok then return decoded end
  143. return nil
  144. end
  145.  
  146. local function ensureDataDir()
  147. if not fs.exists(CONFIG.DATA_DIR) then
  148. fs.makeDir(CONFIG.DATA_DIR)
  149. end
  150. end
  151.  
  152. local function readJsonFile(path, fallback)
  153. if not fs.exists(path) then return fallback end
  154. local h = fs.open(path, "r")
  155. if not h then return fallback end
  156. local data = h.readAll()
  157. h.close()
  158. if not data or data == "" then return fallback end
  159. local decoded = safeJsonDecode(data)
  160. if decoded == nil then return fallback end
  161. return decoded
  162. end
  163.  
  164. local function writeJsonAtomic(path, value)
  165. local encoded = safeJsonEncode(value)
  166. if not encoded then return false, "JSON encode failed" end
  167.  
  168. local tmp = path .. ".tmp"
  169. local ok, err = pcall(function()
  170. local h = assert(fs.open(tmp, "w"))
  171. h.write(encoded)
  172. h.close()
  173. if fs.exists(path) then fs.delete(path) end
  174. fs.move(tmp, path)
  175. end)
  176. if not ok then
  177. pcall(function() if fs.exists(tmp) then fs.delete(tmp) end end)
  178. return false, tostring(err)
  179. end
  180. return true
  181. end
  182.  
  183. local function appendLine(path, line)
  184. local ok, err = pcall(function()
  185. local h = assert(fs.open(path, "a"))
  186. h.writeLine(line)
  187. h.close()
  188. end)
  189. return ok, err
  190. end
  191.  
  192. local function tableCopy(t)
  193. local out = {}
  194. if t then
  195. for k, v in pairs(t) do out[k] = v end
  196. end
  197. return out
  198. end
  199.  
  200. local function containsIgnoreCase(haystack, needle)
  201. return type(haystack) == "string"
  202. and type(needle) == "string"
  203. and haystack:lower() == needle:lower()
  204. end
  205.  
  206. local function indexToLetters(n)
  207. local s = ""
  208. while n > 0 do
  209. local r = (n - 1) % 26
  210. s = string.char(65 + r) .. s
  211. n = math.floor((n - 1) / 26)
  212. end
  213. return s
  214. end
  215.  
  216. local function getFreeSpace()
  217. local ok, value = pcall(fs.getFreeSpace, CONFIG.DATA_DIR)
  218. if not ok then return nil end
  219. if type(value) == "number" then return value end
  220. return nil -- "unlimited" or another implementation-specific value
  221. end
  222.  
  223. local function formatBytes(n)
  224. if type(n) ~= "number" then return "unlimited" end
  225. if n >= 1024 * 1024 then return string.format("%.1fMB", n / (1024 * 1024)) end
  226. if n >= 1024 then return string.format("%.0fKB", n / 1024) end
  227. return tostring(n) .. "B"
  228. end
  229.  
  230. -- ---------------------------------------------------------------------------
  231. -- Peripheral discovery / AP compatibility adapter
  232. -- ---------------------------------------------------------------------------
  233.  
  234. local detector = peripheral.find("player_detector") or peripheral.find("playerDetector")
  235. if not detector then
  236. error("No Advanced Peripherals player detector found. Attach a player_detector to this computer.", 0)
  237. end
  238.  
  239. local detectorMethodName
  240. if type(detector.getPlayer) == "function" then
  241. detectorMethodName = "getPlayer"
  242. elseif type(detector.getPlayerPos) == "function" then
  243. detectorMethodName = "getPlayerPos"
  244. else
  245. error("Player detector found, but neither getPlayer nor getPlayerPos is available. Check enablePlayerPosFunction.", 0)
  246. end
  247.  
  248. local function getPlayerInfo()
  249. local fn = detector[detectorMethodName]
  250. local ok, value = pcall(fn, CONFIG.TARGET)
  251. if ok then
  252. return value, nil, true
  253. end
  254.  
  255. -- Some AP builds may throw for an unavailable player instead of returning nil.
  256. -- If getOnlinePlayers is available, use it to distinguish offline from a real API error.
  257. if type(detector.getOnlinePlayers) == "function" then
  258. local listOk, online = pcall(detector.getOnlinePlayers)
  259. if listOk and type(online) == "table" then
  260. local found = false
  261. for _, entry in pairs(online) do
  262. local name
  263. if type(entry) == "string" then
  264. name = entry
  265. elseif type(entry) == "table" then
  266. name = entry.name or entry.username
  267. end
  268. if name and containsIgnoreCase(name, CONFIG.TARGET) then
  269. found = true
  270. break
  271. end
  272. end
  273. if not found then return nil, nil, true end
  274. end
  275. end
  276.  
  277. return nil, tostring(value), false
  278. end
  279.  
  280. local monitor = peripheral.find("monitor")
  281. local display = monitor or term.current()
  282. local monitorName = nil
  283.  
  284. if monitor then
  285. pcall(monitor.setTextScale, CONFIG.MONITOR_TEXT_SCALE)
  286. for _, name in ipairs(peripheral.getNames()) do
  287. local ok, wrapped = pcall(peripheral.wrap, name)
  288. if ok and wrapped == monitor then
  289. monitorName = name
  290. break
  291. end
  292. end
  293. end
  294.  
  295. -- ---------------------------------------------------------------------------
  296. -- Persistent databases
  297. -- ---------------------------------------------------------------------------
  298.  
  299. ensureDataDir()
  300.  
  301. local locations = readJsonFile(PATH.locations, {
  302. nextIndex = 1,
  303. items = {},
  304. })
  305. if type(locations.items) ~= "table" then locations.items = {} end
  306. if type(locations.nextIndex) ~= "number" then locations.nextIndex = 1 end
  307.  
  308. local loadedState = readJsonFile(PATH.state, {})
  309. local state = {
  310. online = loadedState.online == true,
  311. lastPos = loadedState.lastPos,
  312. lastSampleMs = loadedState.lastSampleMs,
  313. lastObservedMs = loadedState.lastObservedMs,
  314. currentLocationId = loadedState.currentLocationId,
  315. currentSessionId = loadedState.currentSessionId,
  316. sessionStartMs = loadedState.sessionStartMs,
  317. dwellAnchor = loadedState.dwellAnchor,
  318. dwellStartMs = loadedState.dwellStartMs,
  319. respawnKey = loadedState.respawnKey,
  320. sessionCounter = tonumber(loadedState.sessionCounter) or 0,
  321. lastHealth = loadedState.lastHealth,
  322. movementStoragePaused = loadedState.movementStoragePaused == true,
  323. lastDebugDumpMs = loadedState.lastDebugDumpMs,
  324. }
  325.  
  326. local events = {}
  327. local sessionDurations = {}
  328. local sessionExitReason = {}
  329.  
  330. local function loadEvents()
  331. if not fs.exists(PATH.events) then return end
  332. local h = fs.open(PATH.events, "r")
  333. if not h then return end
  334. while true do
  335. local line = h.readLine()
  336. if not line then break end
  337. local evt = safeJsonDecode(line)
  338. if type(evt) == "table" then
  339. events[#events + 1] = evt
  340. if evt.type == "LOCATION_EXIT" and evt.sessionId and evt.durationMs then
  341. sessionDurations[evt.sessionId] = evt.durationMs
  342. sessionExitReason[evt.sessionId] = evt.reason
  343. end
  344. end
  345. end
  346. h.close()
  347. end
  348.  
  349. loadEvents()
  350.  
  351. local uiDirty = true
  352. local storageError = nil
  353.  
  354. local function logEvent(evt)
  355. evt = tableCopy(evt)
  356. evt.t = evt.t or nowMs()
  357. local encoded = safeJsonEncode(evt)
  358. if encoded then
  359. local ok, err = appendLine(PATH.events, encoded)
  360. if not ok then storageError = "event log: " .. tostring(err) end
  361. else
  362. storageError = "event JSON encode failed"
  363. end
  364.  
  365. events[#events + 1] = evt
  366. if evt.type == "LOCATION_EXIT" and evt.sessionId and evt.durationMs then
  367. sessionDurations[evt.sessionId] = evt.durationMs
  368. sessionExitReason[evt.sessionId] = evt.reason
  369. end
  370. uiDirty = true
  371. end
  372.  
  373. local function saveLocations()
  374. local ok, err = writeJsonAtomic(PATH.locations, locations)
  375. if not ok then storageError = "locations: " .. tostring(err) end
  376. end
  377.  
  378. local function saveState()
  379. local out = {
  380. online = state.online,
  381. lastPos = state.lastPos,
  382. lastSampleMs = state.lastSampleMs,
  383. lastObservedMs = state.lastObservedMs,
  384. currentLocationId = state.currentLocationId,
  385. currentSessionId = state.currentSessionId,
  386. sessionStartMs = state.sessionStartMs,
  387. dwellAnchor = state.dwellAnchor,
  388. dwellStartMs = state.dwellStartMs,
  389. respawnKey = state.respawnKey,
  390. sessionCounter = state.sessionCounter,
  391. lastHealth = state.lastHealth,
  392. movementStoragePaused = state.movementStoragePaused,
  393. lastDebugDumpMs = state.lastDebugDumpMs,
  394. }
  395. local ok, err = writeJsonAtomic(PATH.state, out)
  396. if not ok then storageError = "state: " .. tostring(err) end
  397. end
  398.  
  399. local function findLocationById(id)
  400. if not id then return nil end
  401. for _, loc in ipairs(locations.items) do
  402. if loc.id == id then return loc end
  403. end
  404. return nil
  405. end
  406.  
  407. local function findNearestLocation(pos, radius)
  408. local best, bestDist
  409. radius = radius or CONFIG.LOCATION_RADIUS
  410. for _, loc in ipairs(locations.items) do
  411. if loc.dim == pos.dim then
  412. local d = distance3(pos, loc)
  413. if d <= radius and (not bestDist or d < bestDist) then
  414. best = loc
  415. bestDist = d
  416. end
  417. end
  418. end
  419. return best, bestDist
  420. end
  421.  
  422. local function newLocation(pos, kind, t)
  423. local id
  424. local name
  425.  
  426. if kind == "spawn" then
  427. id = "SPAWN"
  428. name = "Spawn Base"
  429. else
  430. local idx = locations.nextIndex
  431. locations.nextIndex = idx + 1
  432. id = indexToLetters(idx)
  433. name = "Location " .. id
  434. end
  435.  
  436. local loc = {
  437. id = id,
  438. name = name,
  439. kind = kind or "unknown",
  440. dim = pos.dim,
  441. x = floorCoord(pos.x),
  442. y = floorCoord(pos.y),
  443. z = floorCoord(pos.z),
  444. createdMs = t or nowMs(),
  445. visits = 0,
  446. totalMs = 0,
  447. lastEnterMs = nil,
  448. lastExitMs = nil,
  449. lastSeenMs = nil,
  450. }
  451. locations.items[#locations.items + 1] = loc
  452. saveLocations()
  453.  
  454. logEvent({
  455. type = "LOCATION_CREATED",
  456. t = t,
  457. locationId = loc.id,
  458. name = loc.name,
  459. kind = loc.kind,
  460. dim = loc.dim,
  461. x = loc.x,
  462. y = loc.y,
  463. z = loc.z,
  464. message = loc.name .. " discovered",
  465. })
  466.  
  467. return loc
  468. end
  469.  
  470. local function ensureTeleportLocation(pos, t)
  471. local loc = findNearestLocation(pos, CONFIG.LOCATION_MERGE_RADIUS)
  472. if loc then return loc end
  473. return newLocation(pos, "teleport", t)
  474. end
  475.  
  476. local function makeSessionId(loc, t)
  477. state.sessionCounter = state.sessionCounter + 1
  478. return string.format("%d-%s-%d", t or nowMs(), tostring(loc.id), state.sessionCounter)
  479. end
  480.  
  481. local function endLocationSession(t, reason, pos)
  482. if not state.currentLocationId or not state.currentSessionId or not state.sessionStartMs then
  483. state.currentLocationId = nil
  484. state.currentSessionId = nil
  485. state.sessionStartMs = nil
  486. return
  487. end
  488.  
  489. t = t or nowMs()
  490. if t < state.sessionStartMs then t = state.sessionStartMs end
  491.  
  492. local loc = findLocationById(state.currentLocationId)
  493. local duration = t - state.sessionStartMs
  494.  
  495. if loc then
  496. loc.totalMs = (loc.totalMs or 0) + duration
  497. loc.lastExitMs = t
  498. loc.lastSeenMs = t
  499. saveLocations()
  500. end
  501.  
  502. logEvent({
  503. type = "LOCATION_EXIT",
  504. t = t,
  505. locationId = state.currentLocationId,
  506. name = loc and loc.name or state.currentLocationId,
  507. sessionId = state.currentSessionId,
  508. durationMs = duration,
  509. reason = reason or "left",
  510. dim = pos and pos.dim or (loc and loc.dim),
  511. x = pos and floorCoord(pos.x) or nil,
  512. y = pos and floorCoord(pos.y) or nil,
  513. z = pos and floorCoord(pos.z) or nil,
  514. })
  515.  
  516. state.currentLocationId = nil
  517. state.currentSessionId = nil
  518. state.sessionStartMs = nil
  519. saveState()
  520. end
  521.  
  522. local function beginLocationSession(loc, t, reason, pos, forcedSessionId, forcedStartMs)
  523. if not loc then return nil end
  524. t = t or nowMs()
  525.  
  526. if state.currentLocationId == loc.id and state.currentSessionId then
  527. loc.lastSeenMs = t
  528. return state.currentSessionId
  529. end
  530.  
  531. if state.currentLocationId then
  532. endLocationSession(t, "switched_location", pos)
  533. end
  534.  
  535. local sessionId = forcedSessionId or makeSessionId(loc, t)
  536. local startMs = forcedStartMs or t
  537.  
  538. state.currentLocationId = loc.id
  539. state.currentSessionId = sessionId
  540. state.sessionStartMs = startMs
  541.  
  542. loc.visits = (loc.visits or 0) + 1
  543. loc.lastEnterMs = startMs
  544. loc.lastSeenMs = t
  545. saveLocations()
  546.  
  547. logEvent({
  548. type = "LOCATION_ENTER",
  549. t = t,
  550. startedMs = startMs,
  551. locationId = loc.id,
  552. name = loc.name,
  553. sessionId = sessionId,
  554. reason = reason or "entered",
  555. dim = pos and pos.dim or loc.dim,
  556. x = pos and floorCoord(pos.x) or loc.x,
  557. y = pos and floorCoord(pos.y) or loc.y,
  558. z = pos and floorCoord(pos.z) or loc.z,
  559. })
  560.  
  561. saveState()
  562. return sessionId
  563. end
  564.  
  565. -- ---------------------------------------------------------------------------
  566. -- Movement stream: compact delta encoding
  567. -- ---------------------------------------------------------------------------
  568.  
  569. local movement = {
  570. count = 0,
  571. last = nil,
  572. sinceAnchor = 0,
  573. loaded = false,
  574. records = nil,
  575. }
  576.  
  577. local function splitTabs(line)
  578. local out = {}
  579. local start = 1
  580. while true do
  581. local i = line:find("\t", start, true)
  582. if not i then
  583. out[#out + 1] = line:sub(start)
  584. break
  585. end
  586. out[#out + 1] = line:sub(start, i - 1)
  587. start = i + 1
  588. end
  589. return out
  590. end
  591.  
  592. local function scanMovementFile(storeRecords)
  593. local count = 0
  594. local prev = nil
  595. local records = storeRecords and {} or nil
  596.  
  597. if not fs.exists(PATH.movements) then
  598. return count, prev, records
  599. end
  600.  
  601. local h = fs.open(PATH.movements, "r")
  602. if not h then return count, prev, records end
  603.  
  604. while true do
  605. local line = h.readLine()
  606. if not line then break end
  607. local p = splitTabs(line)
  608. local rec = nil
  609.  
  610. if p[1] == "A" and #p >= 6 then
  611. rec = {
  612. t = tonumber(p[2]),
  613. dim = p[3],
  614. x = tonumber(p[4]),
  615. y = tonumber(p[5]),
  616. z = tonumber(p[6]),
  617. }
  618. elseif p[1] == "D" and #p >= 5 and prev then
  619. local dt = tonumber(p[2])
  620. local dx = tonumber(p[3])
  621. local dy = tonumber(p[4])
  622. local dz = tonumber(p[5])
  623. if dt and dx and dy and dz then
  624. rec = {
  625. t = prev.t + dt,
  626. dim = prev.dim,
  627. x = prev.x + dx,
  628. y = prev.y + dy,
  629. z = prev.z + dz,
  630. }
  631. end
  632. end
  633.  
  634. if rec and rec.t and rec.dim and rec.x and rec.y and rec.z then
  635. count = count + 1
  636. prev = rec
  637. if records then records[#records + 1] = rec end
  638. end
  639. end
  640.  
  641. h.close()
  642. return count, prev, records
  643. end
  644.  
  645. movement.count, movement.last = scanMovementFile(false)
  646. movement.sinceAnchor = movement.count % CONFIG.MOVEMENT_ANCHOR_EVERY
  647.  
  648. local function loadMovementRecords()
  649. if movement.loaded then return movement.records end
  650. local count, last, records = scanMovementFile(true)
  651. movement.count = count
  652. movement.last = last
  653. movement.records = records or {}
  654. movement.loaded = true
  655. return movement.records
  656. end
  657.  
  658. local function maybePauseMovementForStorage(t)
  659. local free = getFreeSpace()
  660. if not free then return false end
  661.  
  662. if not state.movementStoragePaused and free < CONFIG.MIN_FREE_SPACE then
  663. state.movementStoragePaused = true
  664. logEvent({
  665. type = "STORAGE_LOW",
  666. t = t,
  667. freeBytes = free,
  668. message = "Movement logging paused: low computer storage",
  669. })
  670. saveState()
  671. elseif state.movementStoragePaused and free >= CONFIG.STORAGE_RECOVER_SPACE then
  672. state.movementStoragePaused = false
  673. logEvent({
  674. type = "STORAGE_RECOVERED",
  675. t = t,
  676. freeBytes = free,
  677. message = "Movement logging resumed",
  678. })
  679. saveState()
  680. end
  681. return state.movementStoragePaused
  682. end
  683.  
  684. local function recordMovement(pos, t)
  685. local rec = {
  686. t = t,
  687. dim = pos.dim,
  688. x = floorCoord(pos.x),
  689. y = floorCoord(pos.y),
  690. z = floorCoord(pos.z),
  691. }
  692. if not rec.x or not rec.y or not rec.z then return end
  693.  
  694. if movement.last and sameBlock(movement.last, rec) then return end
  695. if maybePauseMovementForStorage(t) then return end
  696.  
  697. local line
  698. local forceAnchor = not movement.last
  699. or movement.last.dim ~= rec.dim
  700. or movement.sinceAnchor >= CONFIG.MOVEMENT_ANCHOR_EVERY
  701. or (rec.t - movement.last.t) < 0
  702. or (rec.t - movement.last.t) > 600000
  703.  
  704. if forceAnchor then
  705. line = table.concat({"A", tostring(rec.t), rec.dim, tostring(rec.x), tostring(rec.y), tostring(rec.z)}, "\t")
  706. movement.sinceAnchor = 0
  707. else
  708. line = table.concat({
  709. "D",
  710. tostring(rec.t - movement.last.t),
  711. tostring(rec.x - movement.last.x),
  712. tostring(rec.y - movement.last.y),
  713. tostring(rec.z - movement.last.z),
  714. }, "\t")
  715. movement.sinceAnchor = movement.sinceAnchor + 1
  716. end
  717.  
  718. local ok, err = appendLine(PATH.movements, line)
  719. if not ok then
  720. storageError = "movement log: " .. tostring(err)
  721. state.movementStoragePaused = true
  722. return
  723. end
  724.  
  725. movement.count = movement.count + 1
  726. movement.last = rec
  727. if movement.loaded then
  728. movement.records[#movement.records + 1] = rec
  729. end
  730. uiDirty = true
  731. end
  732.  
  733. -- ---------------------------------------------------------------------------
  734. -- Spawn/respawn parsing
  735. -- ---------------------------------------------------------------------------
  736.  
  737. local function extractRespawn(info)
  738. if type(info) ~= "table" then return nil end
  739. local rp = info.respawnPosition
  740. local rd = info.respawnDimension
  741.  
  742. if type(rp) == "table" then
  743. local x = tonumber(rp.x or rp[1])
  744. local y = tonumber(rp.y or rp[2])
  745. local z = tonumber(rp.z or rp[3])
  746. if x and y and z and rd ~= nil then
  747. return {
  748. x = x,
  749. y = y,
  750. z = z,
  751. dim = tostring(rd),
  752. }
  753. end
  754. end
  755.  
  756. -- Defensive support for possible alternative field naming in mod builds.
  757. local x = tonumber(info.respawnX or info.spawnX)
  758. local y = tonumber(info.respawnY or info.spawnY)
  759. local z = tonumber(info.respawnZ or info.spawnZ)
  760. local dim = info.respawnDimension or info.spawnDimension
  761. if x and y and z and dim then
  762. return {x = x, y = y, z = z, dim = tostring(dim)}
  763. end
  764.  
  765. return nil
  766. end
  767.  
  768. local function ensureSpawnLocation(spawn, t)
  769. local existing = findLocationById("SPAWN")
  770. local key = table.concat({spawn.dim, floorCoord(spawn.x), floorCoord(spawn.y), floorCoord(spawn.z)}, "|")
  771.  
  772. if not existing then
  773. existing = newLocation(spawn, "spawn", t)
  774. state.respawnKey = key
  775. logEvent({
  776. type = "SPAWN_SET",
  777. t = t,
  778. dim = spawn.dim,
  779. x = floorCoord(spawn.x),
  780. y = floorCoord(spawn.y),
  781. z = floorCoord(spawn.z),
  782. locationId = "SPAWN",
  783. })
  784. saveState()
  785. return existing
  786. end
  787.  
  788. if state.respawnKey ~= key
  789. or existing.dim ~= spawn.dim
  790. or existing.x ~= floorCoord(spawn.x)
  791. or existing.y ~= floorCoord(spawn.y)
  792. or existing.z ~= floorCoord(spawn.z) then
  793.  
  794. local old = {dim = existing.dim, x = existing.x, y = existing.y, z = existing.z}
  795. existing.dim = spawn.dim
  796. existing.x = floorCoord(spawn.x)
  797. existing.y = floorCoord(spawn.y)
  798. existing.z = floorCoord(spawn.z)
  799. existing.lastSeenMs = t
  800. state.respawnKey = key
  801. saveLocations()
  802.  
  803. logEvent({
  804. type = "SPAWN_CHANGED",
  805. t = t,
  806. locationId = "SPAWN",
  807. fromDim = old.dim,
  808. fromX = old.x,
  809. fromY = old.y,
  810. fromZ = old.z,
  811. dim = existing.dim,
  812. x = existing.x,
  813. y = existing.y,
  814. z = existing.z,
  815. })
  816. saveState()
  817. end
  818.  
  819. return existing
  820. end
  821.  
  822. -- ---------------------------------------------------------------------------
  823. -- Tracking logic
  824. -- ---------------------------------------------------------------------------
  825.  
  826. local lastApiErrorText = nil
  827. local lastApiErrorMs = 0
  828. local pendingDimensionEvent = nil
  829. local startupMs = nowMs()
  830. local startedWithPriorState = loadedState and next(loadedState) ~= nil
  831.  
  832. local function dumpDebugPlayer(info, t)
  833. if not info then return end
  834. if state.lastDebugDumpMs and (t - state.lastDebugDumpMs) < CONFIG.DEBUG_PLAYER_DUMP_SECONDS * 1000 then
  835. return
  836. end
  837. local ok = writeJsonAtomic(PATH.debugPlayer, info)
  838. if ok then
  839. state.lastDebugDumpMs = t
  840. end
  841. end
  842.  
  843. local function closeInterruptedPriorSession()
  844. if not state.currentLocationId or not state.currentSessionId or not state.sessionStartMs then return end
  845.  
  846. local closeAt = state.lastObservedMs or state.lastSampleMs or state.sessionStartMs
  847. if closeAt < state.sessionStartMs then closeAt = state.sessionStartMs end
  848.  
  849. endLocationSession(closeAt, "tracker_interrupted", state.lastPos)
  850. end
  851.  
  852. if startedWithPriorState then
  853. closeInterruptedPriorSession()
  854. if loadedState.online == true and loadedState.lastObservedMs then
  855. logEvent({
  856. type = "TRACKING_GAP",
  857. t = startupMs,
  858. gapStartedMs = loadedState.lastObservedMs,
  859. durationMs = math.max(0, startupMs - loadedState.lastObservedMs),
  860. message = "Tracker restarted after an observation gap",
  861. })
  862. end
  863. end
  864.  
  865. -- We cannot assume the player remained online while this computer was unloaded.
  866. state.online = false
  867. state.lastPos = nil
  868. state.lastSampleMs = nil
  869. state.lastObservedMs = nil
  870. state.currentLocationId = nil
  871. state.currentSessionId = nil
  872. state.sessionStartMs = nil
  873. state.dwellAnchor = nil
  874. state.dwellStartMs = nil
  875. state.lastHealth = nil
  876. saveState()
  877.  
  878. logEvent({
  879. type = "SYSTEM_START",
  880. t = startupMs,
  881. target = CONFIG.TARGET,
  882. apiMethod = detectorMethodName,
  883. movementCount = movement.count,
  884. message = "Tracker started",
  885. })
  886.  
  887. local function resetDwellCandidate()
  888. state.dwellAnchor = nil
  889. state.dwellStartMs = nil
  890. end
  891.  
  892. local function updateKnownLocationMembership(pos, t)
  893. local current = findLocationById(state.currentLocationId)
  894.  
  895. if current then
  896. local d = distance3(pos, current)
  897. if d <= CONFIG.LOCATION_RADIUS then
  898. current.lastSeenMs = t
  899. resetDwellCandidate()
  900. return current
  901. else
  902. endLocationSession(t, "left_radius", pos)
  903. current = nil
  904. end
  905. end
  906.  
  907. local nearest = findNearestLocation(pos, CONFIG.LOCATION_RADIUS)
  908. if nearest then
  909. beginLocationSession(nearest, t, "entered_radius", pos)
  910. resetDwellCandidate()
  911. return nearest
  912. end
  913.  
  914. return nil
  915. end
  916.  
  917. local function updateDwellDiscovery(pos, t)
  918. if not CONFIG.ENABLE_DWELL_DISCOVERY then return end
  919. if state.currentLocationId then
  920. resetDwellCandidate()
  921. return
  922. end
  923.  
  924. if not state.dwellAnchor
  925. or state.dwellAnchor.dim ~= pos.dim
  926. or distance3(pos, state.dwellAnchor) > CONFIG.LOCATION_RADIUS then
  927.  
  928. state.dwellAnchor = {
  929. x = floorCoord(pos.x),
  930. y = floorCoord(pos.y),
  931. z = floorCoord(pos.z),
  932. dim = pos.dim,
  933. }
  934. state.dwellStartMs = t
  935. return
  936. end
  937.  
  938. if state.dwellStartMs and (t - state.dwellStartMs) >= CONFIG.DWELL_DISCOVERY_SECONDS * 1000 then
  939. local anchor = state.dwellAnchor
  940. local startMs = state.dwellStartMs
  941. local loc = findNearestLocation(anchor, CONFIG.LOCATION_MERGE_RADIUS)
  942. if not loc then
  943. loc = newLocation(anchor, "dwell", t)
  944. end
  945.  
  946. local sessionId = makeSessionId(loc, startMs)
  947. beginLocationSession(loc, t, "dwell_discovered", pos, sessionId, startMs)
  948. logEvent({
  949. type = "DWELL_DISCOVERY",
  950. t = t,
  951. locationId = loc.id,
  952. name = loc.name,
  953. sessionId = sessionId,
  954. dwellBeforeDiscoveryMs = t - startMs,
  955. dim = loc.dim,
  956. x = loc.x,
  957. y = loc.y,
  958. z = loc.z,
  959. })
  960. resetDwellCandidate()
  961. end
  962. end
  963.  
  964. local function handleTeleport(prev, pos, t, distance, reason)
  965. local oldLocationId = state.currentLocationId
  966.  
  967. -- If the destination is outside the old location, terminate its dwell first.
  968. local current = findLocationById(state.currentLocationId)
  969. if current and distance3(pos, current) > CONFIG.LOCATION_RADIUS then
  970. endLocationSession(t, "teleported_away", pos)
  971. end
  972.  
  973. local destination = ensureTeleportLocation(pos, t)
  974. local sessionId
  975. if state.currentLocationId == destination.id and state.currentSessionId then
  976. sessionId = state.currentSessionId
  977. destination.lastSeenMs = t
  978. else
  979. sessionId = beginLocationSession(destination, t, "teleport_destination", pos)
  980. end
  981.  
  982. logEvent({
  983. type = "TELEPORT",
  984. t = t,
  985. reason = reason,
  986. distance = distance,
  987. fromDim = prev and prev.dim or nil,
  988. fromX = prev and floorCoord(prev.x) or nil,
  989. fromY = prev and floorCoord(prev.y) or nil,
  990. fromZ = prev and floorCoord(prev.z) or nil,
  991. dim = pos.dim,
  992. x = floorCoord(pos.x),
  993. y = floorCoord(pos.y),
  994. z = floorCoord(pos.z),
  995. locationId = destination.id,
  996. name = destination.name,
  997. sessionId = sessionId,
  998. leftLocationId = oldLocationId,
  999. })
  1000.  
  1001. resetDwellCandidate()
  1002. end
  1003.  
  1004. local function playerWentOffline(t, source)
  1005. if not state.online then return end
  1006. endLocationSession(t, source == "leave_event" and "disconnect" or "offline", state.lastPos)
  1007. logEvent({
  1008. type = "LEAVE",
  1009. t = t,
  1010. source = source,
  1011. dim = state.lastPos and state.lastPos.dim or nil,
  1012. x = state.lastPos and floorCoord(state.lastPos.x) or nil,
  1013. y = state.lastPos and floorCoord(state.lastPos.y) or nil,
  1014. z = state.lastPos and floorCoord(state.lastPos.z) or nil,
  1015. })
  1016. state.online = false
  1017. state.lastPos = nil
  1018. state.lastSampleMs = nil
  1019. state.lastObservedMs = t
  1020. state.lastHealth = nil
  1021. resetDwellCandidate()
  1022. saveState()
  1023. end
  1024.  
  1025. local function playerCameOnline(info, pos, t, source)
  1026. if state.online then return end
  1027. state.online = true
  1028. logEvent({
  1029. type = source == "join_event" and "JOIN" or "ONLINE",
  1030. t = t,
  1031. source = source,
  1032. dim = pos.dim,
  1033. x = floorCoord(pos.x),
  1034. y = floorCoord(pos.y),
  1035. z = floorCoord(pos.z),
  1036. health = info.health,
  1037. })
  1038. end
  1039.  
  1040. local function pollPlayer(source)
  1041. local t = nowMs()
  1042. local info, err, callSucceeded = getPlayerInfo()
  1043.  
  1044. if not callSucceeded then
  1045. if err ~= lastApiErrorText or (t - lastApiErrorMs) > 30000 then
  1046. logEvent({
  1047. type = "API_ERROR",
  1048. t = t,
  1049. message = err,
  1050. })
  1051. lastApiErrorText = err
  1052. lastApiErrorMs = t
  1053. end
  1054. return
  1055. end
  1056.  
  1057. lastApiErrorText = nil
  1058.  
  1059. if type(info) ~= "table" then
  1060. playerWentOffline(t, source or "poll")
  1061. return
  1062. end
  1063.  
  1064. local x, y, z = tonumber(info.x), tonumber(info.y), tonumber(info.z)
  1065. local dim = info.dimension and tostring(info.dimension) or nil
  1066. if not x or not y or not z or not dim then
  1067. if (t - lastApiErrorMs) > 30000 then
  1068. logEvent({
  1069. type = "API_ERROR",
  1070. t = t,
  1071. message = "Player detector returned a player object without x/y/z/dimension. Check morePlayerInformation.",
  1072. })
  1073. lastApiErrorMs = t
  1074. end
  1075. return
  1076. end
  1077.  
  1078. local pos = {x = x, y = y, z = z, dim = dim}
  1079. local wasOnline = state.online
  1080. local prev = state.lastPos
  1081. local prevSampleMs = state.lastSampleMs
  1082.  
  1083. playerCameOnline(info, pos, t, source or "poll")
  1084. dumpDebugPlayer(info, t)
  1085.  
  1086. -- Detect respawn/spawn point if the AP build exposes it in a usable shape.
  1087. local respawn = extractRespawn(info)
  1088. if respawn then
  1089. ensureSpawnLocation(respawn, t)
  1090. end
  1091.  
  1092. -- Detect large instantaneous movement or cross-dimensional movement.
  1093. local teleported = false
  1094. if wasOnline and prev and prevSampleMs then
  1095. local gap = t - prevSampleMs
  1096. if prev.dim ~= pos.dim then
  1097. handleTeleport(prev, pos, t, nil, "dimension")
  1098. teleported = true
  1099. elseif gap <= CONFIG.TELEPORT_MAX_GAP_MS then
  1100. local d = distance3(prev, pos)
  1101. local seconds = math.max(gap / 1000, 0.001)
  1102. local speed = d / seconds
  1103. if d > CONFIG.TELEPORT_DISTANCE and speed >= CONFIG.TELEPORT_MIN_SPEED then
  1104. handleTeleport(prev, pos, t, d, "distance")
  1105. teleported = true
  1106. end
  1107. elseif gap > CONFIG.TELEPORT_MAX_GAP_MS * 2 then
  1108. logEvent({
  1109. type = "SAMPLE_GAP",
  1110. t = t,
  1111. durationMs = gap,
  1112. fromDim = prev.dim,
  1113. fromX = floorCoord(prev.x),
  1114. fromY = floorCoord(prev.y),
  1115. fromZ = floorCoord(prev.z),
  1116. dim = pos.dim,
  1117. x = floorCoord(pos.x),
  1118. y = floorCoord(pos.y),
  1119. z = floorCoord(pos.z),
  1120. })
  1121. end
  1122. end
  1123.  
  1124. if not teleported then
  1125. local known = updateKnownLocationMembership(pos, t)
  1126. if not known then updateDwellDiscovery(pos, t) end
  1127. end
  1128.  
  1129. -- Optional damage/heal log. It only records actual health value changes.
  1130. if CONFIG.LOG_HEALTH_CHANGES and type(info.health) == "number" then
  1131. if state.lastHealth ~= nil and info.health ~= state.lastHealth then
  1132. logEvent({
  1133. type = info.health < state.lastHealth and "DAMAGE" or "HEAL",
  1134. t = t,
  1135. fromHealth = state.lastHealth,
  1136. health = info.health,
  1137. maxHealth = info.maxHealth,
  1138. dim = pos.dim,
  1139. x = floorCoord(pos.x),
  1140. y = floorCoord(pos.y),
  1141. z = floorCoord(pos.z),
  1142. })
  1143. end
  1144. state.lastHealth = info.health
  1145. end
  1146.  
  1147. recordMovement(pos, t)
  1148.  
  1149. state.online = true
  1150. state.lastPos = pos
  1151. state.lastSampleMs = t
  1152. state.lastObservedMs = t
  1153.  
  1154. local current = findLocationById(state.currentLocationId)
  1155. if current then current.lastSeenMs = t end
  1156. end
  1157.  
  1158. -- ---------------------------------------------------------------------------
  1159. -- Advanced Peripherals event compatibility
  1160. -- ---------------------------------------------------------------------------
  1161.  
  1162. local function targetMatches(name)
  1163. return containsIgnoreCase(name, CONFIG.TARGET)
  1164. end
  1165.  
  1166. local function handlePeripheralEvent(ev)
  1167. local name = ev[1]
  1168. local t = nowMs()
  1169.  
  1170. if name == "playerJoin" then
  1171. local username, dim = ev[2], ev[3]
  1172. if targetMatches(username) then
  1173. -- Poll immediately so the JOIN event can include an exact position if available.
  1174. pollPlayer("join_event")
  1175. if not state.online then
  1176. logEvent({type = "JOIN_EVENT", t = t, dim = dim, source = "AP_0.7"})
  1177. end
  1178. end
  1179.  
  1180. elseif name == "player_join" then
  1181. local uuid, username, dim = ev[2], ev[3], ev[4]
  1182. if targetMatches(username) then
  1183. pollPlayer("join_event")
  1184. if not state.online then
  1185. logEvent({type = "JOIN_EVENT", t = t, uuid = uuid, dim = dim, source = "AP_0.8"})
  1186. end
  1187. end
  1188.  
  1189. elseif name == "playerLeave" then
  1190. local username, dim = ev[2], ev[3]
  1191. if targetMatches(username) then
  1192. playerWentOffline(t, "leave_event")
  1193. if not state.online then uiDirty = true end
  1194. end
  1195.  
  1196. elseif name == "player_leave" then
  1197. local uuid, username, dim = ev[2], ev[3], ev[4]
  1198. if targetMatches(username) then
  1199. playerWentOffline(t, "leave_event")
  1200. uiDirty = true
  1201. end
  1202.  
  1203. elseif name == "playerChangedDimension" then
  1204. local username, fromDim, toDim = ev[2], ev[3], ev[4]
  1205. if targetMatches(username) then
  1206. pendingDimensionEvent = {t = t, fromDim = fromDim, toDim = toDim}
  1207. logEvent({
  1208. type = "DIMENSION_CHANGE",
  1209. t = t,
  1210. fromDim = fromDim,
  1211. dim = toDim,
  1212. source = "AP_0.7",
  1213. })
  1214. pollPlayer("dimension_event")
  1215. end
  1216.  
  1217. elseif name == "player_changed_dimension" then
  1218. local uuid, username, fromDim, toDim = ev[2], ev[3], ev[4], ev[5]
  1219. if targetMatches(username) then
  1220. pendingDimensionEvent = {t = t, fromDim = fromDim, toDim = toDim}
  1221. logEvent({
  1222. type = "DIMENSION_CHANGE",
  1223. t = t,
  1224. uuid = uuid,
  1225. fromDim = fromDim,
  1226. dim = toDim,
  1227. source = "AP_0.8",
  1228. })
  1229. pollPlayer("dimension_event")
  1230. end
  1231.  
  1232. elseif name == "player_death" then
  1233. local uuid, username, damageSource = ev[2], ev[3], ev[4]
  1234. if targetMatches(username) then
  1235. logEvent({
  1236. type = "DEATH",
  1237. t = t,
  1238. uuid = uuid,
  1239. damageSource = damageSource,
  1240. dim = state.lastPos and state.lastPos.dim or nil,
  1241. x = state.lastPos and floorCoord(state.lastPos.x) or nil,
  1242. y = state.lastPos and floorCoord(state.lastPos.y) or nil,
  1243. z = state.lastPos and floorCoord(state.lastPos.z) or nil,
  1244. })
  1245. end
  1246. end
  1247. end
  1248.  
  1249. -- ---------------------------------------------------------------------------
  1250. -- UI
  1251. -- ---------------------------------------------------------------------------
  1252.  
  1253. local TABS = {
  1254. {key = "ALL", label = "ALL"},
  1255. {key = "TP", label = "TP"},
  1256. {key = "LOC", label = "LOC"},
  1257. {key = "MOVE", label = "MOVE"},
  1258. {key = "PRES", label = "ON/OFF"},
  1259. {key = "SYS", label = "SYS"},
  1260. }
  1261.  
  1262. local ui = {
  1263. tab = 1,
  1264. scroll = 0,
  1265. visibleRows = 1,
  1266. maxScroll = 0,
  1267. tabHitboxes = {},
  1268. scrollbar = nil,
  1269. terminalDragging = false,
  1270. }
  1271.  
  1272. local function setBg(c)
  1273. if display.isColor and display.isColor() then display.setBackgroundColor(c) end
  1274. end
  1275.  
  1276. local function setFg(c)
  1277. if display.isColor and display.isColor() then display.setTextColor(c) end
  1278. end
  1279.  
  1280. local function writeAt(x, y, text, fg, bg)
  1281. local w, h = display.getSize()
  1282. if y < 1 or y > h or x > w then return end
  1283. text = tostring(text or "")
  1284. if x < 1 then
  1285. text = text:sub(2 - x)
  1286. x = 1
  1287. end
  1288. if #text > (w - x + 1) then text = text:sub(1, w - x + 1) end
  1289. if bg then setBg(bg) end
  1290. if fg then setFg(fg) end
  1291. display.setCursorPos(x, y)
  1292. display.write(text)
  1293. end
  1294.  
  1295. local function padRight(s, width)
  1296. s = tostring(s or "")
  1297. if #s >= width then return s:sub(1, width) end
  1298. return s .. string.rep(" ", width - #s)
  1299. end
  1300.  
  1301. local function eventMatchesTab(evt, key)
  1302. if key == "ALL" then return true end
  1303. if key == "TP" then return evt.type == "TELEPORT" or evt.type == "DIMENSION_CHANGE" end
  1304. if key == "PRES" then
  1305. return evt.type == "JOIN" or evt.type == "ONLINE" or evt.type == "LEAVE" or evt.type == "JOIN_EVENT"
  1306. end
  1307. if key == "SYS" then
  1308. return evt.type == "SYSTEM_START"
  1309. or evt.type == "TRACKING_GAP"
  1310. or evt.type == "SAMPLE_GAP"
  1311. or evt.type == "API_ERROR"
  1312. or evt.type == "STORAGE_LOW"
  1313. or evt.type == "STORAGE_RECOVERED"
  1314. end
  1315. return false
  1316. end
  1317.  
  1318. local function currentSessionDuration(sessionId)
  1319. if not sessionId then return nil, false end
  1320. if sessionDurations[sessionId] ~= nil then
  1321. return sessionDurations[sessionId], false
  1322. end
  1323. if state.currentSessionId == sessionId and state.sessionStartMs then
  1324. return math.max(0, nowMs() - state.sessionStartMs), true
  1325. end
  1326. return nil, false
  1327. end
  1328.  
  1329. local function formatEvent(evt)
  1330. local ts = formatTimestamp(evt.t, true)
  1331. local typ = evt.type or "EVENT"
  1332.  
  1333. if typ == "TELEPORT" then
  1334. local dwell, active = currentSessionDuration(evt.sessionId)
  1335. local dwellText = dwell and ((active and "ACTIVE " or "") .. formatDuration(dwell)) or "pending"
  1336. local distText = evt.reason == "dimension" and "DIM" or (evt.distance and string.format("%.0fb", evt.distance) or "?")
  1337. return string.format("[%s] TP %s -> %s %s @ %s | %s",
  1338. ts,
  1339. distText,
  1340. evt.name or evt.locationId or "location",
  1341. dimShort(evt.dim),
  1342. posString(evt),
  1343. dwellText)
  1344.  
  1345. elseif typ == "LOCATION_ENTER" then
  1346. return string.format("[%s] ENTER %s %s @ %s (%s)",
  1347. ts, evt.name or evt.locationId or "location", dimShort(evt.dim), posString(evt), evt.reason or "")
  1348.  
  1349. elseif typ == "LOCATION_EXIT" then
  1350. return string.format("[%s] EXIT %s after %s (%s)",
  1351. ts, evt.name or evt.locationId or "location", formatDuration(evt.durationMs), evt.reason or "")
  1352.  
  1353. elseif typ == "LOCATION_CREATED" then
  1354. return string.format("[%s] NEW %s [%s] %s @ %s",
  1355. ts, evt.name or evt.locationId or "location", evt.kind or "?", dimShort(evt.dim), posString(evt))
  1356.  
  1357. elseif typ == "JOIN" or typ == "ONLINE" or typ == "JOIN_EVENT" then
  1358. return string.format("[%s] ONLINE %s @ %s", ts, dimShort(evt.dim), posString(evt))
  1359.  
  1360. elseif typ == "LEAVE" then
  1361. return string.format("[%s] OFFLINE %s @ %s", ts, dimShort(evt.dim), posString(evt))
  1362.  
  1363. elseif typ == "DIMENSION_CHANGE" then
  1364. return string.format("[%s] DIM %s -> %s", ts, dimShort(evt.fromDim), dimShort(evt.dim))
  1365.  
  1366. elseif typ == "SPAWN_SET" then
  1367. return string.format("[%s] SPAWN %s @ %s", ts, dimShort(evt.dim), posString(evt))
  1368.  
  1369. elseif typ == "SPAWN_CHANGED" then
  1370. return string.format("[%s] SPAWN CHANGED %s %d,%d,%d -> %s %s",
  1371. ts,
  1372. dimShort(evt.fromDim), evt.fromX or 0, evt.fromY or 0, evt.fromZ or 0,
  1373. dimShort(evt.dim), posString(evt))
  1374.  
  1375. elseif typ == "DWELL_DISCOVERY" then
  1376. return string.format("[%s] DWELL -> %s after %s", ts, evt.name or evt.locationId or "location", formatDuration(evt.dwellBeforeDiscoveryMs))
  1377.  
  1378. elseif typ == "DAMAGE" or typ == "HEAL" then
  1379. return string.format("[%s] %s %.1f -> %.1f HP @ %s", ts, typ, evt.fromHealth or 0, evt.health or 0, posString(evt))
  1380.  
  1381. elseif typ == "DEATH" then
  1382. return string.format("[%s] DEATH %s @ %s", ts, tostring(evt.damageSource or "unknown"), posString(evt))
  1383.  
  1384. elseif typ == "TRACKING_GAP" then
  1385. return string.format("[%s] TRACKER GAP %s", ts, formatDuration(evt.durationMs))
  1386.  
  1387. elseif typ == "SAMPLE_GAP" then
  1388. return string.format("[%s] SAMPLE GAP %s", ts, formatDuration(evt.durationMs))
  1389.  
  1390. elseif typ == "STORAGE_LOW" then
  1391. return string.format("[%s] STORAGE LOW - movement paused (%s free)", ts, formatBytes(evt.freeBytes))
  1392.  
  1393. elseif typ == "STORAGE_RECOVERED" then
  1394. return string.format("[%s] STORAGE OK - movement resumed (%s free)", ts, formatBytes(evt.freeBytes))
  1395.  
  1396. elseif typ == "API_ERROR" then
  1397. return string.format("[%s] API ERROR %s", ts, tostring(evt.message or ""))
  1398.  
  1399. elseif typ == "SYSTEM_START" then
  1400. return string.format("[%s] TRACKER START method=%s moves=%s", ts, tostring(evt.apiMethod or "?"), tostring(evt.movementCount or 0))
  1401. end
  1402.  
  1403. return string.format("[%s] %s %s", ts, typ, tostring(evt.message or ""))
  1404. end
  1405.  
  1406. local function buildEventRows(tabKey)
  1407. local rows = {}
  1408. for i = #events, 1, -1 do
  1409. local evt = events[i]
  1410. if eventMatchesTab(evt, tabKey) then
  1411. rows[#rows + 1] = formatEvent(evt)
  1412. end
  1413. end
  1414. return rows
  1415. end
  1416.  
  1417. local function buildLocationRows()
  1418. local copy = {}
  1419. for _, loc in ipairs(locations.items) do copy[#copy + 1] = loc end
  1420. table.sort(copy, function(a, b)
  1421. local at = tonumber(a.totalMs) or 0
  1422. local bt = tonumber(b.totalMs) or 0
  1423. if at == bt then return tostring(a.id) < tostring(b.id) end
  1424. return at > bt
  1425. end)
  1426.  
  1427. local rows = {}
  1428. for _, loc in ipairs(copy) do
  1429. local active = state.currentLocationId == loc.id
  1430. local liveTotal = tonumber(loc.totalMs) or 0
  1431. if active and state.sessionStartMs then liveTotal = liveTotal + (nowMs() - state.sessionStartMs) end
  1432. rows[#rows + 1] = string.format("%s%s %s | %s @ %d,%d,%d | visits:%d total:%s",
  1433. active and "*" or " ",
  1434. tostring(loc.id),
  1435. loc.name or "Location",
  1436. dimShort(loc.dim),
  1437. loc.x or 0, loc.y or 0, loc.z or 0,
  1438. loc.visits or 0,
  1439. formatDuration(liveTotal))
  1440. end
  1441. return rows
  1442. end
  1443.  
  1444. local function buildMovementRows()
  1445. local records = loadMovementRecords()
  1446. local rows = {}
  1447. for i = #records, 1, -1 do
  1448. local p = records[i]
  1449. rows[#rows + 1] = string.format("[%s] %s @ %d,%d,%d",
  1450. formatTimestamp(p.t, true), dimShort(p.dim), p.x, p.y, p.z)
  1451. end
  1452. return rows
  1453. end
  1454.  
  1455. local function buildRows()
  1456. local key = TABS[ui.tab].key
  1457. if key == "LOC" then return buildLocationRows() end
  1458. if key == "MOVE" then return buildMovementRows() end
  1459. return buildEventRows(key)
  1460. end
  1461.  
  1462. local function statusLine(width)
  1463. if state.online and state.lastPos then
  1464. local site = findLocationById(state.currentLocationId)
  1465. local s = string.format("%s ONLINE | %s %s",
  1466. CONFIG.TARGET, dimShort(state.lastPos.dim), posString(state.lastPos))
  1467. if site and state.sessionStartMs then
  1468. s = s .. string.format(" | %s %s", site.name, formatDuration(nowMs() - state.sessionStartMs))
  1469. end
  1470. return padRight(s, width)
  1471. end
  1472. return padRight(CONFIG.TARGET .. " OFFLINE", width)
  1473. end
  1474.  
  1475. local function drawTabs(width)
  1476. ui.tabHitboxes = {}
  1477. local x = 1
  1478. for i, tab in ipairs(TABS) do
  1479. local label = " " .. tab.label .. " "
  1480. if x > width - 1 then break end
  1481. local maxLen = math.min(#label, width - x)
  1482. if maxLen <= 0 then break end
  1483. local shown = label:sub(1, maxLen)
  1484. local active = i == ui.tab
  1485. local bg = active and colors.blue or colors.gray
  1486. local fg = active and colors.white or colors.lightGray
  1487. writeAt(x, 1, shown, fg, bg)
  1488. ui.tabHitboxes[#ui.tabHitboxes + 1] = {i = i, x1 = x, x2 = x + #shown - 1}
  1489. x = x + #shown
  1490. end
  1491. if x <= width then writeAt(x, 1, string.rep(" ", width - x + 1), colors.white, colors.black) end
  1492. end
  1493.  
  1494. local function drawScrollbar(width, top, bottom, totalRows)
  1495. local height = bottom - top + 1
  1496. if height < 3 then
  1497. ui.scrollbar = nil
  1498. return
  1499. end
  1500.  
  1501. local x = width
  1502. writeAt(x, top, "^", colors.white, colors.gray)
  1503. writeAt(x, bottom, "v", colors.white, colors.gray)
  1504.  
  1505. local trackTop = top + 1
  1506. local trackBottom = bottom - 1
  1507. local trackHeight = trackBottom - trackTop + 1
  1508. for y = trackTop, trackBottom do writeAt(x, y, "|", colors.gray, colors.black) end
  1509.  
  1510. local thumbY = trackTop
  1511. if ui.maxScroll > 0 and trackHeight > 1 then
  1512. thumbY = trackTop + math.floor((ui.scroll / ui.maxScroll) * (trackHeight - 1) + 0.5)
  1513. end
  1514. writeAt(x, thumbY, "#", colors.white, colors.blue)
  1515.  
  1516. ui.scrollbar = {
  1517. x = x,
  1518. top = top,
  1519. bottom = bottom,
  1520. trackTop = trackTop,
  1521. trackBottom = trackBottom,
  1522. trackHeight = trackHeight,
  1523. thumbY = thumbY,
  1524. }
  1525. end
  1526.  
  1527. local function drawUI()
  1528. local w, h = display.getSize()
  1529. display.setCursorBlink(false)
  1530. setBg(colors.black)
  1531. setFg(colors.white)
  1532. display.clear()
  1533.  
  1534. if w < 12 or h < 5 then
  1535. writeAt(1, 1, "Monitor too small", colors.white, colors.red)
  1536. return
  1537. end
  1538.  
  1539. drawTabs(w)
  1540. writeAt(1, 2, statusLine(w), state.online and colors.lime or colors.red, colors.black)
  1541.  
  1542. local free = getFreeSpace()
  1543. local info = string.format("Moves:%d Events:%d Free:%s%s",
  1544. movement.count,
  1545. #events,
  1546. formatBytes(free),
  1547. state.movementStoragePaused and " MOVE-PAUSED" or "")
  1548. if storageError then info = "ERR: " .. storageError end
  1549. writeAt(1, 3, padRight(info, w), storageError and colors.red or colors.lightGray, colors.black)
  1550.  
  1551. local top = 4
  1552. local bottom = h
  1553. local contentWidth = w - 1
  1554. ui.visibleRows = bottom - top + 1
  1555.  
  1556. local rows = buildRows()
  1557. ui.maxScroll = math.max(0, #rows - ui.visibleRows)
  1558. if ui.scroll > ui.maxScroll then ui.scroll = ui.maxScroll end
  1559. if ui.scroll < 0 then ui.scroll = 0 end
  1560.  
  1561. for row = 0, ui.visibleRows - 1 do
  1562. local idx = 1 + ui.scroll + row
  1563. local text = rows[idx] or ""
  1564. writeAt(1, top + row, padRight(text, contentWidth), colors.white, colors.black)
  1565. end
  1566.  
  1567. drawScrollbar(w, top, bottom, #rows)
  1568. uiDirty = false
  1569. end
  1570.  
  1571. local function setTab(i)
  1572. if i < 1 or i > #TABS then return end
  1573. ui.tab = i
  1574. ui.scroll = 0
  1575. uiDirty = true
  1576. end
  1577.  
  1578. local function scrollBy(delta)
  1579. ui.scroll = math.max(0, math.min(ui.maxScroll, ui.scroll + delta))
  1580. uiDirty = true
  1581. end
  1582.  
  1583. local function scrollbarJump(y)
  1584. local sb = ui.scrollbar
  1585. if not sb or ui.maxScroll <= 0 then return end
  1586. if y <= sb.top then
  1587. scrollBy(-1)
  1588. elseif y >= sb.bottom then
  1589. scrollBy(1)
  1590. elseif y >= sb.trackTop and y <= sb.trackBottom then
  1591. local ratio
  1592. if sb.trackHeight <= 1 then ratio = 0 else ratio = (y - sb.trackTop) / (sb.trackHeight - 1) end
  1593. ui.scroll = math.floor(ratio * ui.maxScroll + 0.5)
  1594. uiDirty = true
  1595. end
  1596. end
  1597.  
  1598. local function handleTouch(x, y)
  1599. if y == 1 then
  1600. for _, hit in ipairs(ui.tabHitboxes) do
  1601. if x >= hit.x1 and x <= hit.x2 then
  1602. setTab(hit.i)
  1603. return
  1604. end
  1605. end
  1606. end
  1607.  
  1608. if ui.scrollbar and x == ui.scrollbar.x then
  1609. scrollbarJump(y)
  1610. end
  1611. end
  1612.  
  1613. local function handleKey(key)
  1614. if key == keys.up then scrollBy(-1)
  1615. elseif key == keys.down then scrollBy(1)
  1616. elseif key == keys.pageUp then scrollBy(-math.max(1, ui.visibleRows - 1))
  1617. elseif key == keys.pageDown then scrollBy(math.max(1, ui.visibleRows - 1))
  1618. elseif key == keys.home then ui.scroll = 0; uiDirty = true
  1619. elseif key == keys["end"] then ui.scroll = ui.maxScroll; uiDirty = true
  1620. elseif key == keys.one then setTab(1)
  1621. elseif key == keys.two then setTab(2)
  1622. elseif key == keys.three then setTab(3)
  1623. elseif key == keys.four then setTab(4)
  1624. elseif key == keys.five then setTab(5)
  1625. elseif key == keys.six then setTab(6)
  1626. end
  1627. end
  1628.  
  1629. -- ---------------------------------------------------------------------------
  1630. -- Main loop
  1631. -- ---------------------------------------------------------------------------
  1632.  
  1633. local pollTimer = nil
  1634. local stateTimer = nil
  1635.  
  1636. local function schedulePoll()
  1637. pollTimer = os.startTimer(CONFIG.POLL_SECONDS)
  1638. end
  1639.  
  1640. local function scheduleStateSave()
  1641. stateTimer = os.startTimer(CONFIG.STATE_SAVE_SECONDS)
  1642. end
  1643.  
  1644. -- Initial authoritative sample.
  1645. pollPlayer("startup")
  1646. schedulePoll()
  1647. scheduleStateSave()
  1648. drawUI()
  1649.  
  1650. while true do
  1651. local ev = {os.pullEventRaw()}
  1652. local name = ev[1]
  1653.  
  1654. if name == "terminate" then
  1655. local t = nowMs()
  1656. -- Manual program termination is an observation stop, not a player disconnect.
  1657. if state.currentLocationId then
  1658. endLocationSession(state.lastObservedMs or t, "tracker_stopped", state.lastPos)
  1659. end
  1660. logEvent({type = "SYSTEM_STOP", t = t, message = "Tracker manually stopped"})
  1661. saveState()
  1662. setBg(colors.black)
  1663. setFg(colors.white)
  1664. display.clear()
  1665. display.setCursorPos(1, 1)
  1666. print("MD_Sky tracker stopped.")
  1667. break
  1668.  
  1669. elseif name == "timer" then
  1670. local id = ev[2]
  1671. if id == pollTimer then
  1672. pollPlayer("poll")
  1673. schedulePoll()
  1674. elseif id == stateTimer then
  1675. saveState()
  1676. scheduleStateSave()
  1677. uiDirty = true -- refresh active dwell duration / free-space display
  1678. end
  1679.  
  1680. elseif name == "monitor_touch" then
  1681. local side, x, y = ev[2], ev[3], ev[4]
  1682. if not monitorName or side == monitorName then handleTouch(x, y) end
  1683.  
  1684. elseif name == "mouse_scroll" and not monitor then
  1685. local direction = ev[2]
  1686. scrollBy(direction)
  1687.  
  1688. elseif name == "mouse_click" and not monitor then
  1689. local button, x, y = ev[2], ev[3], ev[4]
  1690. if button == 1 then
  1691. handleTouch(x, y)
  1692. if ui.scrollbar and x == ui.scrollbar.x and y >= ui.scrollbar.trackTop and y <= ui.scrollbar.trackBottom then
  1693. ui.terminalDragging = true
  1694. end
  1695. end
  1696.  
  1697. elseif name == "mouse_drag" and not monitor then
  1698. local button, x, y = ev[2], ev[3], ev[4]
  1699. if button == 1 and ui.terminalDragging and ui.scrollbar then
  1700. scrollbarJump(y)
  1701. end
  1702.  
  1703. elseif name == "mouse_up" and not monitor then
  1704. ui.terminalDragging = false
  1705.  
  1706. elseif name == "key" and not monitor then
  1707. handleKey(ev[2])
  1708.  
  1709. elseif name == "monitor_resize" or name == "term_resize" then
  1710. uiDirty = true
  1711.  
  1712. else
  1713. handlePeripheralEvent(ev)
  1714. end
  1715.  
  1716. if uiDirty then drawUI() end
  1717. end
  1718.  
Advertisement
Add Comment
Please, Sign In to add comment