Sedrowow

scoreboard

Feb 27th, 2026 (edited)
64
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 17.03 KB | Gaming | 0 0
  1. -- Scoreboard Server + Monitor Admin Panel
  2. -- ComputerCraft / CC:Tweaked
  3.  
  4. local CHANNEL = 44551
  5. local UI_REFRESH_SEC = 0.2
  6. local DATA_FILE = "scoreboard_data.db"
  7.  
  8. local modemSide
  9. local monitor
  10.  
  11. local participants = {}
  12. -- participants[computerId] = {
  13. --   id = number,
  14. --   username = string,
  15. --   points = number,
  16. --   disqualified = bool,
  17. --   connected = bool,
  18. --   lastSeen = epoch
  19. -- }
  20.  
  21. local selectedId = nil
  22. local revealed = false
  23. local statusMessage = "Waiting for registrations..."
  24. local resetConfirmUntil = 0
  25. local removeConfirmUntil = 0
  26. local removeConfirmId = nil
  27.  
  28. local function toHalfStep(value)
  29.   return math.floor((tonumber(value) or 0) * 2 + 0.5) / 2
  30. end
  31.  
  32. local function formatPoints(value)
  33.   local points = toHalfStep(value)
  34.   if points % 1 == 0 then
  35.     return tostring(math.floor(points))
  36.   end
  37.   return string.format("%.1f", points)
  38. end
  39.  
  40. local function formatSignedPoints(value)
  41.   local points = toHalfStep(value)
  42.   local sign = points >= 0 and "+" or ""
  43.   return sign .. formatPoints(points)
  44. end
  45.  
  46. local function saveState()
  47.   local snapshot = {
  48.     revealed = revealed,
  49.     participants = {}
  50.   }
  51.  
  52.   for id, p in pairs(participants) do
  53.     snapshot.participants[tostring(id)] = {
  54.       id = p.id,
  55.       username = p.username,
  56.       points = p.points,
  57.       disqualified = p.disqualified
  58.     }
  59.   end
  60.  
  61.   local data = textutils.serialize(snapshot)
  62.   local file = fs.open(DATA_FILE, "w")
  63.   if not file then
  64.     statusMessage = "Failed to save state"
  65.     return false
  66.   end
  67.   file.write(data)
  68.   file.close()
  69.   return true
  70. end
  71.  
  72. local function loadState()
  73.   if not fs.exists(DATA_FILE) then
  74.     return false
  75.   end
  76.  
  77.   local file = fs.open(DATA_FILE, "r")
  78.   if not file then
  79.     return false
  80.   end
  81.  
  82.   local raw = file.readAll()
  83.   file.close()
  84.  
  85.   local decoded = textutils.unserialize(raw)
  86.   if type(decoded) ~= "table" then
  87.     return false
  88.   end
  89.  
  90.   participants = {}
  91.   if type(decoded.participants) == "table" then
  92.     for key, p in pairs(decoded.participants) do
  93.       if type(p) == "table" then
  94.         local id = tonumber(p.id) or tonumber(key)
  95.         if id then
  96.           participants[id] = {
  97.             id = id,
  98.             username = tostring(p.username or ("Player" .. id)),
  99.             points = toHalfStep(p.points),
  100.             disqualified = p.disqualified == true,
  101.             connected = false,
  102.             lastSeen = 0
  103.           }
  104.         end
  105.       end
  106.     end
  107.   end
  108.  
  109.   revealed = decoded.revealed == true
  110.   return true
  111. end
  112.  
  113. local function now()
  114.   return os.epoch("utc")
  115. end
  116.  
  117. local function findModemSide()
  118.   for _, side in ipairs(peripheral.getNames()) do
  119.     if peripheral.getType(side) == "modem" then
  120.       return side
  121.     end
  122.   end
  123.   return nil
  124. end
  125.  
  126. local function initPeripherals()
  127.   modemSide = findModemSide()
  128.   if not modemSide then
  129.     error("No modem found. Attach an ender modem.")
  130.   end
  131.  
  132.   if not rednet.isOpen(modemSide) then
  133.     rednet.open(modemSide)
  134.   end
  135.  
  136.   monitor = peripheral.find("monitor")
  137.   if not monitor then
  138.     error("No monitor found. Attach a monitor for admin UI.")
  139.   end
  140.  
  141.   monitor.setTextScale(0.5)
  142.   monitor.setBackgroundColor(colors.black)
  143.   monitor.setTextColor(colors.white)
  144.   monitor.clear()
  145. end
  146.  
  147. local function sortedActive()
  148.   local t = {}
  149.   for _, p in pairs(participants) do
  150.     if not p.disqualified then
  151.       table.insert(t, p)
  152.     end
  153.   end
  154.   table.sort(t, function(a, b)
  155.     if a.points == b.points then
  156.       return string.lower(a.username) < string.lower(b.username)
  157.     end
  158.     return a.points > b.points
  159.   end)
  160.   return t
  161. end
  162.  
  163. local function buildRankedActive()
  164.   local sorted = sortedActive()
  165.   local ranked = {}
  166.  
  167.   local currentRank = 0
  168.   local previousPoints = nil
  169.  
  170.   for i, participant in ipairs(sorted) do
  171.     if previousPoints == nil or participant.points ~= previousPoints then
  172.       currentRank = currentRank + 1
  173.       previousPoints = participant.points
  174.     end
  175.  
  176.     ranked[i] = {
  177.       participant = participant,
  178.       rank = currentRank,
  179.       index = i
  180.     }
  181.   end
  182.  
  183.   return ranked
  184. end
  185.  
  186. local function sendTo(id, payload)
  187.   payload.channel = CHANNEL
  188.   rednet.send(id, payload, "cc_quiz_scoreboard")
  189. end
  190.  
  191. local function sendScoreStateTo(id)
  192.   local p = participants[id]
  193.   if not p then return end
  194.  
  195.   if p.disqualified then
  196.     sendTo(id, {
  197.       type = "disqualified",
  198.       reason = "You have been disqualified from the quiz."
  199.     })
  200.     return
  201.   end
  202.  
  203.   if not revealed then
  204.     sendTo(id, {
  205.       type = "score_hidden",
  206.       message = "Scoreboard is hidden by administration."
  207.     })
  208.     return
  209.   end
  210.  
  211.   local ranked = buildRankedActive()
  212.   local selfEntry = nil
  213.   for i = 1, #ranked do
  214.     if ranked[i].participant.id == id then
  215.       selfEntry = ranked[i]
  216.       break
  217.     end
  218.   end
  219.  
  220.   if not selfEntry then
  221.     sendTo(id, {
  222.       type = "score_hidden",
  223.       message = "You are not currently in the ranking."
  224.     })
  225.     return
  226.   end
  227.  
  228.   local top3 = {}
  229.   for i = 1, #ranked do
  230.     if ranked[i].rank > 3 then
  231.       break
  232.     end
  233.     top3[#top3 + 1] = {
  234.       rank = ranked[i].rank,
  235.       username = ranked[i].participant.username,
  236.       points = ranked[i].participant.points,
  237.       id = ranked[i].participant.id
  238.     }
  239.   end
  240.  
  241.   local above = nil
  242.   local below = nil
  243.   for i = selfEntry.index - 1, 1, -1 do
  244.     if ranked[i].rank < selfEntry.rank then
  245.       above = {
  246.         rank = ranked[i].rank,
  247.         username = ranked[i].participant.username,
  248.         points = ranked[i].participant.points,
  249.         id = ranked[i].participant.id
  250.       }
  251.       break
  252.     end
  253.   end
  254.  
  255.   for i = selfEntry.index + 1, #ranked do
  256.     if ranked[i].rank > selfEntry.rank then
  257.       below = {
  258.         rank = ranked[i].rank,
  259.         username = ranked[i].participant.username,
  260.         points = ranked[i].participant.points,
  261.         id = ranked[i].participant.id
  262.       }
  263.       break
  264.     end
  265.   end
  266.  
  267.   sendTo(id, {
  268.     type = "score_reveal",
  269.     self = {
  270.       rank = selfEntry.rank,
  271.       username = p.username,
  272.       points = p.points,
  273.       id = p.id
  274.     },
  275.     top3 = top3,
  276.     above = above,
  277.     below = below,
  278.     total = #ranked
  279.   })
  280. end
  281.  
  282. local function broadcastState()
  283.   for id, _ in pairs(participants) do
  284.     sendScoreStateTo(id)
  285.   end
  286. end
  287.  
  288. local function registerParticipant(id, username)
  289.   username = tostring(username or "")
  290.   username = username:gsub("^%s+", ""):gsub("%s+$", "")
  291.  
  292.   if username == "" then
  293.     sendTo(id, {
  294.       type = "register_error",
  295.       message = "Username cannot be empty."
  296.     })
  297.     return
  298.   end
  299.  
  300.   local existing = participants[id]
  301.   if existing then
  302.     existing.username = username
  303.     existing.connected = true
  304.     existing.lastSeen = now()
  305.     if existing.disqualified then
  306.       sendTo(id, {
  307.         type = "disqualified",
  308.         reason = "You have been disqualified from the quiz."
  309.       })
  310.     else
  311.       sendTo(id, {
  312.         type = "register_ok",
  313.         username = username,
  314.         points = existing.points
  315.       })
  316.       sendScoreStateTo(id)
  317.     end
  318.     statusMessage = "Updated registration for #" .. id .. " as " .. username
  319.     return
  320.   end
  321.  
  322.   participants[id] = {
  323.     id = id,
  324.     username = username,
  325.     points = 0,
  326.     disqualified = false,
  327.     connected = true,
  328.     lastSeen = now()
  329.   }
  330.  
  331.   selectedId = selectedId or id
  332.  
  333.   sendTo(id, {
  334.     type = "register_ok",
  335.     username = username,
  336.     points = 0
  337.   })
  338.   sendScoreStateTo(id)
  339.  
  340.   statusMessage = "Registered #" .. id .. " as " .. username
  341. end
  342.  
  343. local function disqualifyParticipant(id)
  344.   local p = participants[id]
  345.   if not p then return end
  346.   p.disqualified = true
  347.  
  348.   if selectedId == id then
  349.     selectedId = nil
  350.   end
  351.  
  352.   sendTo(id, {
  353.     type = "disqualified",
  354.     reason = "You have been disqualified by administration."
  355.   })
  356.  
  357.   statusMessage = "Disqualified " .. p.username
  358.   saveState()
  359. end
  360.  
  361. local function removeParticipantFully(id)
  362.   local p = participants[id]
  363.   if not p then return end
  364.  
  365.   sendTo(id, {
  366.     type = "force_reset",
  367.     reason = "Removed from participant list by administration."
  368.   })
  369.  
  370.   participants[id] = nil
  371.   if selectedId == id then
  372.     selectedId = nil
  373.   end
  374.  
  375.   statusMessage = "Removed " .. p.username .. " from list"
  376.   saveState()
  377. end
  378.  
  379. local function adjustPoints(id, delta)
  380.   local p = participants[id]
  381.   if not p or p.disqualified then return end
  382.   p.points = toHalfStep((p.points or 0) + delta)
  383.   statusMessage = string.format("%s: %s (now %s)", p.username, formatSignedPoints(delta), formatPoints(p.points))
  384.   saveState()
  385. end
  386.  
  387. local function resetAllPoints()
  388.   local count = 0
  389.   for _, p in pairs(participants) do
  390.     p.points = 0
  391.     count = count + 1
  392.   end
  393.   statusMessage = "Reset points for " .. count .. " participant(s)"
  394.   saveState()
  395. end
  396.  
  397. local function safeWrite(x, y, text, textColor, bgColor)
  398.   monitor.setCursorPos(x, y)
  399.   if bgColor then monitor.setBackgroundColor(bgColor) end
  400.   if textColor then monitor.setTextColor(textColor) end
  401.   monitor.write(text)
  402. end
  403.  
  404. local buttons = {}
  405.  
  406. local function addButton(name, x1, y1, x2, y2, label, color)
  407.   buttons[name] = {
  408.     x1 = x1, y1 = y1, x2 = x2, y2 = y2,
  409.     label = label,
  410.     color = color or colors.gray
  411.   }
  412. end
  413.  
  414. local function drawButton(btn)
  415.   for y = btn.y1, btn.y2 do
  416.     monitor.setCursorPos(btn.x1, y)
  417.     monitor.setBackgroundColor(btn.color)
  418.     monitor.setTextColor(colors.white)
  419.     monitor.write(string.rep(" ", btn.x2 - btn.x1 + 1))
  420.   end
  421.   local width = btn.x2 - btn.x1 + 1
  422.   local label = btn.label
  423.   if #label > width - 2 then
  424.     label = label:sub(1, width - 2)
  425.   end
  426.   local lx = btn.x1 + math.floor((width - #label) / 2)
  427.   local ly = btn.y1 + math.floor((btn.y2 - btn.y1) / 2)
  428.   safeWrite(lx, ly, label, colors.white, btn.color)
  429. end
  430.  
  431. local function participantList()
  432.   local t = {}
  433.   for _, p in pairs(participants) do
  434.     table.insert(t, p)
  435.   end
  436.   table.sort(t, function(a, b)
  437.     if a.disqualified ~= b.disqualified then
  438.       return not a.disqualified
  439.     end
  440.     return string.lower(a.username) < string.lower(b.username)
  441.   end)
  442.   return t
  443. end
  444.  
  445. local function drawUI()
  446.   local w, h = monitor.getSize()
  447.   monitor.setBackgroundColor(colors.black)
  448.   monitor.setTextColor(colors.white)
  449.   monitor.clear()
  450.  
  451.   safeWrite(1, 1, "CC Quiz Scoreboard Server", colors.yellow, colors.black)
  452.   safeWrite(1, 2, "Channel: " .. CHANNEL .. "  Reveal: " .. (revealed and "ON" or "OFF"), colors.lightGray, colors.black)
  453.  
  454.   local dividerX = math.floor(w * 0.55)
  455.   for y = 1, h do
  456.     safeWrite(dividerX, y, "|", colors.gray, colors.black)
  457.   end
  458.  
  459.   local list = participantList()
  460.   local listTop = 4
  461.   local listBottom = h - 2
  462.   local row = listTop
  463.  
  464.   safeWrite(1, 3, "Participants (touch to select)", colors.cyan, colors.black)
  465.  
  466.   local drawnRows = {}
  467.   for i = 1, #list do
  468.     if row > listBottom then break end
  469.     local p = list[i]
  470.     local prefix = (selectedId == p.id) and "> " or "  "
  471.     local status = p.disqualified and " [DQ]" or ""
  472.     local line = string.format("%s%s (%s)%s", prefix, p.username, formatPoints(p.points), status)
  473.     line = line:sub(1, dividerX - 2)
  474.  
  475.     local bg = (selectedId == p.id) and colors.gray or colors.black
  476.     safeWrite(1, row, string.rep(" ", dividerX - 1), colors.white, bg)
  477.     safeWrite(1, row, line, colors.white, bg)
  478.     drawnRows[row] = p.id
  479.     row = row + 1
  480.   end
  481.  
  482.   while row <= listBottom do
  483.     safeWrite(1, row, string.rep(" ", dividerX - 1), colors.white, colors.black)
  484.     row = row + 1
  485.   end
  486.  
  487.   local panelX = dividerX + 2
  488.   safeWrite(panelX, 3, "Admin Controls", colors.cyan, colors.black)
  489.  
  490.   local selected = selectedId and participants[selectedId] or nil
  491.   if selected then
  492.     safeWrite(panelX, 4, "Selected:", colors.lightGray, colors.black)
  493.     safeWrite(panelX, 5, selected.username, colors.white, colors.black)
  494.     safeWrite(panelX, 6, "Points: " .. formatPoints(selected.points), colors.white, colors.black)
  495.     safeWrite(panelX, 7, "State: " .. (selected.disqualified and "DQ" or "Active"), colors.white, colors.black)
  496.   else
  497.     safeWrite(panelX, 4, "Selected: none", colors.lightGray, colors.black)
  498.   end
  499.  
  500.   local bx1 = panelX
  501.   local bw = math.max(12, w - panelX - 1)
  502.   local bx2 = math.min(w, bx1 + bw - 1)
  503.   local half = math.floor((bx1 + bx2) / 2)
  504.  
  505.   addButton("add1", bx1, 9, bx2, 10, "+0.5 Point", colors.green)
  506.   addButton("sub1", bx1, 11, bx2, 12, "-0.5 Point", colors.red)
  507.   addButton("add5", bx1, 13, bx2, 14, "+1 Point", colors.lime)
  508.   addButton("sub5", bx1, 15, bx2, 16, "-1 Point", colors.orange)
  509.   addButton("toggle", bx1, 17, bx2, 18, revealed and "Hide Scoreboard" or "Reveal Scoreboard", colors.blue)
  510.   addButton("reset", bx1, 19, half, 20, "Reset Points", colors.brown)
  511.   addButton("remove", half + 1, 19, bx2, 20, "Remove", colors.purple)
  512.  
  513.   for _, btn in pairs(buttons) do
  514.     drawButton(btn)
  515.   end
  516.  
  517.   safeWrite(1, h - 1, string.rep(" ", w), colors.white, colors.black)
  518.   safeWrite(1, h - 1, statusMessage:sub(1, w), colors.yellow, colors.black)
  519.  
  520.   return drawnRows
  521. end
  522.  
  523. local participantRows = {}
  524.  
  525. local function buttonAt(x, y)
  526.   for name, btn in pairs(buttons) do
  527.     if x >= btn.x1 and x <= btn.x2 and y >= btn.y1 and y <= btn.y2 then
  528.       return name
  529.     end
  530.   end
  531.   return nil
  532. end
  533.  
  534. local function handleAdminTouch(x, y)
  535.   if participantRows[y] then
  536.     resetConfirmUntil = 0
  537.     removeConfirmUntil = 0
  538.     removeConfirmId = nil
  539.     selectedId = participantRows[y]
  540.     local p = participants[selectedId]
  541.     statusMessage = p and ("Selected " .. p.username) or statusMessage
  542.     return true
  543.   end
  544.  
  545.   local name = buttonAt(x, y)
  546.   if not name then
  547.     return false
  548.   end
  549.  
  550.   if name == "toggle" then
  551.     resetConfirmUntil = 0
  552.     removeConfirmUntil = 0
  553.     removeConfirmId = nil
  554.     revealed = not revealed
  555.     statusMessage = revealed and "Scoreboard revealed" or "Scoreboard hidden"
  556.     saveState()
  557.     broadcastState()
  558.     return true
  559.   end
  560.  
  561.   if name == "reset" then
  562.     removeConfirmUntil = 0
  563.     removeConfirmId = nil
  564.     if now() <= resetConfirmUntil then
  565.       resetConfirmUntil = 0
  566.       resetAllPoints()
  567.       broadcastState()
  568.     else
  569.       resetConfirmUntil = now() + 3000
  570.       statusMessage = "Tap Reset Points again within 3s to confirm"
  571.     end
  572.     return true
  573.   end
  574.  
  575.   resetConfirmUntil = 0
  576.  
  577.   if not selectedId or not participants[selectedId] then
  578.     removeConfirmUntil = 0
  579.     removeConfirmId = nil
  580.     statusMessage = "Select a participant first"
  581.     return true
  582.   end
  583.  
  584.   local selected = participants[selectedId]
  585.  
  586.   if name == "add1" then
  587.     removeConfirmUntil = 0
  588.     removeConfirmId = nil
  589.     adjustPoints(selectedId, 0.5)
  590.   elseif name == "sub1" then
  591.     removeConfirmUntil = 0
  592.     removeConfirmId = nil
  593.     adjustPoints(selectedId, -0.5)
  594.   elseif name == "add5" then
  595.     removeConfirmUntil = 0
  596.     removeConfirmId = nil
  597.     adjustPoints(selectedId, 1)
  598.   elseif name == "sub5" then
  599.     removeConfirmUntil = 0
  600.     removeConfirmId = nil
  601.     adjustPoints(selectedId, -1)
  602.   elseif name == "remove" then
  603.     if not selected.disqualified then
  604.       removeConfirmUntil = 0
  605.       removeConfirmId = nil
  606.       disqualifyParticipant(selectedId)
  607.     else
  608.       local currentTs = now()
  609.       if removeConfirmId == selectedId and currentTs <= removeConfirmUntil then
  610.         removeConfirmUntil = 0
  611.         removeConfirmId = nil
  612.         removeParticipantFully(selectedId)
  613.       else
  614.         removeConfirmId = selectedId
  615.         removeConfirmUntil = currentTs + 3000
  616.         statusMessage = "Tap Remove again within 3s to delete " .. selected.username
  617.       end
  618.     end
  619.   end
  620.  
  621.   broadcastState()
  622.   return true
  623. end
  624.  
  625. local function handleNetworkMessage(senderId, msg, protocol)
  626.   if protocol ~= "cc_quiz_scoreboard" then
  627.     return
  628.   end
  629.  
  630.   if type(msg) ~= "table" or not msg.type then
  631.     return
  632.   end
  633.  
  634.   if msg.type == "register" then
  635.     registerParticipant(senderId, msg.username)
  636.     saveState()
  637.     broadcastState()
  638.   elseif msg.type == "heartbeat" then
  639.     local p = participants[senderId]
  640.     if p then
  641.       p.connected = true
  642.       p.lastSeen = now()
  643.     end
  644.   elseif msg.type == "request_state" then
  645.     sendScoreStateTo(senderId)
  646.   end
  647. end
  648.  
  649. local function main()
  650.   initPeripherals()
  651.  
  652.   if loadState() then
  653.     local count = 0
  654.     for _ in pairs(participants) do
  655.       count = count + 1
  656.     end
  657.     statusMessage = "Loaded saved state (" .. count .. " participants)"
  658.   end
  659.  
  660.   rednet.host("cc_quiz_scoreboard", "server")
  661.   if statusMessage == "Loaded saved state (0 participants)" or statusMessage == "Waiting for registrations..." then
  662.     statusMessage = "Server online. Waiting for participants..."
  663.   end
  664.  
  665.   local refreshTimer = os.startTimer(UI_REFRESH_SEC)
  666.  
  667.   while true do
  668.     participantRows = drawUI()
  669.  
  670.     local event, p1, p2, p3 = os.pullEvent()
  671.  
  672.     if event == "monitor_touch" then
  673.       local x, y = p2, p3
  674.       handleAdminTouch(x, y)
  675.  
  676.     elseif event == "rednet_message" then
  677.       handleNetworkMessage(p1, p2, p3)
  678.  
  679.     elseif event == "timer" and p1 == refreshTimer then
  680.       refreshTimer = os.startTimer(UI_REFRESH_SEC)
  681.     end
  682.   end
  683. end
  684.  
  685. main()
  686.  
Advertisement
Add Comment
Please, Sign In to add comment