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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Base URL of the BalatroMP website. The server polls <base>/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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions src/actionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ActionCreateLobby>,
client: Client,
) => {
/** Also sets the client lobby to this newly created one */
new Lobby(client, gameMode);
sendModPolicy(client);
};

const joinLobbyAction = (
Expand All @@ -70,6 +80,7 @@ const joinLobbyAction = (
return;
}
newLobby.join(client);
sendModPolicy(client);
};

const leaveLobbyAction = (client: Client) => {
Expand Down Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type ActionLobbyInfo = {
guestReady?: boolean
isHost: boolean
}
export type ModMap = Record<string, boolean | string | string[]>
export type ActionSetModPolicy = { action: 'setModPolicy'; banned: ModMap; approved: ModMap }
export type ActionStopGame = { action: 'stopGame' }
export type ActionStartGame = {
action: 'startGame'
Expand Down Expand Up @@ -79,6 +81,7 @@ export type ActionServerToClient =
| ActionEnemyDisconnected
| ActionEnemyReconnected
| ActionLobbyInfo
| ActionSetModPolicy
| ActionStopGame
| ActionStartGame
| ActionStartBlind
Expand Down
42 changes: 42 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = ''
Expand Down Expand Up @@ -575,6 +578,31 @@ const adminHandlers: Record<string, (parsed: any, socket: import('net').Socket)
socket.end(JSON.stringify(result) + '\n')
},

setModPolicy(parsed, socket) {
const { banned, approved } = parsed
const isMap = (v: unknown) => 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()) {
Expand Down Expand Up @@ -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`)
}
114 changes: 114 additions & 0 deletions src/modPolicy.ts
Original file line number Diff line number Diff line change
@@ -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<string, ModRule>
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>): 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<ModPolicy | null> {
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
}
}