Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ MP.BANNED_MODS = {
["FantomsPreview"] = true,
}

-- Approved (allowed) mods, shown green in the lobby mod list. Mods in neither
-- list show white (unknown). Both lists are normally replaced by the server's
-- setModPolicy message on connect; these are fallbacks for older/offline servers.
MP.APPROVED_MODS = {
["Lovely"] = true,
["Steamodded"] = true,
["Multiplayer"] = true,
["Preview"] = true,
}

MP.LOBBY = {
connected = false,
temp_code = "",
Expand Down
106 changes: 85 additions & 21 deletions lib/matchmaking.lua
Original file line number Diff line number Diff line change
Expand Up @@ -245,33 +245,97 @@ function MP.UTILS.version_mismatches()
return results
end

-- Mod-policy keys are matched case- and punctuation-insensitively, so staff can
-- key the banned/approved lists by display name (e.g. "Joker Display") and still
-- match the wire-reported SMODS id (e.g. "JokerDisplay" or "jokerdisplay").
function MP.UTILS.normalize_mod_name(name)
if type(name) ~= "string" then return "" end
return (name:lower():gsub("[^%w]", ""))
end

-- Cache the normalized index per source table; keyed weakly by table identity so
-- it auto-refreshes when the server replaces MP.BANNED_MODS / MP.APPROVED_MODS.
local _norm_index_cache = setmetatable({}, { __mode = "k" })
local function normalized_index(map)
if type(map) ~= "table" then return {} end
local cached = _norm_index_cache[map]
if cached then return cached end
local idx = {}
for k, v in pairs(map) do
idx[MP.UTILS.normalize_mod_name(k)] = v
end
_norm_index_cache[map] = idx
return idx
end

-- A rule version matches the wire version on exact string OR on the X.Y.Z
-- (clean semver) prefix, so a rule like "1.0.0" still matches "1.0.0~BETA-1620a"
-- or "1.0.0-DEV". version_prefix is defined earlier in this file.
local function version_matches(rule_version, version)
if version == rule_version then return true end
local rp = MP.UTILS.version_prefix(rule_version)
local vp = MP.UTILS.version_prefix(version)
return rp ~= nil and rp == vp
end

-- A rule is true (any version), an exact version string, or a list of versions.
local function rule_matches(rule, version)
if rule == nil then return false end
if type(rule) == "boolean" then return rule end
if type(rule) == "string" then return version_matches(rule, version) end
if type(rule) == "table" then
for _, v in ipairs(rule) do
if version_matches(v, version) then return true end
end
end
return false
end

-- A version-like segment starts with a digit, 'v', or '~' (e.g. "0.2.2", "v1",
-- "~BETA"). Used to guess where a mod id ends and its version begins.
local function looks_like_version(seg)
return seg ~= nil and seg:match("^[%dv~]") ~= nil
end

-- Matches a parsed mod entry against a normalized index. A mod's version can
-- contain dashes (e.g. Saturn "0.2.2-E-ALPHA"), and parse_modlist splits on the
-- last dash, so mod_name can arrive with version fragments glued on
-- ("Saturn-0.2.2-E"). Walk the dash-delimited prefixes and treat a prefix as the
-- id only when the next segment looks like a version — so "Saturn|0.2.2" splits
-- but a genuinely dashed id like "Saturn-Extras|1.0" stays intact. First (shortest)
-- valid match wins. Heuristic, not airtight; special-case real collisions if any
-- ever show up.
local function index_match(idx, mod_name, mod_version)
local segs = {}
for seg in tostring(mod_name):gmatch("[^%-]+") do
segs[#segs + 1] = seg
end
local candidate
for i = 1, #segs do
candidate = candidate and (candidate .. "-" .. segs[i]) or segs[i]
if segs[i + 1] == nil or looks_like_version(segs[i + 1]) then
if rule_matches(idx[MP.UTILS.normalize_mod_name(candidate)], mod_version) then return true end
end
end
return false
end

-- Returns "banned" | "approved" | "unknown". Banned takes precedence.
function MP.UTILS.classify_mod(mod_name, mod_version)
if index_match(normalized_index(MP.BANNED_MODS), mod_name, mod_version) then return "banned" end
if index_match(normalized_index(MP.APPROVED_MODS), mod_name, mod_version) then return "approved" end
return "unknown"
end

function MP.UTILS.get_banned_mods(mods)
local banned_mods = {}
if not mods then return banned_mods end

local idx = normalized_index(MP.BANNED_MODS)
for mod_name, mod_version in pairs(mods) do
local ban_info = MP.BANNED_MODS[mod_name]
local is_banned = false

if ban_info then
if type(ban_info) == "boolean" then
-- Old format: ban all versions
is_banned = ban_info
elseif type(ban_info) == "string" then
-- New format: ban specific version
is_banned = (mod_version == ban_info)
elseif type(ban_info) == "table" then
-- Table format: ban multiple specific versions
for _, banned_version in ipairs(ban_info) do
if mod_version == banned_version then
is_banned = true
break
end
end
end
if index_match(idx, mod_name, mod_version) then
table.insert(banned_mods, mod_name)
end

if is_banned then table.insert(banned_mods, mod_name) end
end

return banned_mods
Expand Down
10 changes: 10 additions & 0 deletions networking/action_handlers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,15 @@ local last_game_seed = nil

local function noop() end

-- Server-authoritative mod policy. The server sends this on connect (and again
-- via the admin setModPolicy command), overriding the hardcoded fallbacks in
-- core.lua so staff can update bans/approvals without a mod release. If the
-- server never sends it (older server), the core.lua defaults remain.
local function action_set_mod_policy(p)
if type(p.banned) == "table" then MP.BANNED_MODS = p.banned end
if type(p.approved) == "table" then MP.APPROVED_MODS = p.approved end
end

local HANDLERS = {
connected = action_connected,
version = action_version,
Expand All @@ -1329,6 +1338,7 @@ local HANDLERS = {
enemyDisconnected = action_enemyDisconnected,
enemyReconnected = action_enemyReconnected,
lobbyInfo = action_lobbyInfo,
setModPolicy = action_set_mod_policy,
startGame = action_start_game,
startBlind = action_start_blind,
enemyInfo = action_enemy_info,
Expand Down
4 changes: 3 additions & 1 deletion ui/lobby/lobby.lua
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,9 @@ function MP.UI.modlist_to_view(mods, text_colour)

local function add_mod_row(mod)
local mod_name, mod_version = MP.UTILS.resolve_mod_name_and_version(mod.name, mod.version)
local color = MP.BANNED_MODS[mod.name] and G.C.RED or text_colour
-- red = banned, green = approved, default (white) = unknown
local class = MP.UTILS.classify_mod(mod.name, mod_version)
local color = (class == "banned" and G.C.RED) or (class == "approved" and G.C.GREEN) or text_colour
table.insert(t, {
n = G.UIT.R,
config = {
Expand Down
2 changes: 1 addition & 1 deletion ui/lobby/start_ready_button.lua
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ local function get_warnings()
if #host_banned_mods > 0 or #guest_banned_mods > 0 then
table.insert(warnings, {
localize("k_warning_banned_mods"),
G.C.RED,
G.C.UI.TEXT_LIGHT, -- white reads better than red on the lobby background
0.4,
})
end
Expand Down