rune
Version:
CLI to upload your games to Rune
249 lines (244 loc) • 9.84 kB
JavaScript
import { ESLint } from "eslint";
import runePlugin from "eslint-plugin-rune";
import { parse, valid } from "node-html-parser";
import path from "path";
import semver from "semver";
import { findShortestPathFileThatEndsWith } from "./getGameFiles.js";
export const MAX_PLAYERS = 6;
export const validationOptions = {
sdkUrlStartOldRune: "https://cdn.jsdelivr.net/npm/rune-games-sdk",
sdkUrlStartRune: "https://cdn.jsdelivr.net/npm/rune-sdk",
sdkUrlStartDusk: "https://cdn.jsdelivr.net/npm/dusk-games-sdk",
sdkVersionRegex: /(?:rune|rune-games|dusk-games)-sdk@(\d+(\.\d+(\.\d+)?)?)/,
minSdkVersion: "4.8.1",
maxFiles: 1000,
maxSizeMb: 25,
};
const eslint = new ESLint({
overrideConfigFile: true,
allowInlineConfig: false,
baseConfig: [
{
languageOptions: {
sourceType: "module",
},
},
//@ts-expect-error - For some reason types not working here
runePlugin.configs.logic,
],
});
export function parseGameIndexHtml(indexHtmlContent) {
if (!valid(indexHtmlContent))
return null;
const { sdkUrlStartRune, sdkUrlStartOldRune, sdkUrlStartDusk } = validationOptions;
const parsedIndexHtml = parse(indexHtmlContent);
const scripts = parsedIndexHtml.getElementsByTagName("script");
const sdkScript = scripts.find((script) => script.getAttribute("src")?.startsWith(sdkUrlStartDusk) ||
script.getAttribute("src")?.startsWith(sdkUrlStartRune) ||
script.getAttribute("src")?.startsWith(sdkUrlStartOldRune));
return { parsedIndexHtml, scripts, sdkScript };
}
export async function validateGameFilesWithEval(logicRunner, files) {
const logicJs = findShortestPathFileThatEndsWith(files, "logic.js");
//Remove export { ... } from logic.js
const logicWithoutExports = logicJs?.content?.replace(/export {[^}]*}/gm, "");
const gameConfig = logicJs
? eval(`
//Prevent Math precision from being modified
globalThis.Math.__SDK_PRECISION_SET__ = true
${logicRunner}
${logicWithoutExports}
const bindings = globalThis.RUNE_FUNCTION_PREFIX_getLogicRunnerBindings()
if (!bindings) {
const invalidConfig = {}
invalidConfig
} else {
bindings.getConfig()
}
`)
: {};
return validateGameFiles(files, gameConfig);
}
export async function validateGameFiles(files, gameConfig) {
const { sdkVersionRegex, minSdkVersion, maxFiles, maxSizeMb } = validationOptions;
const errors = [];
let sdkName = "Rune";
if (files.length > maxFiles) {
errors.push({ message: `Too many files (>${maxFiles})` });
}
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
if (totalSize > maxSizeMb * 1e6) {
errors.push({ message: `Game size must be less than ${maxSizeMb}MB` });
}
const indexHtml = findShortestPathFileThatEndsWith(files, "index.html");
const logicJs = findShortestPathFileThatEndsWith(files, "logic.js");
if (logicJs && logicJs.size > 1e6) {
errors.push({ message: `logic.js size can't be more than 1MB` });
}
if (!indexHtml) {
errors.push({ message: "Game must include index.html" });
}
else if (!indexHtml.content) {
errors.push({
message: "index.html content has not been provided for validation",
});
}
else {
const gameIndexHtmlElements = parseGameIndexHtml(indexHtml.content);
if (!gameIndexHtmlElements) {
errors.push({ message: "index.html is not valid HTML" });
}
else if (!gameIndexHtmlElements.sdkScript) {
errors.push({
message: `Game index.html must include ${sdkName} SDK script`,
});
}
else {
const { sdkScript, scripts } = gameIndexHtmlElements;
const { sdkUrlStartDusk, sdkUrlStartRune, sdkUrlStartOldRune } = validationOptions;
sdkName = scripts.some((script) => script.getAttribute("src")?.startsWith(sdkUrlStartDusk))
? "Dusk"
: "Rune";
if (scripts.filter((script) => script.getAttribute("src")?.startsWith(sdkUrlStartRune) ||
script.getAttribute("src")?.startsWith(sdkUrlStartOldRune) ||
script.getAttribute("src")?.startsWith(sdkUrlStartDusk)).length > 1) {
errors.push({
message: `${sdkName} SDK is imported 2+ times in index.html. If using the ${sdkName} Vite plugin, then remove your SDK import in index.html.`,
});
}
if (sdkScript.getAttribute("src")?.endsWith("/multiplayer.js") ||
sdkScript.getAttribute("src")?.endsWith("/multiplayer-dev.js")) {
await validateMultiplayer({
errors,
gameConfig,
logicJs,
indexHtml,
});
if (scripts.indexOf(sdkScript) !== 0) {
errors.push({
message: `${sdkName} SDK must be the first script in index.html`,
});
}
const sdkVersion = sdkScript
.getAttribute("src")
?.match(sdkVersionRegex)?.[1];
if (!sdkVersion) {
errors.push({ message: `${sdkName} SDK must specify a version` });
}
const [major, minor, patch] = (sdkVersion ?? "").split(".");
const maxedOutSdkVersion = `${major}.${minor ?? 999}.${patch ?? 999}`;
const sdkVersionCoerced = semver.coerce(sdkVersion && maxedOutSdkVersion);
const minSdkVersionCoerced = semver.coerce(minSdkVersion);
if (sdkVersionCoerced &&
minSdkVersionCoerced &&
semver.lt(sdkVersionCoerced, minSdkVersionCoerced)) {
errors.push({
message: `${sdkName} SDK is below minimum version (included ${sdkVersion}, min ${minSdkVersion})`,
});
}
}
else {
errors.push({
message: `${sdkName} SDK script url must end with /multiplayer.js or /multiplayer-dev.js`,
});
}
}
}
return {
valid: errors.length === 0,
errors,
sdk: sdkName,
};
}
async function validateMultiplayer({ errors, gameConfig, logicJs, indexHtml, }) {
if (!logicJs) {
errors.push({
message: "logic.js must be included in the game files",
});
}
else {
if (path.dirname(indexHtml.path) !== path.dirname(logicJs.path)) {
errors.push({
message: "logic.js must be in the same directory as index.html",
});
}
return validateMultiplayerLogicJsContent({
logicJs,
gameConfig,
errors,
});
}
}
async function validateMultiplayerLogicJsContent({ logicJs, gameConfig, errors, }) {
if (!logicJs.content) {
errors.push({
message: "logic.js content has not been provided for validation",
});
}
else {
await eslint
.lintText(logicJs.content)
.then((results) => {
const result = results.at(0);
if (result) {
const lintErrors = result.messages.filter((err) => err.severity === 2);
if (lintErrors.length > 0) {
errors.push({
message: "logic.js contains invalid code",
lintErrors,
});
}
}
else {
errors.push({ message: "failed to lint logic.js" });
}
})
.catch(() => {
errors.push({ message: "failed to lint logic.js" });
});
if (!gameConfig.minPlayers ||
typeof gameConfig.minPlayers !== "number" ||
isNaN(gameConfig.minPlayers)) {
errors.push({
message: "logic.js: minPlayers not found or is invalid",
});
}
else if (gameConfig.minPlayers &&
(gameConfig.minPlayers < 1 || gameConfig.minPlayers > MAX_PLAYERS)) {
errors.push({
message: `logic.js: minPlayers must be between 1 and ${MAX_PLAYERS}`,
});
}
if (!gameConfig.maxPlayers ||
typeof gameConfig.maxPlayers !== "number" ||
isNaN(gameConfig.maxPlayers)) {
errors.push({
message: "logic.js: maxPlayers not found or is invalid",
});
}
else if (gameConfig.maxPlayers &&
(gameConfig.maxPlayers < 1 || gameConfig.maxPlayers > MAX_PLAYERS)) {
errors.push({
message: `logic.js: maxPlayers must be between 1 and ${MAX_PLAYERS}`,
});
}
if (gameConfig.maxPlayers &&
gameConfig.minPlayers &&
gameConfig.maxPlayers < gameConfig.minPlayers) {
errors.push({
message: "logic.js: maxPlayers must be greater than or equal to minPlayers",
});
}
if (gameConfig.updatesPerSecond === undefined) {
errors.push({
message: "logic.js: updatesPerSecond must be a constant (updatesPerSecond: 1-30)",
});
}
else if (gameConfig.updatesPerSecond < 1 ||
gameConfig.updatesPerSecond > 30) {
errors.push({
message: "logic.js: updatesPerSecond must be undefined or between 1 and 30",
});
}
}
}