Skip to content

Commit 2a5db09

Browse files
ChronoFinaleclaude
andcommitted
feat: colour opponents' mods by server mod policy
Receive the server's banned/approved mod policy (setModPolicy action) and colour each opponent mod in the lobby list: red (banned), green (approved), white (unknown). Unknown stays white — we never auto-flag what we can't classify. - matchmaking.lua: classify_mod / get_banned_mods match the policy case- and punctuation-insensitively, with version-prefix matching and a dash-boundary heuristic so a glued "id-version" wire string still resolves (e.g. "Saturn-0.2.2-E"). The approved list mirrors banned. - action_handlers.lua: setModPolicy handler sets MP.BANNED_MODS / MP.APPROVED_MODS. - lobby.lua: colour mod rows via classify_mod. - start_ready_button.lua: banned warning text uses light/white (readable on the lobby background). - core.lua: MP.APPROVED_MODS default fallback. Pairs with the server (BalatroMultiplayerAPI-Server), which sends the policy on lobby join, and the website (www), where staff manage the lists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d2f39d4 commit 2a5db09

5 files changed

Lines changed: 109 additions & 23 deletions

File tree

core.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ MP.BANNED_MODS = {
1111
["FantomsPreview"] = true,
1212
}
1313

14+
-- Approved (allowed) mods, shown green in the lobby mod list. Mods in neither
15+
-- list show white (unknown). Both lists are normally replaced by the server's
16+
-- setModPolicy message on connect; these are fallbacks for older/offline servers.
17+
MP.APPROVED_MODS = {
18+
["Lovely"] = true,
19+
["Steamodded"] = true,
20+
["Multiplayer"] = true,
21+
["Preview"] = true,
22+
}
23+
1424
MP.LOBBY = {
1525
connected = false,
1626
temp_code = "",

lib/matchmaking.lua

Lines changed: 85 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -245,33 +245,97 @@ function MP.UTILS.version_mismatches()
245245
return results
246246
end
247247

248+
-- Mod-policy keys are matched case- and punctuation-insensitively, so staff can
249+
-- key the banned/approved lists by display name (e.g. "Joker Display") and still
250+
-- match the wire-reported SMODS id (e.g. "JokerDisplay" or "jokerdisplay").
251+
function MP.UTILS.normalize_mod_name(name)
252+
if type(name) ~= "string" then return "" end
253+
return (name:lower():gsub("[^%w]", ""))
254+
end
255+
256+
-- Cache the normalized index per source table; keyed weakly by table identity so
257+
-- it auto-refreshes when the server replaces MP.BANNED_MODS / MP.APPROVED_MODS.
258+
local _norm_index_cache = setmetatable({}, { __mode = "k" })
259+
local function normalized_index(map)
260+
if type(map) ~= "table" then return {} end
261+
local cached = _norm_index_cache[map]
262+
if cached then return cached end
263+
local idx = {}
264+
for k, v in pairs(map) do
265+
idx[MP.UTILS.normalize_mod_name(k)] = v
266+
end
267+
_norm_index_cache[map] = idx
268+
return idx
269+
end
270+
271+
-- A rule version matches the wire version on exact string OR on the X.Y.Z
272+
-- (clean semver) prefix, so a rule like "1.0.0" still matches "1.0.0~BETA-1620a"
273+
-- or "1.0.0-DEV". version_prefix is defined earlier in this file.
274+
local function version_matches(rule_version, version)
275+
if version == rule_version then return true end
276+
local rp = MP.UTILS.version_prefix(rule_version)
277+
local vp = MP.UTILS.version_prefix(version)
278+
return rp ~= nil and rp == vp
279+
end
280+
281+
-- A rule is true (any version), an exact version string, or a list of versions.
282+
local function rule_matches(rule, version)
283+
if rule == nil then return false end
284+
if type(rule) == "boolean" then return rule end
285+
if type(rule) == "string" then return version_matches(rule, version) end
286+
if type(rule) == "table" then
287+
for _, v in ipairs(rule) do
288+
if version_matches(v, version) then return true end
289+
end
290+
end
291+
return false
292+
end
293+
294+
-- A version-like segment starts with a digit, 'v', or '~' (e.g. "0.2.2", "v1",
295+
-- "~BETA"). Used to guess where a mod id ends and its version begins.
296+
local function looks_like_version(seg)
297+
return seg ~= nil and seg:match("^[%dv~]") ~= nil
298+
end
299+
300+
-- Matches a parsed mod entry against a normalized index. A mod's version can
301+
-- contain dashes (e.g. Saturn "0.2.2-E-ALPHA"), and parse_modlist splits on the
302+
-- last dash, so mod_name can arrive with version fragments glued on
303+
-- ("Saturn-0.2.2-E"). Walk the dash-delimited prefixes and treat a prefix as the
304+
-- id only when the next segment looks like a version — so "Saturn|0.2.2" splits
305+
-- but a genuinely dashed id like "Saturn-Extras|1.0" stays intact. First (shortest)
306+
-- valid match wins. Heuristic, not airtight; special-case real collisions if any
307+
-- ever show up.
308+
local function index_match(idx, mod_name, mod_version)
309+
local segs = {}
310+
for seg in tostring(mod_name):gmatch("[^%-]+") do
311+
segs[#segs + 1] = seg
312+
end
313+
local candidate
314+
for i = 1, #segs do
315+
candidate = candidate and (candidate .. "-" .. segs[i]) or segs[i]
316+
if segs[i + 1] == nil or looks_like_version(segs[i + 1]) then
317+
if rule_matches(idx[MP.UTILS.normalize_mod_name(candidate)], mod_version) then return true end
318+
end
319+
end
320+
return false
321+
end
322+
323+
-- Returns "banned" | "approved" | "unknown". Banned takes precedence.
324+
function MP.UTILS.classify_mod(mod_name, mod_version)
325+
if index_match(normalized_index(MP.BANNED_MODS), mod_name, mod_version) then return "banned" end
326+
if index_match(normalized_index(MP.APPROVED_MODS), mod_name, mod_version) then return "approved" end
327+
return "unknown"
328+
end
329+
248330
function MP.UTILS.get_banned_mods(mods)
249331
local banned_mods = {}
250332
if not mods then return banned_mods end
251333

334+
local idx = normalized_index(MP.BANNED_MODS)
252335
for mod_name, mod_version in pairs(mods) do
253-
local ban_info = MP.BANNED_MODS[mod_name]
254-
local is_banned = false
255-
256-
if ban_info then
257-
if type(ban_info) == "boolean" then
258-
-- Old format: ban all versions
259-
is_banned = ban_info
260-
elseif type(ban_info) == "string" then
261-
-- New format: ban specific version
262-
is_banned = (mod_version == ban_info)
263-
elseif type(ban_info) == "table" then
264-
-- Table format: ban multiple specific versions
265-
for _, banned_version in ipairs(ban_info) do
266-
if mod_version == banned_version then
267-
is_banned = true
268-
break
269-
end
270-
end
271-
end
336+
if index_match(idx, mod_name, mod_version) then
337+
table.insert(banned_mods, mod_name)
272338
end
273-
274-
if is_banned then table.insert(banned_mods, mod_name) end
275339
end
276340

277341
return banned_mods

networking/action_handlers.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,6 +1319,15 @@ local last_game_seed = nil
13191319

13201320
local function noop() end
13211321

1322+
-- Server-authoritative mod policy. The server sends this on connect (and again
1323+
-- via the admin setModPolicy command), overriding the hardcoded fallbacks in
1324+
-- core.lua so staff can update bans/approvals without a mod release. If the
1325+
-- server never sends it (older server), the core.lua defaults remain.
1326+
local function action_set_mod_policy(p)
1327+
if type(p.banned) == "table" then MP.BANNED_MODS = p.banned end
1328+
if type(p.approved) == "table" then MP.APPROVED_MODS = p.approved end
1329+
end
1330+
13221331
local HANDLERS = {
13231332
connected = action_connected,
13241333
version = action_version,
@@ -1329,6 +1338,7 @@ local HANDLERS = {
13291338
enemyDisconnected = action_enemyDisconnected,
13301339
enemyReconnected = action_enemyReconnected,
13311340
lobbyInfo = action_lobbyInfo,
1341+
setModPolicy = action_set_mod_policy,
13321342
startGame = action_start_game,
13331343
startBlind = action_start_blind,
13341344
enemyInfo = action_enemy_info,

ui/lobby/lobby.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ function MP.UI.modlist_to_view(mods, text_colour)
236236

237237
local function add_mod_row(mod)
238238
local mod_name, mod_version = MP.UTILS.resolve_mod_name_and_version(mod.name, mod.version)
239-
local color = MP.BANNED_MODS[mod.name] and G.C.RED or text_colour
239+
-- red = banned, green = approved, default (white) = unknown
240+
local class = MP.UTILS.classify_mod(mod.name, mod_version)
241+
local color = (class == "banned" and G.C.RED) or (class == "approved" and G.C.GREEN) or text_colour
240242
table.insert(t, {
241243
n = G.UIT.R,
242244
config = {

ui/lobby/start_ready_button.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ local function get_warnings()
8181
if #host_banned_mods > 0 or #guest_banned_mods > 0 then
8282
table.insert(warnings, {
8383
localize("k_warning_banned_mods"),
84-
G.C.RED,
84+
G.C.UI.TEXT_LIGHT, -- white reads better than red on the lobby background
8585
0.4,
8686
})
8787
end

0 commit comments

Comments
 (0)