lots of mod info added

This commit is contained in:
2026-09-11 00:07:19 -06:00
parent 30f175a5da
commit 95bf06a012
92 changed files with 6466 additions and 67 deletions
+347
View File
@@ -0,0 +1,347 @@
-- The Privileges Manager formspec: searchable player list on the left,
-- toggle-switch privilege rows on the right. Built with formspec version 4
-- (real coordinates) so it renders identically on Minetest Game and
-- Mineclonia / VoxeLibre.
local FORMNAME = "priviledges_manager:main"
local esc = minetest.formspec_escape
-- Per-viewer interface state. Cleared when the form is closed.
local ctx = {}
local function get_ctx(name)
ctx[name] = ctx[name] or { query = "", target = nil, filtered = {} }
return ctx[name]
end
-- Theme colours, adapted to the host game's palette.
local THEME = priviledges_manager.is_mcl and {
bg = "#0d0d0fee",
panel = "#00000055",
on = "#3fae54", -- switch ON (green)
off = "#5a5a5e", -- switch OFF (grey)
locked = "#3a2a2a", -- not grantable by this viewer
} or {
bg = "#23232bee",
panel = "#0000004f",
on = "#4caf50",
off = "#6d6d75",
locked = "#4a3030",
}
-- Layout constants. The formspec is deliberately wide so that long
-- privilege descriptions fit on the right without being clipped.
local W, H = 19.0, 12.0
local PRIV_ROW_H = 1.15
local CONTENT_X, CONTENT_Y = 6.1, 2.2
local CONTENT_W, CONTENT_H = 12.0, 9.0
-- Word-wrap `text` into at most `maxlines` lines of about `maxchars`
-- characters each, appending an ellipsis if it had to be cut short. This is
-- deterministic (no reliance on a widget auto-wrapping), so descriptions can
-- never spill under the toggle button.
local function wrap(text, maxchars, maxlines)
local words = {}
for w in tostring(text or ""):gmatch("%S+") do
words[#words + 1] = w
end
local lines, cur, i = {}, "", 1
while i <= #words do
local cand = cur == "" and words[i] or (cur .. " " .. words[i])
if #cand <= maxchars then
cur = cand
i = i + 1
elseif cur == "" then
-- A single word longer than the line: hard-cut it.
cur = words[i]:sub(1, maxchars - 1) .. "\u{2026}"
i = i + 1
else
lines[#lines + 1] = cur
cur = ""
if #lines == maxlines then
break
end
end
end
if #lines < maxlines and cur ~= "" then
lines[#lines + 1] = cur
cur = ""
end
-- Words still remaining means we ran out of lines: mark a truncation.
if i <= #words then
local last = lines[#lines] or ""
if #last + 1 > maxchars then
last = last:sub(1, maxchars - 1)
end
lines[#lines] = last .. "\u{2026}"
end
return lines
end
local function build_player_list(c)
local players = priviledges_manager.players.list(c.query)
c.filtered = players
local items, selidx = {}, 0
for i, p in ipairs(players) do
local marker = p.online and "\u{25CF} " or "\u{25CB} "
local colour = p.online and "#7fe07f" or "#bfbfbf"
items[i] = colour .. esc(marker .. p.name)
if p.name == c.target then
selidx = i
end
end
return table.concat(items, ","), selidx, #players
end
local function build_priv_rows(c, viewer)
if not c.target then
return ("label[%f,%f;%s]"):format(
CONTENT_X + 0.3, CONTENT_Y + 0.4,
esc("Select a player on the left to manage their privileges."))
end
local privs = priviledges_manager.privs.all()
local held = minetest.get_player_privs(c.target)
-- Geometry shared by every row. The toggle is pinned to the right and the
-- text is wrapped to end well before it, so they can never overlap.
local btn_w, btn_h = 1.7, 0.7
local btn_x = CONTENT_W - btn_w - 0.5 -- toggle pinned to the right
local text_right = btn_x - 0.3 -- text must end before here
local text_w = text_right - 0.5
-- Conservative character budget for the wrapped description (tuned for the
-- default font so it stays inside even if the smaller font is unavailable).
local desc_chars = math.max(12, math.floor(text_w / 0.23))
local LINE_H = 0.27
-- First pass: wrap every description (no truncation) and work out each
-- row's height so the full text always fits. Rows grow as tall as needed.
local rows = {}
local y = 0.1
for i, p in ipairs(privs) do
local desc = (p.def and p.def.description) or ""
local dlines = desc ~= "" and wrap(desc, desc_chars, math.huge) or {}
local row_h = #dlines > 0
and (0.52 + #dlines * LINE_H + 0.12) or 0.72
rows[i] = { p = p, desc = desc, dlines = dlines, y = y, h = row_h }
y = y + row_h + 0.06
end
local content_h = y + 0.05
-- Size the scrollbar to the actual content so the full list is reachable
-- and the thumb reflects how much is visible.
local scroll_factor = 0.1
local overflow = math.max(0, content_h - CONTENT_H)
local max_units = math.ceil(overflow / scroll_factor)
local thumb = overflow > 0
and math.max(1, math.floor(max_units * CONTENT_H / overflow)) or 0
local value = math.min(c.scroll or 0, max_units)
local fs = {}
fs[#fs + 1] = ("scrollbaroptions[arrows=default;max=%d;thumbsize=%d]"):format(
max_units, thumb)
fs[#fs + 1] = ("scrollbar[%f,%f;0.35,%f;vertical;privscroll;%d]"):format(
CONTENT_X + CONTENT_W + 0.05, CONTENT_Y, CONTENT_H, value)
fs[#fs + 1] = ("scroll_container[%f,%f;%f,%f;privscroll;vertical;%f]"):format(
CONTENT_X, CONTENT_Y, CONTENT_W, CONTENT_H, scroll_factor)
-- Slightly smaller text for the privilege rows. Only affects labels emitted
-- after this point (the rows + nothing else of note before the container end).
fs[#fs + 1] = "style_type[label;font_size=*0.9]"
for _, r in ipairs(rows) do
local p, desc, row_h = r.p, r.desc, r.h
local is_on = held[p.name] == true
local grantable = priviledges_manager.privs.can_grant(viewer, p.name)
local btn = "t_" .. p.name
-- Row background.
fs[#fs + 1] = ("box[0.1,%f;%f,%f;#ffffff10]"):format(
r.y, CONTENT_W - 0.5, row_h)
-- Privilege name.
fs[#fs + 1] = ("label[0.35,%f;%s]"):format(r.y + 0.27, esc(p.name))
-- Full description, wrapped across as many lines as it needs.
for k, line in ipairs(r.dlines) do
fs[#fs + 1] = ("label[0.5,%f;%s]"):format(
r.y + 0.52 + (k - 1) * LINE_H, esc(line))
end
-- The toggle switch itself: a coloured button, vertically centred.
local colour = grantable and (is_on and THEME.on or THEME.off) or THEME.locked
local face = is_on and "ON" or "OFF"
if not grantable then
face = "\u{1F512}" -- padlock: cannot change this privilege
end
fs[#fs + 1] = ("style[%s;bgcolor=%s;textcolor=#ffffff]"):format(btn, colour)
fs[#fs + 1] = ("button[%f,%f;%f,%f;%s;%s]"):format(
btn_x, r.y + (row_h - btn_h) / 2, btn_w, btn_h, btn, face)
-- Full description on hover: an area tooltip over the text, plus the
-- same text on the toggle itself.
local tip = desc ~= "" and desc or p.name
if not grantable then
tip = tip .. "\nYou are not allowed to change this privilege."
end
fs[#fs + 1] = ("tooltip[%f,%f;%f,%f;%s]"):format(
0.1, r.y, text_right, row_h, esc(tip))
fs[#fs + 1] = ("tooltip[%s;%s]"):format(btn, esc(tip))
end
fs[#fs + 1] = "scroll_container_end[]"
fs[#fs + 1] = "style_type[label;font_size=*1.0]"
return table.concat(fs)
end
local function build(viewer)
local c = get_ctx(viewer)
local list_items, selidx, count = build_player_list(c)
local target_label
if c.target then
local online = minetest.get_player_by_name(c.target) ~= nil
local known = priviledges_manager.players.exists(c.target)
target_label = "Managing: " .. c.target ..
(online and " (online)" or known and " (offline)" or " (new account)")
else
target_label = "No player selected"
end
local fs = {
"formspec_version[4]",
("size[%f,%f]"):format(W, H),
("bgcolor[%s;true]"):format(THEME.bg),
-- Header.
("label[0.4,0.55;%s]"):format(esc("\u{1F511} Privileges Manager \u{2014} " ..
priviledges_manager.game_title)),
-- Left panel: search + player list.
("box[0.3,1.0;5.5,%f;%s]"):format(H - 1.3, THEME.panel),
"field_close_on_enter[query;false]",
("field[0.5,1.25;3.7,0.7;query;;%s]"):format(esc(c.query)),
"button[4.3,1.25;1.3,0.7;search;Search]",
("tooltip[query;%s]"):format(esc(
"Type part of a name and press Enter to filter the list.")),
("textlist[0.5,2.15;5.1,%f;playerlist;%s;%d;false]"):format(
H - 4.2, list_items, selidx),
("label[0.5,%f;%s]"):format(H - 1.85,
esc(count .. " player(s) \u{25CF} online \u{25CB} offline")),
-- Manage an arbitrary (possibly never-seen) account by exact name.
"field_close_on_enter[newname;true]",
("field[0.5,%f;3.7,0.7;newname;;]"):format(H - 1.5),
("button[4.3,%f;1.3,0.7;manage;Open]"):format(H - 1.5),
("tooltip[newname;%s]"):format(esc(
"Manage an offline player by exact name, then press Enter / Open.")),
-- Right panel: privilege toggles.
("box[6.0,1.0;%f,%f;%s]"):format(W - 6.3, H - 1.3, THEME.panel),
("label[6.2,1.55;%s]"):format(esc(target_label)),
build_priv_rows(c, viewer),
"button_exit[" .. ("%f,%f;2.0,0.7;close;Close]"):format(W - 2.3, H - 0.95),
}
return table.concat(fs)
end
function priviledges_manager.show(viewer, target)
if target and target ~= "" then
local c = get_ctx(viewer)
c.target = target
priviledges_manager.players.remember(target)
end
minetest.show_formspec(viewer, FORMNAME, build(viewer))
end
minetest.register_on_player_receive_fields(function(player, formname, fields)
if formname ~= FORMNAME then
return
end
local viewer = player:get_player_name()
-- Security: never trust the client. Re-check permission on every action.
if not priviledges_manager.privs.can_use(viewer) then
minetest.chat_send_player(viewer, "You may no longer use the Privileges Manager.")
ctx[viewer] = nil
return true
end
local c = get_ctx(viewer)
if fields.quit or fields.close then
ctx[viewer] = nil
return true
end
local rerender = false
-- Remember the scroll position so toggling a switch doesn't jump to top.
if fields.privscroll then
local ev = minetest.explode_scrollbar_event(fields.privscroll)
if ev.type == "CHG" then
c.scroll = ev.value
end
end
-- Live search (fires on Enter in the query field or the Search button).
if fields.query ~= nil and fields.query ~= c.query then
c.query = fields.query
rerender = true
end
if fields.search then
rerender = true
end
-- Player picked from the list.
if fields.playerlist then
local ev = minetest.explode_textlist_event(fields.playerlist)
if ev.type == "CHG" or ev.type == "DCL" then
local item = c.filtered[ev.index]
if item then
c.target = item.name
rerender = true
end
end
end
-- Manage an arbitrary account by exact name.
if (fields.manage or fields.key_enter_field == "newname")
and fields.newname and fields.newname:trim() ~= "" then
c.target = fields.newname:trim()
priviledges_manager.players.remember(c.target)
rerender = true
end
-- A privilege toggle was pressed.
for key in pairs(fields) do
local priv = key:match("^t_(.+)$")
if priv and c.target and minetest.registered_privileges[priv] then
if priviledges_manager.privs.can_grant(viewer, priv) then
local held = minetest.get_player_privs(c.target)
priviledges_manager.privs.set(c.target, priv, not held[priv], viewer)
else
minetest.chat_send_player(viewer,
"You are not allowed to change the '" .. priv .. "' privilege.")
end
rerender = true
break
end
end
if rerender then
priviledges_manager.show(viewer)
end
return true
end)
minetest.register_on_leaveplayer(function(player)
ctx[player:get_player_name()] = nil
end)
@@ -0,0 +1,67 @@
-- Entry points: chat command, plus optional inventory buttons for the two
-- common inventory frameworks (sfinv on Minetest Game, unified_inventory).
local function open_for(name, target)
if not priviledges_manager.privs.can_use(name) then
return false, "You need the 'privs' (or 'basic_privs') privilege to manage privileges."
end
priviledges_manager.show(name, target ~= "" and target or nil)
return true
end
minetest.register_chatcommand("privman", {
params = "[player]",
description = "Open the visual Privileges Manager (optionally for a player)",
-- No priv requirement here so basic_privs holders can open it too;
-- the real check happens in open_for and on every toggle.
func = function(name, param)
return open_for(name, param:trim())
end,
})
minetest.register_chatcommand("privileges_manager", {
params = "[player]",
description = "Alias for /privman",
func = function(name, param)
return open_for(name, param:trim())
end,
})
-- sfinv tab (Minetest Game and games that use sfinv).
if minetest.get_modpath("sfinv") and sfinv and sfinv.register_page then
sfinv.register_page("priviledges_manager:tab", {
title = "Privileges",
is_in_nav = function(self, player, context)
return priviledges_manager.privs.can_use(player:get_player_name())
end,
get = function(self, player, context)
return sfinv.make_formspec(player, context,
"button[2.5,3;5,1;privman_open;Open Privileges Manager]"
.. "label[0.5,1;Manage player privileges visually.]",
false)
end,
on_player_receive_fields = function(self, player, context, fields)
if fields.privman_open then
priviledges_manager.show(player:get_player_name())
end
end,
})
end
-- unified_inventory button (used by many games incl. some Mineclone setups).
if minetest.get_modpath("unified_inventory") and unified_inventory
and unified_inventory.register_button then
unified_inventory.register_button("privman", {
type = "image",
image = "ui_skins_button.png", -- generic; falls back gracefully if missing
tooltip = "Privileges Manager",
action = function(player)
if priviledges_manager.privs.can_use(player:get_player_name()) then
priviledges_manager.show(player:get_player_name())
end
end,
condition = function(player)
return priviledges_manager.privs.can_use(player:get_player_name())
end,
})
end
+98
View File
@@ -0,0 +1,98 @@
-- Player discovery: online players, plus every account the auth backend
-- knows about (so offline players can be managed too).
priviledges_manager.players = {}
local storage = minetest.get_mod_storage()
local STORE_KEY = "seen_players"
-- A fallback set of names we have personally seen join. This is only used
-- when the active auth handler does not support iterate() (most custom
-- auth handlers). The builtin handler does support it, covering everyone.
local seen = minetest.deserialize(storage:get_string(STORE_KEY)) or {}
local function persist()
storage:set_string(STORE_KEY, minetest.serialize(seen))
end
local function remember(name)
if name and name ~= "" and not seen[name] then
seen[name] = true
persist()
end
end
priviledges_manager.players.remember = remember
minetest.register_on_joinplayer(function(player)
remember(player:get_player_name())
end)
-- Returns a set { [name] = true } of every account we can discover.
local function all_known_names()
local names = {}
-- 1. Currently connected players.
for _, player in ipairs(minetest.get_connected_players()) do
names[player:get_player_name()] = true
end
-- 2. Offline accounts via the auth handler, if it supports iteration.
local handler = minetest.get_auth_handler and minetest.get_auth_handler()
if handler and handler.iterate then
-- iterate() returns the full pairs() triple (generator, state, control);
-- capture all three so the for-loop has its iterator state.
local ok, gen, state, control = pcall(handler.iterate)
if ok and type(gen) == "function" then
for name in gen, state, control do
names[name] = true
end
end
end
-- 3. Anyone we have remembered ourselves (fallback / extra safety).
for name in pairs(seen) do
names[name] = true
end
return names
end
-- Returns a sorted array of { name = string, online = bool } filtered by
-- a case-insensitive substring query. Online players are listed first.
function priviledges_manager.players.list(query)
query = (query or ""):lower()
local online = {}
for _, player in ipairs(minetest.get_connected_players()) do
online[player:get_player_name()] = true
end
local result = {}
for name in pairs(all_known_names()) do
if query == "" or name:lower():find(query, 1, true) then
result[#result + 1] = { name = name, online = online[name] == true }
end
end
table.sort(result, function(a, b)
if a.online ~= b.online then
return a.online -- online players bubble to the top
end
return a.name:lower() < b.name:lower()
end)
return result
end
-- True if the account exists in the auth database (used to warn when a
-- never-seen name is opened, which would create a fresh account on grant).
function priviledges_manager.players.exists(name)
local handler = minetest.get_auth_handler and minetest.get_auth_handler()
if handler and handler.get_auth then
local ok, auth = pcall(handler.get_auth, name)
if ok and auth then
return true
end
end
return seen[name] == true or minetest.get_player_by_name(name) ~= nil
end
+75
View File
@@ -0,0 +1,75 @@
-- Privilege model: which privileges exist, who may grant them, and the
-- safe online/offline grant + revoke operations.
priviledges_manager.privs = {}
-- Sorted array of { name = string, def = table } for every registered
-- privilege. Works on any game because privileges are an engine concept.
function priviledges_manager.privs.all()
local list = {}
for name, def in pairs(minetest.registered_privileges) do
list[#list + 1] = { name = name, def = def }
end
table.sort(list, function(a, b)
return a.name < b.name
end)
return list
end
-- The set of privileges a "basic_privs" holder is allowed to manage.
local function basic_set()
local set = {}
local conf = minetest.settings:get("basic_privs") or "interact,shout"
for _, priv in ipairs(conf:split(",")) do
set[priv:trim()] = true
end
return set
end
priviledges_manager.privs.basic_set = basic_set
-- May `granter` toggle privilege `priv`? Mirrors the engine's /grant rules:
-- * "privs" -> may manage any privilege
-- * "basic_privs" -> may manage only privileges in the basic_privs setting
function priviledges_manager.privs.can_grant(granter, priv)
local gp = minetest.get_player_privs(granter)
if gp.privs then
return true
end
if gp.basic_privs then
return basic_set()[priv] == true
end
return false
end
-- May `name` open the manager at all? (Has any granting capability.)
function priviledges_manager.privs.can_use(name)
local gp = minetest.get_player_privs(name)
return gp.privs == true or gp.basic_privs == true
end
-- Apply a single privilege change. `value` true = grant, false = revoke.
-- Works for offline players via the auth handler. set_player_privs runs the
-- registered on_grant / on_revoke callbacks itself, so we must not double-run.
function priviledges_manager.privs.set(target, priv, value, caller)
local privs = minetest.get_player_privs(target)
-- Revoking must remove the key entirely; the engine warns on a `false`
-- value passed through set_player_privs.
privs[priv] = value and true or nil
minetest.set_player_privs(target, privs)
-- Keep our own seen-list in sync so freshly created accounts show up.
priviledges_manager.players.remember(target)
-- Notify the affected player if they are online.
if minetest.get_player_by_name(target) then
local verb = value and "granted to" or "revoked from"
minetest.chat_send_player(target,
minetest.colorize("#f5d76e",
("[Privileges] '%s' was %s you by %s."):format(priv, verb, caller)))
end
minetest.log("action",
("[priviledges_manager] %s %s '%s' for %s"):format(
caller, value and "granted" or "revoked", priv, target))
end