Files
cc-stats/main.lua
2026-03-25 20:29:38 +00:00

309 lines
9.4 KiB
Lua

-- leaderboard.lua
-- Fetches player stats and displays a cycling leaderboard on wrapped monitors.
local API_URL = "https://script.lclr.dev/api/w/scripts/jobs/run_wait_result/p/f/scripts/cc-stats"
local API_TOKEN = "4mHiIDc5y1YBX05j68MFyhVoo2qbICe3"
local CYCLE_DELAY = 5 -- seconds per stat
local FETCH_COOLDOWN = 300 -- seconds between fetches (5 minutes)
-- Stats to cycle through (in order)
local STAT_KEYS = {
"playtime",
"deaths",
"mobs_killed",
"damage_taken",
"damage_dealt",
"distance_walked",
"lootr",
"waystones",
}
-- Rank colours (ComputerCraft colour constants)
local RANK_COLORS = {
[1] = colors.yellow, -- gold
[2] = colors.lightGray,
[3] = colors.orange, -- brown-ish (closest CC colour)
}
local DEFAULT_COLOR = colors.white
local TITLE_COLOR = colors.cyan
local SEP_COLOR = colors.gray
local LABEL_COLOR = colors.lightBlue
local BG_COLOR = colors.black
-- Helpers
local function getMonitors()
local mons = {}
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "monitor" then
local m = peripheral.wrap(name)
if m then
m.setTextScale(0.5) -- start at base scale; will be overridden
table.insert(mons, m)
end
end
end
return mons
end
local function centerText(mon, y, text, color, scale)
-- scale is unused here; CC monitors don't support per-text scaling,
-- so we use setTextScale on the whole monitor between draws.
local w, _ = mon.getSize()
local x = math.max(1, math.floor((w - #text) / 2) + 1)
mon.setCursorPos(x, y)
if color then mon.setTextColor(color) end
mon.write(text)
end
local function leftText(mon, y, text, color)
mon.setCursorPos(1, y)
if color then mon.setTextColor(color) end
mon.write(text)
end
local function truncate(str, maxLen)
if #str > maxLen then
return str:sub(1, maxLen - 1) .. "."
end
return str
end
-- Data fetching
local function fetchData()
local ok, result = pcall(function()
-- Build JSON payload with list of stats to fetch
local payload = textutils.serialiseJSON(STAT_KEYS)
local resp = http.post(
API_URL,
payload,
{ ["Content-Type"] = "application/json", ["Authorization"] = "Bearer " .. API_TOKEN }
)
if not resp then error("HTTP request failed") end
local raw = resp.readAll()
resp.close()
return textutils.unserialiseJSON(raw)
end)
if ok and type(result) == "table" then
return result
end
return nil
end
-- Build sorted list of {username, value, type, label} for a given stat key.
-- New format: {"<pseudo>":{"<stat_name>":{"type":"<type>","data":"<value>","label":"<label>"}}}
-- Skips players with no value for the stat. Returns nil if list is empty.
local function buildLeaderboard(data, statKey)
local entries = {}
local statLabel = statKey -- fallback if no label found
for username, stats in pairs(data) do
if type(stats) == "table" and stats[statKey] then
local statInfo = stats[statKey]
if type(statInfo) == "table" and statInfo.data then
-- Extract label from first valid entry
if statInfo.label and statLabel == statKey then
statLabel = statInfo.label
end
-- Convert data to number
local val = tonumber(statInfo.data)
if val then
table.insert(entries, {
username = username,
value = val,
dataType = statInfo.type or "int"
})
end
end
end
end
if #entries == 0 then return nil end
table.sort(entries, function(a, b) return a.value > b.value end)
return entries, statLabel
end
-- Format a raw value into a readable string based on type
local function formatValue(dataType, val)
if dataType == "time" then
-- Assuming time is in ticks (20 ticks/s)
local secs = math.floor(val / 20)
local mins = math.floor(secs / 60)
local hours = math.floor(mins / 60)
mins = mins % 60
return string.format("%dh %02dm", hours, mins)
elseif dataType == "distance" then
-- Distance in centimeters
local m = math.floor(val / 100)
if m >= 1000 then
return string.format("%.1f km", m / 1000)
end
return m .. " m"
else
-- Format large numbers with k/m suffix
if val >= 1000000 then
return string.format("%.1fm", val / 1000000)
elseif val >= 1000 then
return string.format("%.1fk", val / 1000)
end
return tostring(math.floor(val))
end
end
-- Rendering
local function renderLeaderboard(mon, entries, statLabel)
mon.setBackgroundColor(BG_COLOR)
mon.clear()
mon.setTextColor(DEFAULT_COLOR)
local w, h = mon.getSize()
local sep = string.rep("-", w)
-- Section 1: Title (large)
mon.setTextScale(1.5)
w, h = mon.getSize() -- dimensions change with text scale
-- Row 1: "CLASSEMENT"
centerText(mon, 1, "CLASSEMENT", TITLE_COLOR)
-- Section 2: Separator + stat label (medium)
mon.setTextScale(1)
w, h = mon.getSize()
sep = string.rep("-", w)
-- We've already drawn row 1 at scale 1.5; now we're at scale 1.
-- CC resets after setTextScale, so we redraw everything at one scale.
-- Strategy: do two passes — draw title zone, then switch scale for rest.
-- Simpler approach: use a fixed scale and use padding rows instead.
-- Re-draw everything at uniform scale 1 so layout is predictable.
mon.setTextScale(1)
w, h = mon.getSize()
mon.setBackgroundColor(BG_COLOR)
mon.clear()
sep = string.rep("-", w)
local row = 1
-- Title
centerText(mon, row, "CLASSEMENT", TITLE_COLOR) ; row = row + 1
-- Separator
mon.setCursorPos(1, row) ; mon.setTextColor(SEP_COLOR) ; mon.write(sep) ; row = row + 1
-- Stat label (from API)
centerText(mon, row, statLabel, LABEL_COLOR) ; row = row + 1
-- Separator
mon.setCursorPos(1, row) ; mon.setTextColor(SEP_COLOR) ; mon.write(sep) ; row = row + 1
-- Players
-- rank 1 gets a "large" look: prefix + name on one line, value below,
-- with some visual weight. Since CC doesn't support mid-render font
-- scaling, we approximate:
-- rank 1 -> full width, capitalised name, value right-aligned
-- ranks 2-3 -> indented slightly, normal case
-- ranks 4+ -> further indented, dimmer
--
-- We check remaining vertical space and stop when full.
for i, entry in ipairs(entries) do
if row > h then break end
local rankPrefix
local nameColor = RANK_COLORS[i] or DEFAULT_COLOR
local valueColor = RANK_COLORS[i] or colors.lightGray
if i == 1 then
rankPrefix = "#1 "
elseif i == 2 then
rankPrefix = " #2 "
elseif i == 3 then
rankPrefix = " #3 "
else
rankPrefix = string.format(" #%-2d", i)
end
local valStr = formatValue(entry.dataType, entry.value)
local prefix = rankPrefix
local maxName = w - #prefix - #valStr - 1
local name = truncate(entry.username, math.max(3, maxName))
-- Pad so value is right-aligned
local line = prefix .. name
local spaces = w - #line - #valStr
if spaces < 1 then spaces = 1 end
line = line .. string.rep(" ", spaces) .. valStr
-- Draw prefix in rank colour
mon.setCursorPos(1, row)
mon.setTextColor(nameColor)
mon.write(prefix)
-- Draw name
mon.setTextColor(nameColor)
mon.write(name)
-- Draw spaces (neutral)
mon.setTextColor(BG_COLOR)
mon.write(string.rep(" ", spaces))
-- Draw value
mon.setTextColor(valueColor)
mon.write(valStr)
row = row + 1
-- Extra blank line after rank 1 for visual separation
if i == 1 and row <= h then
row = row + 1
end
end
end
-- Main loop
local function main()
local data = nil
local statIdx = 1
local lastFetch = 0 -- start at 0 to force initial fetch
while true do
-- Check if we need to fetch new data (every 5 minutes minimum)
local currentTime = os.epoch("utc") / 1000 -- convert to seconds
if data == nil or (currentTime - lastFetch) >= FETCH_COOLDOWN then
local newData = fetchData()
if newData then
data = newData
lastFetch = currentTime
end
end
local statKey = STAT_KEYS[statIdx]
local entries = nil
local statLabel = statKey
if data then
entries, statLabel = buildLeaderboard(data, statKey)
end
-- Get fresh monitors each tick (in case of reconnect)
local monitors = getMonitors()
if entries then
for _, mon in ipairs(monitors) do
pcall(renderLeaderboard, mon, entries, statLabel)
end
end
-- (if no entries for this stat we simply skip drawing & move on)
-- Advance to next stat
statIdx = (statIdx % #STAT_KEYS) + 1
-- Wait before next stat
os.sleep(CYCLE_DELAY)
end
end
main()