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 + } +}