diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..07ec2bb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+# Base URL of the BalatroMP website. The server polls /api/mod-policy for
+# the banned/approved mod list, caches it in memory, and refreshes on an interval.
+# Defaults to production; override for local dev. mod_policy.json is the offline
+# fallback if the site is unreachable.
+# BALATROMP_BASE_URL=http://localhost:3000
+# MOD_POLICY_REFRESH_MS=300000 # poll interval (ms); min 5000
+# MOD_POLICY_TOKEN= # optional bearer token if the endpoint is gated
diff --git a/.gitignore b/.gitignore
index 2673c52..0160083 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,10 @@ dist
releases
.vs
logs
+
+# Local mod-policy fallback (generated locally; the website is the source of truth)
+/mod_policy.json
+
+# Local admin tooling + keypair (dev-only; never commit the private key)
+/admin-local/
+.github/admin_public.pem
diff --git a/src/actionHandlers.ts b/src/actionHandlers.ts
index 4fb7b3b..d1d8e2c 100644
--- a/src/actionHandlers.ts
+++ b/src/actionHandlers.ts
@@ -2,6 +2,7 @@ import type Client from "./Client.js";
import GameModes from "./GameMode.js";
import { InsaneInt } from "./InsaneInt.js";
import Lobby, { getEnemy } from "./Lobby.js";
+import { getModPolicy } from "./modPolicy.js";
import type {
ActionCreateLobby,
ActionEatPizza,
@@ -49,12 +50,21 @@ const usernameAction = (
client.setModHash(modHash);
};
+// Send the client the current banned/approved mod policy. Done when a client
+// enters a lobby — that's the point the list matters (you're about to inspect an
+// opponent). The server keeps its copy warm via a background poll of the website.
+const sendModPolicy = (client: Client) => {
+ const { banned, approved } = getModPolicy();
+ client.sendAction({ action: "setModPolicy", banned, approved });
+};
+
const createLobbyAction = (
{ gameMode }: ActionHandlerArgs,
client: Client,
) => {
/** Also sets the client lobby to this newly created one */
new Lobby(client, gameMode);
+ sendModPolicy(client);
};
const joinLobbyAction = (
@@ -70,6 +80,7 @@ const joinLobbyAction = (
return;
}
newLobby.join(client);
+ sendModPolicy(client);
};
const leaveLobbyAction = (client: Client) => {
@@ -99,7 +110,9 @@ const rejoinLobbyAction = (
action: "error",
message: "Could not rejoin lobby. Token invalid or slot expired.",
});
+ return;
}
+ sendModPolicy(client);
};
const lobbyInfoAction = (client: Client) => {
diff --git a/src/actions.ts b/src/actions.ts
index 061b6da..71b3fdb 100644
--- a/src/actions.ts
+++ b/src/actions.ts
@@ -16,6 +16,8 @@ export type ActionLobbyInfo = {
guestReady?: boolean
isHost: boolean
}
+export type ModMap = Record
+export type ActionSetModPolicy = { action: 'setModPolicy'; banned: ModMap; approved: ModMap }
export type ActionStopGame = { action: 'stopGame' }
export type ActionStartGame = {
action: 'startGame'
@@ -79,6 +81,7 @@ export type ActionServerToClient =
| ActionEnemyDisconnected
| ActionEnemyReconnected
| ActionLobbyInfo
+ | ActionSetModPolicy
| ActionStopGame
| ActionStartGame
| ActionStartBlind
diff --git a/src/main.ts b/src/main.ts
index 8a55432..c852540 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,6 +2,7 @@ import { Socket, createServer } from 'node:net'
import Client from './Client.js'
import { actionHandlers, disconnectFromLobbyAction } from './actionHandlers.js'
import { Lobbies } from './Lobby.js'
+import { setModPolicy, fetchRemotePolicy, MOD_POLICY_URL, MOD_POLICY_REFRESH_MS } from './modPolicy.js'
import type {
Action,
ActionClientToServer,
@@ -125,6 +126,8 @@ const server = createServer((socket) => {
const client = new Client(socket.address(), sendActionToSocket(socket), socket.end)
client.sendAction({ action: 'connected' })
client.sendAction({ action: 'version' })
+ // Mod policy is sent when the client joins/creates a lobby (see actionHandlers),
+ // not on connect — that's the point it's needed.
// Buffer for incomplete TCP messages
let dataBuffer = ''
@@ -575,6 +578,31 @@ const adminHandlers: Record v === undefined || (!!v && typeof v === 'object' && !Array.isArray(v))
+ if (!isMap(banned) || !isMap(approved)) {
+ socket.end(JSON.stringify({ success: false, error: 'banned/approved must be objects' }) + '\n')
+ return
+ }
+ const p = setModPolicy({ banned, approved })
+ // Push the new policy to everyone currently in a lobby (they already got it
+ // on connect). New connections pick it up automatically.
+ const result = sendToTargets(undefined, undefined, {
+ action: 'setModPolicy',
+ banned: p.banned,
+ approved: p.approved,
+ })
+ socket.end(
+ JSON.stringify({
+ success: true,
+ banned: Object.keys(p.banned).length,
+ approved: Object.keys(p.approved).length,
+ broadcastRecipients: result.recipients,
+ }) + '\n',
+ )
+ },
+
listLobbies(_parsed, socket) {
const lobbies: string[] = []
for (const [code, lobby] of Lobbies.entries()) {
@@ -623,3 +651,17 @@ const adminServer = createServer((socket) => {
adminServer.listen(ADMIN_PORT, '127.0.0.1', () => {
console.log(`Admin server listening on 127.0.0.1:${ADMIN_PORT}`)
})
+
+// Keep the in-memory mod policy warm by polling the BalatroMP website. This only
+// refreshes the server's cached copy — clients receive it when they join/create a
+// lobby (see actionHandlers), not on a timer. On fetch failure the current cache
+// (mod_policy.json fallback at boot) is kept.
+{
+ const refresh = async () => {
+ const next = await fetchRemotePolicy(MOD_POLICY_URL)
+ if (next) setModPolicy(next)
+ }
+ refresh() // pull immediately on boot
+ setInterval(refresh, MOD_POLICY_REFRESH_MS)
+ console.log(`Refreshing mod policy cache from ${MOD_POLICY_URL} every ${MOD_POLICY_REFRESH_MS}ms`)
+}
diff --git a/src/modPolicy.ts b/src/modPolicy.ts
new file mode 100644
index 0000000..e0d37c7
--- /dev/null
+++ b/src/modPolicy.ts
@@ -0,0 +1,114 @@
+// Server-authoritative mod policy: which mods are banned vs approved in ranked.
+//
+// The client receives this via the `setModPolicy` action on connect (and on live
+// admin updates) and colours each opponent mod red (banned) / green (approved) /
+// white (unknown). Server-driven so staff update it without a mod release.
+//
+// NOTE on category bans: the staff policy bans whole categories (mods that add
+// content, raise game speed above 4x, or preview the top card / "misprint tech").
+// The server cannot infer those categories from a mod id alone, so category
+// members must be curated into the `banned` list here by id. Unknown mods stay
+// white on purpose — we never auto-ban something we can't classify.
+//
+// Value semantics (mirror the client's MP.UTILS.get_banned_mods):
+// true -> matches every version
+// "1.2.3" -> matches only that version
+// ["1.0", "1.1"] -> matches this set of versions
+//
+// Keys are SMODS mod ids; the client compares them case- and
+// punctuation-insensitively, so display-name-derived keys still match.
+
+import { existsSync, readFileSync } from 'node:fs'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+export type ModRule = boolean | string | string[]
+export type ModMap = Record
+export type ModPolicy = { banned: ModMap; approved: ModMap }
+
+// Minimal baseline used only if mod_policy.json is missing/invalid. The full
+// curated lists live in mod_policy.json (generated from staff lists).
+const DEFAULT_POLICY: ModPolicy = {
+ banned: { Cryptid: true, Saturn: true, Talisman: true, Showman: true, Aura: true },
+ approved: { Lovely: true, Steamodded: true, Multiplayer: true, Preview: true },
+}
+
+const scriptDir = typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url))
+const POLICY_PATH = resolve(scriptDir, '..', 'mod_policy.json')
+
+function loadInitial(): ModPolicy {
+ if (existsSync(POLICY_PATH)) {
+ try {
+ const parsed = JSON.parse(readFileSync(POLICY_PATH, 'utf-8'))
+ const banned = parsed?.banned && typeof parsed.banned === 'object' ? parsed.banned : {}
+ const approved = parsed?.approved && typeof parsed.approved === 'object' ? parsed.approved : {}
+ console.log(
+ `Loaded mod policy from ${POLICY_PATH}: ${Object.keys(banned).length} banned, ${Object.keys(approved).length} approved`,
+ )
+ return { banned, approved }
+ } catch (e) {
+ console.warn(`Failed to parse mod_policy.json (${(e as Error).message}), using defaults`)
+ }
+ }
+ return { banned: { ...DEFAULT_POLICY.banned }, approved: { ...DEFAULT_POLICY.approved } }
+}
+
+let policy: ModPolicy = loadInitial()
+
+export const getModPolicy = (): ModPolicy => policy
+
+export const setModPolicy = (next: Partial): ModPolicy => {
+ policy = {
+ banned: next.banned && typeof next.banned === 'object' ? next.banned : policy.banned,
+ approved: next.approved && typeof next.approved === 'object' ? next.approved : policy.approved,
+ }
+ return policy
+}
+
+// Remote source of truth: the BalatroMP website. The server polls it and
+// refreshes the in-memory policy without a restart; mod_policy.json remains the
+// boot/offline fallback if the site is unreachable. Override BALATROMP_BASE_URL
+// to http://localhost:3000 for local dev; defaults to production.
+const BALATROMP_BASE_URL = (process.env.BALATROMP_BASE_URL || 'https://balatromp.com').replace(/\/+$/, '')
+export const MOD_POLICY_URL = `${BALATROMP_BASE_URL}/api/mod-policy`
+export const MOD_POLICY_REFRESH_MS = Math.max(5000, Number(process.env.MOD_POLICY_REFRESH_MS) || 300000)
+const MOD_POLICY_TOKEN = process.env.MOD_POLICY_TOKEN || null
+
+// One normalized record from the remote endpoint's `mods` array.
+type RemoteModRecord = {
+ modId: string
+ status: 'banned' | 'approved'
+ versions?: string[] | null
+}
+
+// Fetches the normalized record array { mods: [...] } from the remote endpoint and
+// builds the internal { banned, approved } lookup maps. Returns null on any failure
+// (network, bad status, malformed body) so the caller keeps the cache.
+export async function fetchRemotePolicy(url: string): Promise {
+ try {
+ const res = await fetch(url, {
+ headers: MOD_POLICY_TOKEN ? { Authorization: `Bearer ${MOD_POLICY_TOKEN}` } : {},
+ signal: AbortSignal.timeout(10000),
+ })
+ if (!res.ok) {
+ console.warn(`mod policy fetch ${url} -> HTTP ${res.status}`)
+ return null
+ }
+ const parsed = (await res.json()) as { mods?: RemoteModRecord[] }
+ if (!Array.isArray(parsed?.mods)) {
+ console.warn('mod policy fetch: response has no mods array')
+ return null
+ }
+ const banned: ModMap = {}
+ const approved: ModMap = {}
+ for (const m of parsed.mods) {
+ if (!m || typeof m.modId !== 'string') continue
+ const rule: ModRule = Array.isArray(m.versions) && m.versions.length > 0 ? m.versions : true
+ ;(m.status === 'banned' ? banned : approved)[m.modId] = rule
+ }
+ return { banned, approved }
+ } catch (e) {
+ console.warn(`mod policy fetch failed: ${(e as Error).message}`)
+ return null
+ }
+}