This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
'use strict';
|
||||
|
||||
const UINT32_MAX = 0xffffffff;
|
||||
const MAX_CUSTOM_PLAYERS = 64;
|
||||
|
||||
const DEFAULT_LOBBY_SETTINGS = Object.freeze({
|
||||
lobbyName: '',
|
||||
passKey: '',
|
||||
serverRegion: 3,
|
||||
customGameId: '',
|
||||
customGameMode: '',
|
||||
customMapName: '',
|
||||
customGameCrc: '',
|
||||
customGameTimestamp: '',
|
||||
customMinPlayers: 2,
|
||||
customMaxPlayers: 10,
|
||||
notifyAtPlayers: 8,
|
||||
leaveAtPlayers: 9,
|
||||
adminNames: [],
|
||||
allowSpectating: true,
|
||||
});
|
||||
|
||||
function asInteger(value, name, min, max) {
|
||||
const parsed = typeof value === 'number' ? value : Number(String(value).trim());
|
||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new Error(`${name} must be an integer between ${min} and ${max}.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function asOptionalIntegerString(value, name, max) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return '';
|
||||
if (!/^\d+$/.test(raw)) throw new Error(`${name} must contain digits only.`);
|
||||
const parsed = BigInt(raw);
|
||||
if (parsed < 0n || parsed > BigInt(max)) throw new Error(`${name} is outside its supported range.`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function asUint64String(value, name, { required = false } = {}) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) {
|
||||
if (required) throw new Error(`${name} is required.`);
|
||||
return '';
|
||||
}
|
||||
if (!/^\d+$/.test(raw)) throw new Error(`${name} must contain digits only.`);
|
||||
const parsed = BigInt(raw);
|
||||
if (parsed <= 0n || parsed > 0xffffffffffffffffn) {
|
||||
throw new Error(`${name} must be a non-zero unsigned 64-bit integer.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseAdminNames(value) {
|
||||
const values = Array.isArray(value) ? value : String(value ?? '').split(',');
|
||||
return [...new Set(values.map((entry) => String(entry).trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeIdentity(value) {
|
||||
return String(value ?? '').normalize('NFKC').trim().toLocaleLowerCase('en-US');
|
||||
}
|
||||
|
||||
function validateLobbySettings(input = {}) {
|
||||
const merged = { ...DEFAULT_LOBBY_SETTINGS, ...input };
|
||||
const lobbyName = String(merged.lobbyName ?? '').trim();
|
||||
const customGameMode = String(merged.customGameMode ?? '').trim();
|
||||
const customMapName = String(merged.customMapName ?? '').trim();
|
||||
const passKey = String(merged.passKey ?? '');
|
||||
|
||||
if (!lobbyName) throw new Error('Lobby name is required.');
|
||||
if (lobbyName.length > 100) throw new Error('Lobby name must be at most 100 characters.');
|
||||
if (passKey.length > 64) throw new Error('Lobby password must be at most 64 characters.');
|
||||
if (!customGameMode) {
|
||||
throw new Error('Internal addon name is required for a public custom-game lobby. Create the form from a discovered lobby template.');
|
||||
}
|
||||
if (!customMapName) throw new Error('Custom map name is required.');
|
||||
|
||||
const customMinPlayers = asInteger(merged.customMinPlayers, 'Minimum players', 1, MAX_CUSTOM_PLAYERS);
|
||||
const customMaxPlayers = asInteger(merged.customMaxPlayers, 'Maximum players', 2, MAX_CUSTOM_PLAYERS);
|
||||
const notifyAtPlayers = asInteger(merged.notifyAtPlayers, 'Notification threshold', 1, MAX_CUSTOM_PLAYERS);
|
||||
const leaveAtPlayers = asInteger(merged.leaveAtPlayers, 'Auto-leave threshold', 1, MAX_CUSTOM_PLAYERS);
|
||||
|
||||
if (customMinPlayers > customMaxPlayers) {
|
||||
throw new Error('Minimum players cannot exceed maximum players.');
|
||||
}
|
||||
if (notifyAtPlayers > leaveAtPlayers) {
|
||||
throw new Error('Notification threshold must not exceed the auto-leave threshold.');
|
||||
}
|
||||
if (leaveAtPlayers > customMaxPlayers) {
|
||||
throw new Error('Auto-leave threshold cannot exceed the custom-game maximum player count.');
|
||||
}
|
||||
if (leaveAtPlayers === customMaxPlayers) {
|
||||
throw new Error('Auto-leave threshold must be below the custom-game maximum while the bot occupies one lobby slot.');
|
||||
}
|
||||
|
||||
return {
|
||||
lobbyName,
|
||||
passKey,
|
||||
serverRegion: asInteger(merged.serverRegion, 'Server region', 0, UINT32_MAX),
|
||||
customGameId: asUint64String(merged.customGameId, 'Custom game workshop ID', { required: true }),
|
||||
customGameMode,
|
||||
customMapName,
|
||||
customGameCrc: asUint64String(merged.customGameCrc, 'Custom game CRC'),
|
||||
customGameTimestamp: asOptionalIntegerString(merged.customGameTimestamp, 'Custom game timestamp', UINT32_MAX),
|
||||
customMinPlayers,
|
||||
customMaxPlayers,
|
||||
notifyAtPlayers,
|
||||
leaveAtPlayers,
|
||||
adminNames: parseAdminNames(merged.adminNames),
|
||||
allowSpectating: merged.allowSpectating !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function toDotaLobbyDetails(settings) {
|
||||
const details = {
|
||||
gameName: settings.lobbyName,
|
||||
serverRegion: settings.serverRegion,
|
||||
gameMode: 15,
|
||||
allowCheats: false,
|
||||
fillWithBots: false,
|
||||
allowSpectating: settings.allowSpectating,
|
||||
passKey: settings.passKey,
|
||||
lan: false,
|
||||
customMapName: settings.customMapName,
|
||||
customDifficulty: 0,
|
||||
customGameId: settings.customGameId,
|
||||
customMinPlayers: settings.customMinPlayers,
|
||||
customMaxPlayers: settings.customMaxPlayers,
|
||||
visibility: 0,
|
||||
customGamePenalties: true,
|
||||
};
|
||||
|
||||
details.customGameMode = settings.customGameMode;
|
||||
if (settings.customGameCrc) details.customGameCrc = settings.customGameCrc;
|
||||
if (settings.customGameTimestamp) details.customGameTimestamp = Number(settings.customGameTimestamp);
|
||||
return details;
|
||||
}
|
||||
|
||||
function findAdminMember(settings, members) {
|
||||
const wanted = new Set(settings.adminNames.map(normalizeIdentity));
|
||||
if (!wanted.size) return null;
|
||||
return members.find((member) => wanted.has(normalizeIdentity(member.name))
|
||||
|| wanted.has(normalizeIdentity(member.steamId))) || null;
|
||||
}
|
||||
|
||||
function decideLobbySafety(settings, members, lobbyState) {
|
||||
if (lobbyState !== 0) return { action: 'unsafe-state', admin: null };
|
||||
if (members.length >= settings.leaveAtPlayers) return { action: 'player-limit', admin: null };
|
||||
const admin = findAdminMember(settings, members);
|
||||
if (admin) return { action: 'admin-joined', admin };
|
||||
return { action: 'monitor', admin: null };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_LOBBY_SETTINGS,
|
||||
MAX_CUSTOM_PLAYERS,
|
||||
decideLobbySafety,
|
||||
findAdminMember,
|
||||
normalizeIdentity,
|
||||
parseAdminNames,
|
||||
toDotaLobbyDetails,
|
||||
validateLobbySettings,
|
||||
};
|
||||
Reference in New Issue
Block a user