Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- -- Scoreboard Server + Monitor Admin Panel
- -- ComputerCraft / CC:Tweaked
- local CHANNEL = 44551
- local UI_REFRESH_SEC = 0.2
- local DATA_FILE = "scoreboard_data.db"
- local modemSide
- local monitor
- local participants = {}
- -- participants[computerId] = {
- -- id = number,
- -- username = string,
- -- points = number,
- -- disqualified = bool,
- -- connected = bool,
- -- lastSeen = epoch
- -- }
- local selectedId = nil
- local revealed = false
- local statusMessage = "Waiting for registrations..."
- local resetConfirmUntil = 0
- local removeConfirmUntil = 0
- local removeConfirmId = nil
- local function toHalfStep(value)
- return math.floor((tonumber(value) or 0) * 2 + 0.5) / 2
- end
- local function formatPoints(value)
- local points = toHalfStep(value)
- if points % 1 == 0 then
- return tostring(math.floor(points))
- end
- return string.format("%.1f", points)
- end
- local function formatSignedPoints(value)
- local points = toHalfStep(value)
- local sign = points >= 0 and "+" or ""
- return sign .. formatPoints(points)
- end
- local function saveState()
- local snapshot = {
- revealed = revealed,
- participants = {}
- }
- for id, p in pairs(participants) do
- snapshot.participants[tostring(id)] = {
- id = p.id,
- username = p.username,
- points = p.points,
- disqualified = p.disqualified
- }
- end
- local data = textutils.serialize(snapshot)
- local file = fs.open(DATA_FILE, "w")
- if not file then
- statusMessage = "Failed to save state"
- return false
- end
- file.write(data)
- file.close()
- return true
- end
- local function loadState()
- if not fs.exists(DATA_FILE) then
- return false
- end
- local file = fs.open(DATA_FILE, "r")
- if not file then
- return false
- end
- local raw = file.readAll()
- file.close()
- local decoded = textutils.unserialize(raw)
- if type(decoded) ~= "table" then
- return false
- end
- participants = {}
- if type(decoded.participants) == "table" then
- for key, p in pairs(decoded.participants) do
- if type(p) == "table" then
- local id = tonumber(p.id) or tonumber(key)
- if id then
- participants[id] = {
- id = id,
- username = tostring(p.username or ("Player" .. id)),
- points = toHalfStep(p.points),
- disqualified = p.disqualified == true,
- connected = false,
- lastSeen = 0
- }
- end
- end
- end
- end
- revealed = decoded.revealed == true
- return true
- end
- local function now()
- return os.epoch("utc")
- end
- local function findModemSide()
- for _, side in ipairs(peripheral.getNames()) do
- if peripheral.getType(side) == "modem" then
- return side
- end
- end
- return nil
- end
- local function initPeripherals()
- modemSide = findModemSide()
- if not modemSide then
- error("No modem found. Attach an ender modem.")
- end
- if not rednet.isOpen(modemSide) then
- rednet.open(modemSide)
- end
- monitor = peripheral.find("monitor")
- if not monitor then
- error("No monitor found. Attach a monitor for admin UI.")
- end
- monitor.setTextScale(0.5)
- monitor.setBackgroundColor(colors.black)
- monitor.setTextColor(colors.white)
- monitor.clear()
- end
- local function sortedActive()
- local t = {}
- for _, p in pairs(participants) do
- if not p.disqualified then
- table.insert(t, p)
- end
- end
- table.sort(t, function(a, b)
- if a.points == b.points then
- return string.lower(a.username) < string.lower(b.username)
- end
- return a.points > b.points
- end)
- return t
- end
- local function buildRankedActive()
- local sorted = sortedActive()
- local ranked = {}
- local currentRank = 0
- local previousPoints = nil
- for i, participant in ipairs(sorted) do
- if previousPoints == nil or participant.points ~= previousPoints then
- currentRank = currentRank + 1
- previousPoints = participant.points
- end
- ranked[i] = {
- participant = participant,
- rank = currentRank,
- index = i
- }
- end
- return ranked
- end
- local function sendTo(id, payload)
- payload.channel = CHANNEL
- rednet.send(id, payload, "cc_quiz_scoreboard")
- end
- local function sendScoreStateTo(id)
- local p = participants[id]
- if not p then return end
- if p.disqualified then
- sendTo(id, {
- type = "disqualified",
- reason = "You have been disqualified from the quiz."
- })
- return
- end
- if not revealed then
- sendTo(id, {
- type = "score_hidden",
- message = "Scoreboard is hidden by administration."
- })
- return
- end
- local ranked = buildRankedActive()
- local selfEntry = nil
- for i = 1, #ranked do
- if ranked[i].participant.id == id then
- selfEntry = ranked[i]
- break
- end
- end
- if not selfEntry then
- sendTo(id, {
- type = "score_hidden",
- message = "You are not currently in the ranking."
- })
- return
- end
- local top3 = {}
- for i = 1, #ranked do
- if ranked[i].rank > 3 then
- break
- end
- top3[#top3 + 1] = {
- rank = ranked[i].rank,
- username = ranked[i].participant.username,
- points = ranked[i].participant.points,
- id = ranked[i].participant.id
- }
- end
- local above = nil
- local below = nil
- for i = selfEntry.index - 1, 1, -1 do
- if ranked[i].rank < selfEntry.rank then
- above = {
- rank = ranked[i].rank,
- username = ranked[i].participant.username,
- points = ranked[i].participant.points,
- id = ranked[i].participant.id
- }
- break
- end
- end
- for i = selfEntry.index + 1, #ranked do
- if ranked[i].rank > selfEntry.rank then
- below = {
- rank = ranked[i].rank,
- username = ranked[i].participant.username,
- points = ranked[i].participant.points,
- id = ranked[i].participant.id
- }
- break
- end
- end
- sendTo(id, {
- type = "score_reveal",
- self = {
- rank = selfEntry.rank,
- username = p.username,
- points = p.points,
- id = p.id
- },
- top3 = top3,
- above = above,
- below = below,
- total = #ranked
- })
- end
- local function broadcastState()
- for id, _ in pairs(participants) do
- sendScoreStateTo(id)
- end
- end
- local function registerParticipant(id, username)
- username = tostring(username or "")
- username = username:gsub("^%s+", ""):gsub("%s+$", "")
- if username == "" then
- sendTo(id, {
- type = "register_error",
- message = "Username cannot be empty."
- })
- return
- end
- local existing = participants[id]
- if existing then
- existing.username = username
- existing.connected = true
- existing.lastSeen = now()
- if existing.disqualified then
- sendTo(id, {
- type = "disqualified",
- reason = "You have been disqualified from the quiz."
- })
- else
- sendTo(id, {
- type = "register_ok",
- username = username,
- points = existing.points
- })
- sendScoreStateTo(id)
- end
- statusMessage = "Updated registration for #" .. id .. " as " .. username
- return
- end
- participants[id] = {
- id = id,
- username = username,
- points = 0,
- disqualified = false,
- connected = true,
- lastSeen = now()
- }
- selectedId = selectedId or id
- sendTo(id, {
- type = "register_ok",
- username = username,
- points = 0
- })
- sendScoreStateTo(id)
- statusMessage = "Registered #" .. id .. " as " .. username
- end
- local function disqualifyParticipant(id)
- local p = participants[id]
- if not p then return end
- p.disqualified = true
- if selectedId == id then
- selectedId = nil
- end
- sendTo(id, {
- type = "disqualified",
- reason = "You have been disqualified by administration."
- })
- statusMessage = "Disqualified " .. p.username
- saveState()
- end
- local function removeParticipantFully(id)
- local p = participants[id]
- if not p then return end
- sendTo(id, {
- type = "force_reset",
- reason = "Removed from participant list by administration."
- })
- participants[id] = nil
- if selectedId == id then
- selectedId = nil
- end
- statusMessage = "Removed " .. p.username .. " from list"
- saveState()
- end
- local function adjustPoints(id, delta)
- local p = participants[id]
- if not p or p.disqualified then return end
- p.points = toHalfStep((p.points or 0) + delta)
- statusMessage = string.format("%s: %s (now %s)", p.username, formatSignedPoints(delta), formatPoints(p.points))
- saveState()
- end
- local function resetAllPoints()
- local count = 0
- for _, p in pairs(participants) do
- p.points = 0
- count = count + 1
- end
- statusMessage = "Reset points for " .. count .. " participant(s)"
- saveState()
- end
- local function safeWrite(x, y, text, textColor, bgColor)
- monitor.setCursorPos(x, y)
- if bgColor then monitor.setBackgroundColor(bgColor) end
- if textColor then monitor.setTextColor(textColor) end
- monitor.write(text)
- end
- local buttons = {}
- local function addButton(name, x1, y1, x2, y2, label, color)
- buttons[name] = {
- x1 = x1, y1 = y1, x2 = x2, y2 = y2,
- label = label,
- color = color or colors.gray
- }
- end
- local function drawButton(btn)
- for y = btn.y1, btn.y2 do
- monitor.setCursorPos(btn.x1, y)
- monitor.setBackgroundColor(btn.color)
- monitor.setTextColor(colors.white)
- monitor.write(string.rep(" ", btn.x2 - btn.x1 + 1))
- end
- local width = btn.x2 - btn.x1 + 1
- local label = btn.label
- if #label > width - 2 then
- label = label:sub(1, width - 2)
- end
- local lx = btn.x1 + math.floor((width - #label) / 2)
- local ly = btn.y1 + math.floor((btn.y2 - btn.y1) / 2)
- safeWrite(lx, ly, label, colors.white, btn.color)
- end
- local function participantList()
- local t = {}
- for _, p in pairs(participants) do
- table.insert(t, p)
- end
- table.sort(t, function(a, b)
- if a.disqualified ~= b.disqualified then
- return not a.disqualified
- end
- return string.lower(a.username) < string.lower(b.username)
- end)
- return t
- end
- local function drawUI()
- local w, h = monitor.getSize()
- monitor.setBackgroundColor(colors.black)
- monitor.setTextColor(colors.white)
- monitor.clear()
- safeWrite(1, 1, "CC Quiz Scoreboard Server", colors.yellow, colors.black)
- safeWrite(1, 2, "Channel: " .. CHANNEL .. " Reveal: " .. (revealed and "ON" or "OFF"), colors.lightGray, colors.black)
- local dividerX = math.floor(w * 0.55)
- for y = 1, h do
- safeWrite(dividerX, y, "|", colors.gray, colors.black)
- end
- local list = participantList()
- local listTop = 4
- local listBottom = h - 2
- local row = listTop
- safeWrite(1, 3, "Participants (touch to select)", colors.cyan, colors.black)
- local drawnRows = {}
- for i = 1, #list do
- if row > listBottom then break end
- local p = list[i]
- local prefix = (selectedId == p.id) and "> " or " "
- local status = p.disqualified and " [DQ]" or ""
- local line = string.format("%s%s (%s)%s", prefix, p.username, formatPoints(p.points), status)
- line = line:sub(1, dividerX - 2)
- local bg = (selectedId == p.id) and colors.gray or colors.black
- safeWrite(1, row, string.rep(" ", dividerX - 1), colors.white, bg)
- safeWrite(1, row, line, colors.white, bg)
- drawnRows[row] = p.id
- row = row + 1
- end
- while row <= listBottom do
- safeWrite(1, row, string.rep(" ", dividerX - 1), colors.white, colors.black)
- row = row + 1
- end
- local panelX = dividerX + 2
- safeWrite(panelX, 3, "Admin Controls", colors.cyan, colors.black)
- local selected = selectedId and participants[selectedId] or nil
- if selected then
- safeWrite(panelX, 4, "Selected:", colors.lightGray, colors.black)
- safeWrite(panelX, 5, selected.username, colors.white, colors.black)
- safeWrite(panelX, 6, "Points: " .. formatPoints(selected.points), colors.white, colors.black)
- safeWrite(panelX, 7, "State: " .. (selected.disqualified and "DQ" or "Active"), colors.white, colors.black)
- else
- safeWrite(panelX, 4, "Selected: none", colors.lightGray, colors.black)
- end
- local bx1 = panelX
- local bw = math.max(12, w - panelX - 1)
- local bx2 = math.min(w, bx1 + bw - 1)
- local half = math.floor((bx1 + bx2) / 2)
- addButton("add1", bx1, 9, bx2, 10, "+0.5 Point", colors.green)
- addButton("sub1", bx1, 11, bx2, 12, "-0.5 Point", colors.red)
- addButton("add5", bx1, 13, bx2, 14, "+1 Point", colors.lime)
- addButton("sub5", bx1, 15, bx2, 16, "-1 Point", colors.orange)
- addButton("toggle", bx1, 17, bx2, 18, revealed and "Hide Scoreboard" or "Reveal Scoreboard", colors.blue)
- addButton("reset", bx1, 19, half, 20, "Reset Points", colors.brown)
- addButton("remove", half + 1, 19, bx2, 20, "Remove", colors.purple)
- for _, btn in pairs(buttons) do
- drawButton(btn)
- end
- safeWrite(1, h - 1, string.rep(" ", w), colors.white, colors.black)
- safeWrite(1, h - 1, statusMessage:sub(1, w), colors.yellow, colors.black)
- return drawnRows
- end
- local participantRows = {}
- local function buttonAt(x, y)
- for name, btn in pairs(buttons) do
- if x >= btn.x1 and x <= btn.x2 and y >= btn.y1 and y <= btn.y2 then
- return name
- end
- end
- return nil
- end
- local function handleAdminTouch(x, y)
- if participantRows[y] then
- resetConfirmUntil = 0
- removeConfirmUntil = 0
- removeConfirmId = nil
- selectedId = participantRows[y]
- local p = participants[selectedId]
- statusMessage = p and ("Selected " .. p.username) or statusMessage
- return true
- end
- local name = buttonAt(x, y)
- if not name then
- return false
- end
- if name == "toggle" then
- resetConfirmUntil = 0
- removeConfirmUntil = 0
- removeConfirmId = nil
- revealed = not revealed
- statusMessage = revealed and "Scoreboard revealed" or "Scoreboard hidden"
- saveState()
- broadcastState()
- return true
- end
- if name == "reset" then
- removeConfirmUntil = 0
- removeConfirmId = nil
- if now() <= resetConfirmUntil then
- resetConfirmUntil = 0
- resetAllPoints()
- broadcastState()
- else
- resetConfirmUntil = now() + 3000
- statusMessage = "Tap Reset Points again within 3s to confirm"
- end
- return true
- end
- resetConfirmUntil = 0
- if not selectedId or not participants[selectedId] then
- removeConfirmUntil = 0
- removeConfirmId = nil
- statusMessage = "Select a participant first"
- return true
- end
- local selected = participants[selectedId]
- if name == "add1" then
- removeConfirmUntil = 0
- removeConfirmId = nil
- adjustPoints(selectedId, 0.5)
- elseif name == "sub1" then
- removeConfirmUntil = 0
- removeConfirmId = nil
- adjustPoints(selectedId, -0.5)
- elseif name == "add5" then
- removeConfirmUntil = 0
- removeConfirmId = nil
- adjustPoints(selectedId, 1)
- elseif name == "sub5" then
- removeConfirmUntil = 0
- removeConfirmId = nil
- adjustPoints(selectedId, -1)
- elseif name == "remove" then
- if not selected.disqualified then
- removeConfirmUntil = 0
- removeConfirmId = nil
- disqualifyParticipant(selectedId)
- else
- local currentTs = now()
- if removeConfirmId == selectedId and currentTs <= removeConfirmUntil then
- removeConfirmUntil = 0
- removeConfirmId = nil
- removeParticipantFully(selectedId)
- else
- removeConfirmId = selectedId
- removeConfirmUntil = currentTs + 3000
- statusMessage = "Tap Remove again within 3s to delete " .. selected.username
- end
- end
- end
- broadcastState()
- return true
- end
- local function handleNetworkMessage(senderId, msg, protocol)
- if protocol ~= "cc_quiz_scoreboard" then
- return
- end
- if type(msg) ~= "table" or not msg.type then
- return
- end
- if msg.type == "register" then
- registerParticipant(senderId, msg.username)
- saveState()
- broadcastState()
- elseif msg.type == "heartbeat" then
- local p = participants[senderId]
- if p then
- p.connected = true
- p.lastSeen = now()
- end
- elseif msg.type == "request_state" then
- sendScoreStateTo(senderId)
- end
- end
- local function main()
- initPeripherals()
- if loadState() then
- local count = 0
- for _ in pairs(participants) do
- count = count + 1
- end
- statusMessage = "Loaded saved state (" .. count .. " participants)"
- end
- rednet.host("cc_quiz_scoreboard", "server")
- if statusMessage == "Loaded saved state (0 participants)" or statusMessage == "Waiting for registrations..." then
- statusMessage = "Server online. Waiting for participants..."
- end
- local refreshTimer = os.startTimer(UI_REFRESH_SEC)
- while true do
- participantRows = drawUI()
- local event, p1, p2, p3 = os.pullEvent()
- if event == "monitor_touch" then
- local x, y = p2, p3
- handleAdminTouch(x, y)
- elseif event == "rednet_message" then
- handleNetworkMessage(p1, p2, p3)
- elseif event == "timer" and p1 == refreshTimer then
- refreshTimer = os.startTimer(UI_REFRESH_SEC)
- end
- end
- end
- main()
Advertisement
Add Comment
Please, Sign In to add comment