Add Nomifactory's build scripts

Modified slightly to use LICENSE rather than LICENSE.md
This commit is contained in:
Exa 2022-02-10 12:07:06 -08:00
parent ff5f5642a4
commit 7a329bc07b
44 changed files with 23312 additions and 0 deletions

17
buildtools/.eslintrc.js Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
plugins: ["@typescript-eslint"],
extends: ["plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
rules: {
quotes: [2, "double", { avoidEscape: true }],
semi: [2, "always"],
"eol-last": [2, "always"],
},
};

1
buildtools/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

View File

@ -0,0 +1,7 @@
module.exports = {
semi: true,
trailingComma: "all",
printWidth: 120,
useTabs: true,
alignObjectProperties: true
};

9
buildtools/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"typescript.tsdk": "node_modules\\typescript\\lib",
"eslint.workingDirectories": [
"./",
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

View File

@ -0,0 +1,8 @@
import buildConfig from "./util/buildConfig.default.json";
import fs from "fs";
if (fs.existsSync("./config.json")) {
Object.assign(buildConfig, JSON.parse(fs.readFileSync("./config.json").toString()));
}
export default buildConfig;

12
buildtools/config.json Normal file
View File

@ -0,0 +1,12 @@
{
"copyFromSharedServerGlobs": [
"overrides/**/*",
"!overrides/resources/**/*"
],
"copyFromSharedClientGlobs": [
"overrides/**/*",
"!overrides/resources/minecraft/textures/gui/title/background/*"
],
"nightlyHookAvatar": "https://github.com/NomifactoryDevs.png",
"nightlyHookName": "Nightly Builds"
}

13
buildtools/globals.ts Normal file
View File

@ -0,0 +1,13 @@
import buildConfig from "./buildConfig";
import upath from "upath";
import manifest from "./../manifest.json";
import { ModpackManifest } from "./types/modpackManifest";
export const sharedDestDirectory = upath.join(buildConfig.buildDestinationDirectory, "shared");
export const clientDestDirectory = upath.join(buildConfig.buildDestinationDirectory, "client");
export const mmcDestDirectory = upath.join(buildConfig.buildDestinationDirectory, "mmc");
export const serverDestDirectory = upath.join(buildConfig.buildDestinationDirectory, "server");
export const langDestDirectory = upath.join(buildConfig.buildDestinationDirectory, "lang");
export const tempDirectory = upath.join(buildConfig.buildDestinationDirectory, "temp");
export const modpackManifest = manifest as ModpackManifest;
export const overridesFolder = modpackManifest.overrides || "overrides";

38
buildtools/gulpfile.ts Normal file
View File

@ -0,0 +1,38 @@
import * as gulp from "gulp";
import pruneCacheTask from "./tasks/misc/pruneCache";
export const pruneCache = pruneCacheTask;
import sharedTasks from "./tasks/shared";
import clientTasks from "./tasks/client";
import serverTasks from "./tasks/server";
import langTasks from "./tasks/lang";
import mmcTasks from "./tasks/mmc";
export const buildClient = gulp.series(sharedTasks, clientTasks);
export const buildServer = gulp.series(sharedTasks, serverTasks);
export const buildLang = gulp.series(sharedTasks, langTasks);
export const buildAll = gulp.series(sharedTasks, gulp.series(clientTasks, serverTasks, langTasks));
export const buildMMC = gulp.series(sharedTasks, clientTasks, mmcTasks);
import checkTasks from "./tasks/checks";
export const check = gulp.series(checkTasks);
import * as zip from "./tasks/misc/zip";
export const zipClient = zip.zipClient;
export const zipServer = zip.zipServer;
export const zipLang = zip.zipLang;
export const zipAll = zip.zipAll;
export const zipMMC = zip.zipMMC;
import * as gha from "./tasks/misc/gha";
export const makeArtifactNames = gha.makeArtifactNames;
import deployCurseForgeTask from "./tasks/deploy/curseforge";
export const deployCurseForge = deployCurseForgeTask;
import deployReleasesTask from "./tasks/deploy/releases";
export const deployReleases = deployReleasesTask;
import fireNightlyWebhookTask from "./tasks/misc/webhook";
export const fireNightlyWebhook = fireNightlyWebhookTask;

20418
buildtools/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
buildtools/package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "nomifactory-buildtools",
"version": "1.2.2",
"description": "Nomifactory Server Builder.",
"main": "gulpfile.js",
"author": "NotMyWing",
"license": "LGPL-3.0",
"devDependencies": {
"@octokit/rest": "^18.3.5",
"@types/bluebird": "^3.5.33",
"@types/fancy-log": "^1.3.1",
"@types/gulp": "^4.0.8",
"@types/gulp-imagemin": "^7.0.2",
"@types/gulp-rename": "^2.0.0",
"@types/gulp-zip": "^4.0.1",
"@types/merge-stream": "^1.1.2",
"@types/mustache": "^4.1.1",
"@types/requestretry": "^1.12.7",
"@types/sha1": "^1.1.2",
"@types/unzipper": "^0.10.3",
"@typescript-eslint/eslint-plugin": "^4.18.0",
"@typescript-eslint/parser": "^4.18.0",
"bluebird": "^3.7.2",
"del": "^6.0.0",
"discord-webhook-node": "^1.1.8",
"eslint": "^7.22.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-prettier": "^3.3.1",
"fancy-log": "^1.3.3",
"gulp": "^4.0.2",
"gulp-imagemin": "^7.1.0",
"gulp-rename": "^2.0.0",
"gulp-zip": "^5.1.0",
"merge-stream": "^2.0.0",
"mustache": "^4.1.0",
"png-to-jpeg": "^1.0.1",
"prettier": "^2.2.1",
"request": "^2.88.2",
"requestretry": "^5.0.0",
"sanitize-filename": "^1.6.3",
"sha1": "^1.1.1",
"ts-node": "^9.1.1",
"typescript": "^4.2.3",
"unzipper": "^0.10.11",
"upath": "^2.0.1"
}
}

View File

@ -0,0 +1,25 @@
import gulp from "gulp";
import { checkEnvironmentalVariables } from "../../util/util";
const vars = [
"GITHUB_TOKEN",
"GITHUB_REPOSITORY",
"GITHUB_SHA",
"GITHUB_REF",
"CURSEFORGE_PROJECT_ID",
"CURSEFORGE_API_TOKEN",
];
/**
* Check required env. variables for vailidity and cancel
* the build if something is unset.
*/
async function checkEnv() {
checkEnvironmentalVariables(vars);
if (!/.+\/.+/.exec(process.env.GITHUB_REPOSITORY)) {
throw new Error("Malformed repository slug.");
}
}
export default gulp.series(checkEnv);

View File

@ -0,0 +1,181 @@
import gulp from "gulp";
import { clientDestDirectory, modpackManifest, overridesFolder, sharedDestDirectory } from "../../globals";
import fs from "fs";
import upath from "upath";
import buildConfig from "../../buildConfig";
import { fetchProjectsBulk } from "../../util/curseForgeAPI";
import log from "fancy-log";
import rename from "gulp-rename";
import imagemin from "gulp-imagemin";
import pngToJpeg from "png-to-jpeg";
import { MainMenuConfig } from "../../types/mainMenuConfig";
import del from "del";
async function clientCleanUp() {
return del(upath.join(clientDestDirectory, "*"), { force: true });
}
/**
* Checks and creates all necessary directories so we can build the client safely.
*/
async function createClientDirs() {
if (!fs.existsSync(clientDestDirectory)) {
return fs.promises.mkdir(clientDestDirectory, { recursive: true });
}
}
/**
* Exports the modpack manifest.
*/
async function exportModpackManifest() {
const manifestPath = upath.join(clientDestDirectory, "manifest.json");
// Filter client side files only and prune build-specific fields.
const newFiles = modpackManifest.files
.map((file) => {
if (file.sides) {
if (!file.sides.includes("client")) return;
const newFile = Object.assign({}, file);
delete newFile.sides;
return newFile;
}
return file;
})
.filter(Boolean);
return fs.promises.writeFile(
manifestPath,
JSON.stringify(
{
...modpackManifest,
files: newFiles,
},
null,
" ",
),
);
}
/**
* Copies the license file.
*/
async function copyClientLicense() {
return gulp.src("../LICENSE").pipe(gulp.dest(clientDestDirectory));
}
/**
* Copies the update notes file.
*/
function copyClientUpdateNotes() {
return gulp.src("../UPDATENOTES.md", { allowEmpty: true }).pipe(gulp.dest(clientDestDirectory));
}
/**
* Copies the changelog file.
*/
function copyClientChangelog() {
return gulp.src(upath.join(sharedDestDirectory, "CHANGELOG.md")).pipe(gulp.dest(clientDestDirectory));
}
/**
* Copies modpack overrides.
*/
function copyClientOverrides() {
return gulp
.src(buildConfig.copyFromSharedClientGlobs, { nodir: true, cwd: sharedDestDirectory, allowEmpty: true })
.pipe(gulp.symlink(upath.join(clientDestDirectory, "overrides")));
}
/**
* Fetches mod links and builds modlist.html.
*/
async function fetchModList() {
log("Fetching mod infos...");
// Fetch project/addon infos.
const modInfos = await fetchProjectsBulk(modpackManifest.files.map((mod) => mod.projectID));
log(`Fetched ${modInfos.length} mod infos`);
// Create modlist.html
const output = [
"<ul>\r\n",
...modInfos
// Sort mods by their project IDs.
.sort((a, b) => a.id - b.id)
// Create a <li> node for each mod.
.map((modInfo) => {
return `\t<li><a href="${modInfo.websiteUrl}">${modInfo.name || "Unknown"} (by ${modInfo.authors
.map((author) => author.name || "Someone")
.join(", ")})</a></li>\r\n`;
}),
"</ul>",
];
return fs.promises.writeFile(upath.join(clientDestDirectory, "modlist.html"), output.join(""));
}
const bgImageNamespace = "minecraft";
const bgImagePath = "textures/gui/title/background";
const mainMenuConfigPath = "config/CustomMainMenu/mainmenu.json";
/**
* Minifies (converts to jpeg) main menu files so they don't take up 60% of the pack size.
*/
async function compressMainMenuImages() {
const mainMenuImages = [];
const bgImagePathReal = upath.join("resources", bgImageNamespace, bgImagePath);
// Convert each slideshow image to 80% jpg.
await new Promise((resolve) => {
gulp
.src(upath.join(sharedDestDirectory, overridesFolder, bgImagePathReal, "**/*"))
.pipe(imagemin([pngToJpeg({ quality: 80 })]))
.pipe(
rename((f) => {
// xd
f.extname = ".jpg";
// Ping back the file name so we don't have to scan the folder again.
mainMenuImages.push(`${f.basename}${f.extname}`);
}),
)
.pipe(gulp.dest(upath.join(clientDestDirectory, overridesFolder, bgImagePathReal)))
.on("end", resolve);
});
if (mainMenuImages.length > 0) {
// Read the CustomMainMenu config and parse it.
const mainMenuConfig: MainMenuConfig = JSON.parse(
(await fs.promises.readFile(upath.join(clientDestDirectory, overridesFolder, mainMenuConfigPath))).toString(),
);
// Fill the config with image paths using the weird "namespace:path" scheme.
mainMenuConfig.other.background.slideshow.images = mainMenuImages.map(
(img) => bgImageNamespace + ":" + upath.join(bgImagePath, img),
);
// Write it back.
return fs.promises.writeFile(
upath.join(clientDestDirectory, overridesFolder, mainMenuConfigPath),
JSON.stringify(mainMenuConfig, null, " "),
);
}
}
export default gulp.series(
clientCleanUp,
createClientDirs,
copyClientOverrides,
exportModpackManifest,
copyClientLicense,
copyClientOverrides,
copyClientChangelog,
copyClientUpdateNotes,
fetchModList,
compressMainMenuImages,
);

View File

@ -0,0 +1,120 @@
import { modpackManifest, sharedDestDirectory } from "../../globals";
import request from "requestretry";
import fs from "fs";
import log from "fancy-log";
import upath from "upath";
import buildConfig from "../../buildConfig";
import { makeArtifactNameBody } from "../../util/util";
import sanitize from "sanitize-filename";
const CURSEFORGE_ENDPOINT = "https://minecraft.curseforge.com/";
const variablesToCheck = ["CURSEFORGE_API_TOKEN", "CURSEFORGE_PROJECT_ID", "GITHUB_TAG"];
/**
* Uploads build artifacts to CurseForge.
*/
async function deployCurseForge(): Promise<void> {
/**
* Obligatory variable check.
*/
variablesToCheck.forEach((vari) => {
if (!process.env[vari]) {
throw new Error(`Environmental variable ${vari} is unset.`);
}
});
const tag = process.env.GITHUB_TAG;
const flavorTitle = process.env.BUILD_FLAVOR_TITLE;
const displayName = [modpackManifest.name, tag.replace(/^v/, ""), flavorTitle].filter(Boolean).join(" - ");
const files = [
{
name: sanitize((makeArtifactNameBody(modpackManifest.name) + "-client.zip").toLowerCase()),
displayName: displayName,
},
{
name: sanitize((makeArtifactNameBody(modpackManifest.name) + "-server.zip").toLowerCase()),
displayName: `${displayName} Server`,
},
];
/**
* Obligatory file check.
*/
files.forEach((file) => {
const path = upath.join(buildConfig.buildDestinationDirectory, file.name);
if (!fs.existsSync(path)) {
throw new Error(`File ${path} doesn't exist!`);
}
});
// Since we've built everything beforehand, the changelog must be available in the shared directory.
const changelog = await (await fs.promises.readFile(upath.join(sharedDestDirectory, "CHANGELOG.md")))
.toString()
.replace(/\n/g, " \n")
.replace(/\n\*/g, "\n•");
const tokenHeaders = {
"X-Api-Token": process.env.CURSEFORGE_API_TOKEN,
};
// Fetch the list of Minecraft versions from CurseForge.
log("Fetching CurseForge version manifest...");
const versionsManifest =
(await request({
uri: CURSEFORGE_ENDPOINT + "api/game/versions",
headers: tokenHeaders,
method: "GET",
json: true,
fullResponse: false,
maxAttempts: 5,
})) || [];
const version = versionsManifest.find((m) => m.name == modpackManifest.minecraft.version);
if (!version) {
throw new Error(`Version ${modpackManifest.minecraft.version} not found on CurseForge.`);
}
let clientFileID: number | null;
// Upload artifacts.
for (const file of files) {
const options = {
uri: CURSEFORGE_ENDPOINT + `api/projects/${process.env.CURSEFORGE_PROJECT_ID}/upload-file`,
method: "POST",
headers: {
...tokenHeaders,
"Content-Type": "multipart/form-data",
},
formData: {
metadata: JSON.stringify({
changelog: changelog,
changelogType: "markdown",
releaseType: "release",
parentFileID: clientFileID,
gameVersions: clientFileID ? undefined : [version.id],
displayName: file.displayName,
}),
file: fs.createReadStream(upath.join(buildConfig.buildDestinationDirectory, file.name)),
},
json: true,
fullResponse: false,
};
log(`Uploading ${file.name} to CurseForge...` + (clientFileID ? `(child of ${clientFileID})` : ""));
const response = await request(options);
if (response && response.id) {
if (!clientFileID) {
clientFileID = response.id;
}
} else {
throw new Error(`Failed to upload ${file.name}: Invalid Response.`);
}
}
}
export default deployCurseForge;

View File

@ -0,0 +1,87 @@
import { modpackManifest, sharedDestDirectory } from "../../globals";
import fs from "fs";
import upath from "upath";
import buildConfig from "../../buildConfig";
import { makeArtifactNameBody } from "../../util/util";
import Bluebird from "bluebird";
import { Octokit } from "@octokit/rest";
import sanitize from "sanitize-filename";
const variablesToCheck = ["GITHUB_TAG", "GITHUB_TOKEN", "GITHUB_REPOSITORY"];
/**
* Uploads build artifacts to GitHub Releases.
*/
async function deployReleases(): Promise<void> {
/**
* Obligatory variable check.
*/
variablesToCheck.forEach((vari) => {
if (!process.env[vari]) {
throw new Error(`Environmental variable ${vari} is unset.`);
}
});
const body = makeArtifactNameBody(modpackManifest.name);
const files = ["client", "server", "lang"].map((file) => sanitize(`${body}-${file}.zip`.toLowerCase()));
/**
* Obligatory file check.
*/
files.forEach((file) => {
const path = upath.join(buildConfig.buildDestinationDirectory, file);
if (!fs.existsSync(path)) {
throw new Error(`File ${path} doesn't exist!`);
}
});
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
});
const parsedSlug = /(.+)\/(.+)/.exec(process.env.GITHUB_REPOSITORY);
if (!parsedSlug) {
throw new Error("No/malformed GitHub repository slug provided.");
}
const repo = {
owner: parsedSlug[1],
repo: parsedSlug[2],
};
const tag = process.env.GITHUB_TAG;
const flavorTitle = process.env.BUILD_FLAVOR_TITLE;
// Since we've built everything beforehand, the changelog must be available in the shared directory.
const changelog = await (await fs.promises.readFile(upath.join(sharedDestDirectory, "CHANGELOG.md"))).toString();
// Create a release.
const release = await octokit.repos.createRelease({
tag_name: tag || "latest-dev-preview",
prerelease: !tag,
name: [modpackManifest.name, tag.replace(/^v/, ""), flavorTitle].filter(Boolean).join(" - "),
body: changelog,
...repo,
});
// Upload artifacts.
await Bluebird.map(files, async (file) => {
await octokit.repos.uploadReleaseAsset({
name: file,
release_id: release.data.id,
...repo,
// Dumb workaround thanks to broken typings.
data: (await fs.promises.readFile(upath.join(buildConfig.buildDestinationDirectory, file))) as unknown as string,
});
});
await octokit.repos.updateRelease({
release_id: release.data.id,
draft: false,
...repo,
});
}
export default deployReleases;

View File

@ -0,0 +1,38 @@
import gulp from "gulp";
import rename from "gulp-rename";
import merge from "merge-stream";
import upath from "upath";
import buildConfig from "../../buildConfig";
import { langDestDirectory, overridesFolder, sharedDestDirectory } from "../../globals";
import fs from "fs";
/**
* Checks and creates all necessary directories so we can build the client safely.
*/
async function createLangDirs() {
if (!fs.existsSync(langDestDirectory)) {
await fs.promises.mkdir(langDestDirectory, { recursive: true });
}
}
async function copyLang() {
const resourcesPath = upath.join(sharedDestDirectory, overridesFolder, "resources");
const opts = { nodir: true, base: resourcesPath };
const streams = [
gulp.src(upath.join(resourcesPath, "pack.mcmeta"), opts),
gulp.src(upath.join(resourcesPath, "**/*.lang"), opts).pipe(
rename((f) => {
f.dirname = upath.join("assets", f.dirname);
}),
),
];
return await new Promise((resolve) => {
merge(...streams)
.pipe(gulp.dest(upath.join(buildConfig.buildDestinationDirectory, langDestDirectory)))
.on("end", resolve);
});
}
export default gulp.series(createLangDirs, copyLang);

View File

@ -0,0 +1,16 @@
import { modpackManifest } from "../../globals";
import { makeArtifactNameBody } from "../../util/util";
import sanitize from "sanitize-filename";
export async function makeArtifactNames(): Promise<void> {
const body = makeArtifactNameBody(modpackManifest.name);
const names = {
client: body + "-client",
server: body + "-server",
lang: body + "-lang",
};
Object.keys(names).forEach((name) => {
console.log(`::set-output name=${name}::${sanitize(names[name].toLowerCase())}`);
});
}

View File

@ -0,0 +1,133 @@
import Bluebird from "bluebird";
import { modpackManifest } from "../../globals";
import { downloadOrRetrieveFileDef, getVersionManifest, libraryToPath } from "../../util/util";
import unzip from "unzipper";
import { ForgeProfile } from "../../types/forgeProfile";
import log from "fancy-log";
import sha1 from "sha1";
import { fetchFileInfo } from "../../util/curseForgeAPI";
import { VersionManifest } from "../../types/versionManifest";
import fs from "fs";
import upath from "upath";
import buildConfig from "../../buildConfig";
const FORGE_VERSION_REG = /forge-(.+)/;
const FORGE_MAVEN = "https://files.minecraftforge.net/maven/";
/**
* Download the Forge jar.
*
* This is basically a carbon copy of the server Forge-fetching task,
* except we only download/fetch the Forge jar and enumerate the libraries it has.
*/
async function getForgeURLs() {
const minecraft = modpackManifest.minecraft;
/**
* Break down the Forge version defined in manifest.json.
*/
const parsedForgeEntry = FORGE_VERSION_REG.exec(
(minecraft.modLoaders.find((x) => x.id && x.id.indexOf("forge") != -1) || {}).id || "",
);
if (!parsedForgeEntry) {
throw new Error("Malformed Forge version in manifest.json.");
}
/**
* Transform Forge version into Maven library path.
*/
const forgeMavenLibrary = `net.minecraftforge:forge:${minecraft.version}-${parsedForgeEntry[1]}`;
const forgeInstallerPath = libraryToPath(forgeMavenLibrary) + "-installer.jar";
/**
* Fetch the Forge installer
*/
const forgeJar = await fs.promises.readFile(
(
await downloadOrRetrieveFileDef({
url: FORGE_MAVEN + forgeInstallerPath,
})
).cachePath,
);
/**
* Parse the profile manifest.
*/
let forgeProfile: ForgeProfile;
const files = (await unzip.Open.buffer(forgeJar))?.files;
if (!files) {
throw new Error("Malformed Forge installation jar.");
}
for (const file of files) {
// Look for the installation profile.
if (!forgeProfile && file.path == "version.json") {
forgeProfile = JSON.parse((await file.buffer()).toString());
}
}
if (!forgeProfile || !forgeProfile.libraries) {
throw new Error("Malformed Forge installation profile.");
}
/**
* Finally, fetch libraries.
*/
const libraries = forgeProfile.libraries.filter((x) => Boolean(x?.downloads?.artifact?.url));
return [FORGE_MAVEN + forgeInstallerPath, ...libraries.map((library) => library.downloads.artifact.url)];
}
/**
* Removes ALL files from the cache directory that haven't been accessed during this run.
*/
export default async function pruneCache(): Promise<void> {
const urls = [];
// Push Forge URLs.
urls.push(...(await getForgeURLs()).map((url) => url));
// Fetch file infos.
const fileInfos = await Bluebird.map(modpackManifest.files, (file) => fetchFileInfo(file.projectID, file.fileID));
urls.push(...fileInfos.map((fileInfo) => fileInfo.downloadUrl));
// Fetch the Minecraft server.
const versionManifest: VersionManifest = await getVersionManifest(modpackManifest.minecraft.version);
if (!versionManifest) {
throw new Error(`No manifest found for Minecraft ${versionManifest.id}`);
}
urls.push(versionManifest.downloads.server.url);
// Push external dependencies.
if (modpackManifest.externalDependencies) {
urls.push(...modpackManifest.externalDependencies.map((dep) => dep.url));
}
const cache = (await fs.promises.readdir(buildConfig.downloaderCacheDirectory)).filter((entity) =>
fs.statSync(upath.join(buildConfig.downloaderCacheDirectory, entity)).isFile(),
);
const shaMap: { [key: string]: boolean } = urls.reduce((map, url) => ((map[sha1(url)] = true), map), {});
let count = 0,
bytes = 0;
for (const sha of cache) {
if (!shaMap[sha]) {
const path = upath.join(buildConfig.downloaderCacheDirectory, sha);
const stat = fs.existsSync(path) ? await fs.promises.stat(path) : null;
if (stat && stat.isFile()) {
count += 1;
bytes += stat.size;
log(`Pruning ${sha}...`);
await fs.promises.unlink(path);
}
}
}
log(`Pruned ${count} files (${(bytes / 1024 / 1024).toFixed(3)} MiB)`);
}

View File

@ -0,0 +1,25 @@
import { MessageBuilder, Webhook } from "discord-webhook-node";
import buildConfig from "../../buildConfig";
import { checkEnvironmentalVariables } from "../../util/util";
export default async function fireNightlyWebhook(): Promise<void> {
checkEnvironmentalVariables(["DISCORD_WEBHOOK", "GITHUB_RUN_ID", "GITHUB_SHA"]);
const webhook = new Webhook(process.env.DISCORD_WEBHOOK);
if (buildConfig.nightlyHookName) {
webhook.setUsername(buildConfig.nightlyHookName);
}
if (buildConfig.nightlyHookAvatar) {
webhook.setAvatar(buildConfig.nightlyHookAvatar);
}
const link = `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
const embed = new MessageBuilder()
.setTitle(`New snapshot available! (**${process.env.GITHUB_SHA.substr(0, 7)}**)`)
.setDescription(`Click to [view on GitHub Actions](${link}).`);
return webhook.send(embed);
}

View File

@ -0,0 +1,46 @@
import {
clientDestDirectory,
langDestDirectory,
mmcDestDirectory,
modpackManifest,
serverDestDirectory,
} from "../../globals";
import upath from "upath";
import zip from "gulp-zip";
import gulp from "gulp";
import buildConfig from "../../buildConfig";
import { makeArtifactNameBody } from "../../util/util";
import sanitize from "sanitize-filename";
async function zipFolder(path: string, zipName: string = upath.basename(path) + ".zip"): Promise<void> {
return new Promise((resolve) => {
gulp
.src(upath.join(path, "**"), { nodir: true, base: path, dot: true })
.pipe(zip(zipName))
.pipe(gulp.dest(buildConfig.buildDestinationDirectory))
.on("end", resolve);
});
}
function makeZipper(src: string, artifactName: string) {
const zipFn = () => {
return zipFolder(
upath.join(src),
sanitize((makeArtifactNameBody(modpackManifest.name) + `-${artifactName}.zip`).toLowerCase()),
);
};
Object.defineProperty(zipFn, "name", {
value: `zip${artifactName}`,
configurable: true,
});
return zipFn;
}
export const zipServer = makeZipper(serverDestDirectory, "Server");
export const zipClient = makeZipper(clientDestDirectory, "Client");
export const zipLang = makeZipper(langDestDirectory, "Lang");
export const zipMMC = makeZipper(mmcDestDirectory, "MMC");
export const zipAll = gulp.series(zipServer, zipClient, zipLang);

View File

@ -0,0 +1,118 @@
import { clientDestDirectory, mmcDestDirectory, modpackManifest } from "../../globals";
import { fetchMods } from "../../util/curseForgeAPI";
import * as upath from "upath";
import { series, src, symlink } from "gulp";
import * as fs from "fs";
async function mmcCleanUp(cb) {
if (fs.existsSync(mmcDestDirectory)) {
await fs.promises.rm(mmcDestDirectory, { recursive: true });
}
cb();
}
/**
* Checks and creates all necessary directories so we can build the client safely.
*/
async function createMMCDirs(cb) {
if (!fs.existsSync(mmcDestDirectory)) {
await fs.promises.mkdir(mmcDestDirectory, { recursive: true });
}
cb();
}
/**
* Copies modpack overrides.
*/
function copyOverrides() {
return src(upath.join(clientDestDirectory, "**/*.*"), {
nodir: true,
resolveSymlinks: false,
}).pipe(symlink(upath.join(mmcDestDirectory)));
}
/**
* Copies modpack overrides.
*/
async function renameOverrides() {
await fs.promises.rename(upath.join(mmcDestDirectory, "overrides"), upath.join(mmcDestDirectory, ".minecraft"));
return fs.promises.rm(upath.join(mmcDestDirectory, "manifest.json"));
}
/**
* Downloads client mods according to manifest.json and checks hashes.
*/
async function fetchModJars() {
return fetchMods(
modpackManifest.files.filter((f) => !f.sides || f.sides.includes("client")),
upath.join(mmcDestDirectory, ".minecraft"),
);
}
async function createMMCConfig() {
const cfg = {
InstanceType: "OneSix",
iconKey: "default",
name: modpackManifest.name,
};
return fs.promises.writeFile(
upath.join(mmcDestDirectory, "instance.cfg"),
Object.keys(cfg)
.map((key) => {
return `${key}=${cfg[key]}`;
})
.join("\n"),
);
}
async function createMMCManifest() {
const manifest = {
components: [],
formatVersion: 1,
};
manifest.components.push({
cachedName: "Minecraft",
cachedVersion: modpackManifest.minecraft.version,
important: true,
uid: "net.minecraft",
version: modpackManifest.minecraft.version,
});
const forgeLoader = modpackManifest.minecraft.modLoaders
.map((x) => x.id.match(/forge-(.+)/))
.filter(Boolean)
.shift();
if (forgeLoader) {
const ver = forgeLoader[1];
manifest.components.push({
cachedName: "Forge",
cachedRequires: [
{
equals: `${modpackManifest.minecraft.version}`,
uid: "net.minecraft",
},
],
cachedVersion: ver,
uid: "net.minecraftforge",
version: ver,
});
}
return fs.promises.writeFile(upath.join(mmcDestDirectory, "mmc-pack.json"), JSON.stringify(manifest, null, "\t"));
}
export default series(
mmcCleanUp,
createMMCDirs,
copyOverrides,
renameOverrides,
createMMCConfig,
createMMCManifest,
fetchModJars,
);

View File

@ -0,0 +1,269 @@
import upath from "upath";
import unzip from "unzipper";
import through from "through2";
import mustache from "mustache";
import log from "fancy-log";
import gulp, { src, dest, symlink } from "gulp";
import fs from "fs";
import buildConfig from "../../buildConfig";
import Bluebird from "bluebird";
import { ForgeProfile } from "../../types/forgeProfile";
import { FileDef } from "../../types/fileDef";
import { downloadOrRetrieveFileDef, getVersionManifest, libraryToPath, relative } from "../../util/util";
import { modpackManifest, serverDestDirectory, sharedDestDirectory } from "../../globals";
import del from "del";
import { VersionManifest } from "../../types/versionManifest";
import { fetchMods } from "../../util/curseForgeAPI";
const FORGE_VERSION_REG = /forge-(.+)/;
const FORGE_MAVEN = "https://files.minecraftforge.net/maven/";
let g_forgeJar;
async function serverCleanUp() {
return del(upath.join(serverDestDirectory, "*"), { force: true });
}
/**
* Checks and creates all necessary directories so we can build the client safely.
*/
async function createServerDirs() {
if (!fs.existsSync(serverDestDirectory)) {
return fs.promises.mkdir(serverDestDirectory, { recursive: true });
}
}
/**
* Download the Forge jar.
*
* Extract, parse the profile data and download required libraries.
*/
async function downloadForge() {
const minecraft = modpackManifest.minecraft;
/**
* Break down the Forge version defined in manifest.json.
*/
const parsedForgeEntry = FORGE_VERSION_REG.exec(
(minecraft.modLoaders.find((x) => x.id && x.id.indexOf("forge") != -1) || {}).id || "",
);
if (!parsedForgeEntry) {
throw new Error("Malformed Forge version in manifest.json.");
}
/**
* Transform Forge version into Maven library path.
*/
const forgeMavenLibrary = `net.minecraftforge:forge:${minecraft.version}-${parsedForgeEntry[1]}`;
const forgeInstallerPath = libraryToPath(forgeMavenLibrary) + "-installer.jar";
const forgeUniversalPath = upath.join("maven", libraryToPath(forgeMavenLibrary) + ".jar");
/**
* Fetch the Forge installer
*/
const forgeJar = await fs.promises.readFile(
(
await downloadOrRetrieveFileDef({
url: FORGE_MAVEN + forgeInstallerPath,
})
).cachePath,
);
/**
* Parse the profile manifest.
*/
let forgeUniversalJar: Buffer, forgeProfile: ForgeProfile;
const files = (await unzip.Open.buffer(forgeJar))?.files;
log("Extracting Forge installation profile & jar...");
if (!files) {
throw new Error("Malformed Forge installation jar.");
}
for (const file of files) {
// Look for the universal jar.
if (!forgeUniversalJar && file.path == forgeUniversalPath) {
forgeUniversalJar = await file.buffer();
}
// Look for the installation profile.
else if (!forgeProfile && file.path == "version.json") {
forgeProfile = JSON.parse((await file.buffer()).toString());
}
if (forgeUniversalJar && forgeProfile) {
break;
}
}
if (!forgeProfile || !forgeProfile.libraries) {
throw new Error("Malformed Forge installation profile.");
}
if (!forgeUniversalJar) {
throw new Error("Couldn't find the universal Forge jar in the installation jar.");
}
/**
* Move the universal jar into the dist folder.
*/
log("Extracting the Forge jar...");
await fs.promises.writeFile(upath.join(serverDestDirectory, upath.basename(forgeUniversalPath)), forgeUniversalJar);
/**
* Save the universal jar file name for later.
*
* We will need it to process launchscripts.
*/
g_forgeJar = upath.basename(forgeUniversalPath);
/**
* Finally, fetch libraries.
*/
const libraries = forgeProfile.libraries.filter((x) => Boolean(x?.downloads?.artifact?.url));
log(`Fetching ${libraries.length} server libraries...`);
return Bluebird.map(
libraries,
async (library) => {
const libraryPath = library.downloads.artifact.path;
const def: FileDef = {
url: library.downloads.artifact.url,
};
if (library.downloads.artifact.sha1) {
def.hashes = [{ id: "sha1", hashes: [library.downloads.artifact.sha1] }];
}
const destPath = upath.join(serverDestDirectory, "libraries", libraryPath);
await fs.promises.mkdir(upath.dirname(destPath), { recursive: true });
await fs.promises.symlink(relative(destPath, (await downloadOrRetrieveFileDef(def)).cachePath), destPath);
},
{ concurrency: buildConfig.downloaderConcurrency },
);
}
/**
* Download the server jar.
*/
async function downloadMinecraftServer() {
log("Fetching the Minecraft version manifest...");
const versionManifest: VersionManifest = await getVersionManifest(modpackManifest.minecraft.version);
if (!versionManifest) {
throw new Error(`No manifest found for Minecraft ${versionManifest.id}`);
}
/**
* Fetch the server jar file.
*
* Pass SHA1 hash to compare against the downloaded file.
*/
const serverJar = await downloadOrRetrieveFileDef({
url: versionManifest.downloads.server.url,
hashes: [{ id: "sha1", hashes: versionManifest.downloads.server.sha1 }],
});
if (!(versionManifest.downloads && versionManifest.downloads.server)) {
throw new Error(`No server jar file found for ${versionManifest.id}`);
}
const dest = upath.join(serverDestDirectory, `minecraft_server.${versionManifest.id}.jar`);
await fs.promises.symlink(relative(dest, serverJar.cachePath), dest);
}
/**
* Downloads mods according to manifest.json and checks hashes.
*/
async function downloadMods() {
return fetchMods(
modpackManifest.files.filter((f) => !f.sides || f.sides.includes("server")),
serverDestDirectory,
);
}
/**
* Copies modpack overrides.
*/
function copyServerOverrides() {
return gulp
.src(buildConfig.copyFromSharedServerGlobs, { nodir: true, cwd: sharedDestDirectory, allowEmpty: true })
.pipe(symlink(upath.join(serverDestDirectory)));
}
/**
* Copies files from ./serverfiles into dest folder.
*/
function copyServerfiles() {
return src(["../serverfiles/**"]).pipe(dest(serverDestDirectory));
}
/**
* Copies the license file.
*/
function copyServerLicense() {
return src("../LICENSE").pipe(dest(serverDestDirectory));
}
/**
* Copies the update notes file.
*/
function copyServerUpdateNotes() {
return src("../UPDATENOTES.md", { allowEmpty: true }).pipe(dest(serverDestDirectory));
}
/**
* Copies the changelog file.
*/
function copyServerChangelog() {
return src(upath.join(sharedDestDirectory, "CHANGELOG.md")).pipe(dest(serverDestDirectory));
}
/**
* Copies files from ./launchscripts into dest folder and processes them using mustache.
*
* Replaces jvmArgs, minRAM, maxRAM and forgeJar.
*/
function processLaunchscripts() {
const rules = {
jvmArgs: buildConfig.launchscriptsJVMArgs,
minRAM: buildConfig.launchscriptsMinRAM,
maxRAM: buildConfig.launchscriptsMaxRAM,
forgeJar: "",
};
if (g_forgeJar) {
rules.forgeJar = g_forgeJar;
} else {
log.warn("No forgeJar specified!");
log.warn("Did downloadForge task fail?");
}
return src(["../launchscripts/**"])
.pipe(
through.obj((file, _, callback) => {
if (file.isBuffer()) {
const rendered = mustache.render(file.contents.toString(), rules);
file.contents = Buffer.from(rendered);
}
callback(null, file);
}),
)
.pipe(dest(serverDestDirectory));
}
export default gulp.series([
serverCleanUp,
createServerDirs,
downloadForge,
downloadMinecraftServer,
downloadMods,
copyServerOverrides,
copyServerfiles,
copyServerLicense,
copyServerChangelog,
copyServerUpdateNotes,
processLaunchscripts,
]);

View File

@ -0,0 +1,177 @@
import fs from "fs";
import gulp from "gulp";
import upath from "upath";
import buildConfig from "../../buildConfig";
import { modpackManifest, overridesFolder, sharedDestDirectory, tempDirectory } from "../../globals";
import del from "del";
import { FileDef } from "../../types/fileDef";
import Bluebird from "bluebird";
import {
compareAndExpandManifestDependencies,
downloadOrRetrieveFileDef,
getChangeLog,
getFileAtRevision,
getLastGitTag,
relative,
} from "../../util/util";
async function sharedCleanUp() {
await del(upath.join(sharedDestDirectory, "*"), { force: true });
await del(upath.join(tempDirectory, "*"), { force: true });
}
/**
* Checks and creates all necessary directories so we can build everything safely.
*/
async function createSharedDirs() {
if (!fs.existsSync(sharedDestDirectory)) {
await fs.promises.mkdir(sharedDestDirectory, { recursive: true });
}
if (!fs.existsSync(tempDirectory)) {
await fs.promises.mkdir(tempDirectory, { recursive: true });
}
}
/**
* Copies modpack overrides.
*/
async function copyOverrides() {
return new Promise((resolve) => {
gulp
.src(upath.join(buildConfig.buildSourceDirectory, overridesFolder, "**/*"))
.pipe(gulp.dest(upath.join(sharedDestDirectory, overridesFolder)))
.on("end", resolve);
});
}
/**
* Fetch external dependencies and remove the field from the manifest file.
*/
async function fetchExternalDependencies() {
const dependencies = modpackManifest.externalDependencies;
if (dependencies) {
const destDirectory = upath.join(sharedDestDirectory, overridesFolder, "mods");
if (!fs.existsSync(destDirectory)) {
await fs.promises.mkdir(destDirectory, { recursive: true });
}
// Map dependencies to FileDefs.
const depDefs: FileDef[] = dependencies.map((dep) => {
return {
url: dep.url,
hashes: [
{
hashes: [dep.sha],
id: "sha1",
},
],
};
});
delete modpackManifest.externalDependencies;
return Bluebird.map(
depDefs,
async (depDef) => {
const dest = upath.join(destDirectory, upath.basename(depDef.url));
const cachePath = (await downloadOrRetrieveFileDef(depDef)).cachePath;
const rel = relative(dest, cachePath);
await fs.promises.symlink(rel, dest);
},
{ concurrency: buildConfig.downloaderConcurrency },
);
}
}
/**
* Generates a changelog based on environmental variables.
*/
async function makeChangelog() {
let since = getLastGitTag(),
to: string;
// If this is a tagged build, fetch the tag before last.
if (process.env.GITHUB_TAG) {
since = getLastGitTag(process.env.GITHUB_TAG);
to = process.env.GITHUB_TAG;
}
// Back-compat in case this crap is still around.
else if (since == "latest-dev-preview") {
since = getLastGitTag(since);
}
const old = JSON.parse(getFileAtRevision("manifest.json", since)) as ModpackManifest;
const current = modpackManifest;
const commitList = getChangeLog(since, to, [upath.join("..", modpackManifest.overrides)]);
const builder: string[] = [];
// If the UPDATENOTES.md file is present, prepend it verbatim.
if (fs.existsSync("../UPDATENOTES.md")) {
builder.push((await fs.promises.readFile("../UPDATENOTES.md")).toString());
}
// Push the title.
builder.push(`# Changes since ${since}`);
const comparisonResult = await compareAndExpandManifestDependencies(old, current);
// Push mod update blocks.
[
{
name: "## New mods",
list: comparisonResult.added,
},
{
name: "## Updated mods",
list: comparisonResult.modified,
},
{
name: "## Removed mods",
list: comparisonResult.removed,
},
].forEach((block) => {
if (block.list.length == 0) {
return;
}
builder.push("");
builder.push(block.name);
builder.push(
...block.list
// Yeet invalid project names.
.filter((project) => !/project-\d*/.test(project))
.sort()
.map((name) => `* ${name}`),
);
});
// Push the changelog itself.
if (commitList) {
builder.push("");
builder.push("## Commits");
builder.push(commitList);
}
// Check if the builder only contains the title.
if (builder.length == 1) {
builder.push("");
builder.push("There haven't been any changes.");
}
return fs.promises.writeFile(upath.join(sharedDestDirectory, "CHANGELOG.md"), builder.join("\n"));
}
import transforms from "./transforms";
import { ModpackManifest } from "../../types/modpackManifest";
export default gulp.series(
sharedCleanUp,
createSharedDirs,
copyOverrides,
makeChangelog,
fetchExternalDependencies,
...transforms,
);

View File

@ -0,0 +1,5 @@
import quest from "./quest_i18n";
import scannable from "./scannable";
import version from "./version";
export default [quest, scannable, version];

View File

@ -0,0 +1,123 @@
import fs from "fs";
import upath from "upath";
import { overridesFolder, sharedDestDirectory } from "../../../globals";
import { Quest, QuestBook, QuestLines as QuestLine } from "../../../types/bqQuestBook";
const questLocation = "config/betterquesting/DefaultQuests.json";
const langFileLocation = "resources/questbook/lang";
function escapeString(string: string) {
return string.replace(/%/g, "%%").replace(/\n/g, "%n");
}
function transformKeyPairs(
database: { [key: string]: Quest } | { [key: string]: QuestLine },
namespace: string,
lines: string[],
) {
Object.keys(database).forEach((key) => {
const storeKey = key.replace(/:10/g, "");
const item = database[key];
const properties = item["properties:10"]["betterquesting:10"];
if (properties["name:8"] !== "Gap") {
const titleKey = `nomifactory.quest.${namespace}.${storeKey}.title`;
const descKey = `nomifactory.quest.${namespace}.${storeKey}.desc`;
// Push lang file lines.
lines.push(
`# ${namespace} ${storeKey}`,
`${titleKey}=${escapeString(properties["name:8"])}`,
`${descKey}=${escapeString(properties["desc:8"])}`,
"",
);
properties["name:8"] = titleKey;
properties["desc:8"] = descKey;
} else {
properties["name:8"] = "";
properties["desc:8"] = "";
}
});
}
/**
* Trimming all that results in almost half the size of the original JSON file.
*
* Interesting, huh?
*/
const uselessProps = {
"simultaneous:1": 0,
"ismain:1": 0,
"repeat_relative:1": 1,
"globalshare:1": 0,
"repeattime:3": -1,
"issilent:1": 0,
"snd_complete:8": "minecraft:entity.player.levelup",
"snd_update:8": "minecraft:entity.player.levelup",
"tasklogic:8": "AND",
"questlogic:8": "AND",
"visibility:8": "NORMAL",
"partysinglereward:1": 0,
"lockedprogress:1": 0,
"OreDict:8": "",
"Damage:2": 0,
"Count:3": 0,
"autoclaim:1": 0,
"autoConsume:1": 0,
"consume:1": 0,
"groupDetect:1": 0,
"ignoreNBT:1": 0,
"index:3": 0,
"partialMatch:1": 1,
};
function stripUselessMetadata(object: unknown) {
Object.keys(object).forEach((propName) => {
const prop = object[propName];
if (prop === uselessProps[propName]) {
return delete object[propName];
}
if (typeof prop === "object") {
if (Array.isArray(prop) && prop.length === 0) {
return delete object[propName];
}
stripUselessMetadata(prop);
if (Object.keys(prop).length === 0) {
return delete object[propName];
}
}
});
}
/**
* Extract lang entries from the quest book and transform the database.
*/
export default async function transformQuestBook(): Promise<void> {
const questDatabasePath = upath.join(sharedDestDirectory, overridesFolder, questLocation);
const questLangLocation = upath.join(sharedDestDirectory, overridesFolder, langFileLocation);
const questBook: QuestBook = JSON.parse((await fs.promises.readFile(questDatabasePath)).toString());
// Traverse through the quest book and rewrite titles/descriptions.
// Extract title/desc pairs into a lang file.
const lines: string[] = [];
// Quest lines.
transformKeyPairs(questBook["questLines:9"], "line", lines);
// Quests themselves.
transformKeyPairs(questBook["questDatabase:9"], "db", lines);
// Write lang file.
await fs.promises.mkdir(questLangLocation, { recursive: true });
await fs.promises.writeFile(upath.join(questLangLocation, "en_us.lang"), lines.join("\n"));
// Strip useless metadata.
stripUselessMetadata(questBook);
return fs.promises.writeFile(questDatabasePath, JSON.stringify(questBook));
}

View File

@ -0,0 +1,28 @@
import { overridesFolder, sharedDestDirectory } from "../../../globals";
import upath from "upath";
import fs from "fs";
const scannableConfigFile = "config/scannable.cfg";
/**
* Transform the scannable config.
* Trim excess newlines and remove comments.
*/
export default async function transformScannable(): Promise<void> {
const configPath = upath.join(sharedDestDirectory, overridesFolder, scannableConfigFile);
const contents = (await fs.promises.readFile(configPath))
.toString()
// Match arrays (S:array < ... >)
.replace(/([ \t]+)(S:\w+) <([^>]+)>/g, (_, g0, g1, g2) => {
const body = g2
.replace(/#[^\r\n]+/gm, "") // Comments
.replace(/^\s+$/gm, "") // Trailing whitespaces
.replace(/[\r\n]{2,}/gm, "\n"); // Extra newlines
return g0 + g1 + " <" + body + (body ? "" : "\n") + g0 + " >";
});
return fs.promises.writeFile(configPath, contents);
}

View File

@ -0,0 +1,49 @@
import fs from "fs";
import upath from "path";
import mustache from "mustache";
import { modpackManifest, overridesFolder, sharedDestDirectory } from "../../../globals";
const randomPatchesConfigFile = "config/randompatches.cfg";
/**
* Transform the version field of manifest.json.
*/
export default async function transformManifestVersion(): Promise<void> {
let versionTitle;
if (process.env.GITHUB_TAG) {
const flavorTitle = process.env.BUILD_FLAVOR_TITLE;
const tag = process.env.GITHUB_TAG.replace(/^v/, "");
versionTitle = [modpackManifest.name, tag, flavorTitle].filter(Boolean).join(" - ");
modpackManifest.version = tag;
}
// If SHA is provided and the build isn't tagged, append both the branch and short SHA.
else if (process.env.GITHUB_SHA && process.env.GITHUB_REF && process.env.GITHUB_REF.startsWith("refs/heads/")) {
const shortCommit = process.env.GITHUB_SHA.substr(0, 7);
const branch = /refs\/heads\/(.+)/.exec(process.env.GITHUB_REF)?.[1];
if (!branch) {
throw new Error(`Invalid git ref: ${process.env.GITHUB_REF}`);
}
versionTitle = `${modpackManifest.name} (${branch} branch, ${shortCommit})`;
modpackManifest.version = `${branch}-${shortCommit}`;
} else {
versionTitle = `${modpackManifest.name} (manual build)`;
modpackManifest.version = "manual-build";
}
modpackManifest.name = versionTitle;
const randomPatchesConfigFilePath = upath.join(sharedDestDirectory, overridesFolder, randomPatchesConfigFile);
const randomPatchesFile = (await fs.promises.readFile(randomPatchesConfigFilePath)).toString();
return fs.promises.writeFile(
randomPatchesConfigFilePath,
mustache.render(randomPatchesFile, {
title: versionTitle,
}),
);
}

10
buildtools/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"sourceMap": true,
"module": "commonjs",
"target": "es5",
"esModuleInterop": true,
"resolveJsonModule": true,
"downlevelIteration": true,
}
}

View File

@ -0,0 +1,118 @@
export interface QuestBook {
"format:8": string;
"questDatabase:9": { [key: string]: Quest };
"questLines:9": { [key: string]: QuestLines };
"questSettings:10": QuestSettings;
}
export interface Quest {
"preRequisites:11": number[];
"properties:10": QuestProperties;
"questID:3": number;
"rewards:9": Rewards9;
}
export interface QuestProperties {
"betterquesting:10": QuestPropertiesBQ;
}
export interface QuestPropertiesBQ {
"autoclaim:1": number;
"desc:8": string;
"globalshare:1": number;
"icon:10": Icon;
"ismain:1": number;
"issilent:1": number;
"lockedprogress:1": number;
"name:8": string;
"partysinglereward:1"?: number;
"questlogic:8": QuestLogic;
"repeat_relative:1": number;
"repeattime:3": number;
"simultaneous:1": number;
"snd_complete:8": string;
"snd_update:8": string;
"tasklogic:8": QuestLogic;
"visibility:8": QuestVisibility;
}
export interface Icon {
"Count:3": number;
"Damage:2": number;
"OreDict:8": string;
"id:8": string;
"tag:10"?: unknown;
}
export enum QuestLogic {
And = "AND",
Or = "OR",
}
export enum QuestVisibility {
Always = "ALWAYS",
Chain = "CHAIN",
Hidden = "HIDDEN",
Normal = "NORMAL",
Unlocked = "UNLOCKED",
}
export interface Rewards9 {
"0:10"?: Rewards9_010;
}
export interface Rewards9_010 {
"index:3": number;
"rewardID:8": RewardID8;
"rewards:9"?: { [key: string]: Icon };
"choices:9"?: { [key: string]: Icon };
}
export enum RewardID8 {
BqStandardChoice = "bq_standard:choice",
BqStandardItem = "bq_standard:item",
}
export enum TaskID8 {
BqStandardCheckbox = "bq_standard:checkbox",
BqStandardRetrieval = "bq_standard:retrieval",
}
export interface QuestLines {
"lineID:3": number;
"order:3": number;
"properties:10": QuestLines9_Properties10;
"quests:9": { [key: string]: { [key: string]: number } };
}
export interface QuestLines9_Properties10 {
"betterquesting:10": FluffyBetterquesting10;
}
export interface FluffyBetterquesting10 {
"bg_image:8": string;
"bg_size:3": number;
"desc:8": string;
"icon:10": Icon;
"name:8": string;
"visibility:8": QuestVisibility;
}
export interface QuestSettings {
"betterquesting:10": QuestSettings10_Betterquesting10;
}
export interface QuestSettings10_Betterquesting10 {
"editmode:1": number;
"hardcore:1": number;
"home_anchor_x:5": number;
"home_anchor_y:5": number;
"home_image:8": string;
"home_offset_x:3": number;
"home_offset_y:3": number;
"livesdef:3": number;
"livesmax:3": number;
"pack_name:8": string;
"pack_version:3": number;
"party_enable:1": number;
}

View File

@ -0,0 +1,160 @@
interface Author {
name: string;
url: string;
projectId: number;
id: number;
projectTitleId?: number;
projectTitleTitle: string;
userId: number;
twitchId?: number;
}
interface Attachment {
id: number;
projectId: number;
description: string;
isDefault: boolean;
thumbnailUrl: string;
title: string;
url: string;
status: number;
}
interface Dependency {
id: number;
addonId: number;
type: number;
fileId: number;
}
interface Module {
foldername: string;
type: number;
}
interface SortableGameVersion {
gameVersionPadded: string;
gameVersion: string;
gameVersionReleaseDate: Date;
gameVersionName: string;
}
export interface CurseForgeFileInfo {
id: number;
displayName: string;
fileName: string;
fileDate: Date;
fileLength: number;
releaseType: number;
fileStatus: number;
downloadUrl: string;
isAlternate: boolean;
alternateFileId: number;
dependencies: Dependency[];
isAvailable: boolean;
modules: Module[];
gameVersion: string[];
sortableGameVersion: SortableGameVersion[];
hasInstallScript: boolean;
isCompatibleWithClient: boolean;
categorySectionPackageType: number;
restrictProjectFileAccess: number;
projectStatus: number;
renderCacheId: number;
packageFingerprintId: number;
gameVersionDateReleased: Date;
gameVersionMappingId: number;
gameVersionId: number;
gameId: number;
isServerPack: boolean;
}
interface Category {
categoryId: number;
name: string;
url: string;
avatarUrl: string;
parentId: number;
rootId: number;
projectId: number;
avatarId: number;
gameId: number;
}
interface CategorySection {
id: number;
gameId: number;
name: string;
packageType: number;
path: string;
initialInclusionPattern: string;
gameCategoryId: number;
}
interface GameVersionLatestFile {
gameVersion: string;
projectFileId: number;
projectFileName: string;
fileType: number;
}
export interface CurseForgeModInfo {
id: number;
name: string;
authors: Author[];
attachments: Attachment[];
websiteUrl: string;
gameId: number;
summary: string;
defaultFileId: number;
downloadCount: number;
latestFiles: CurseForgeFileInfo[];
categories: Category[];
status: number;
primaryCategoryId: number;
categorySection: CategorySection;
slug: string;
gameVersionLatestFiles: GameVersionLatestFile[];
isFeatured: boolean;
popularityScore: number;
gamePopularityRank: number;
primaryLanguage: string;
gameSlug: string;
gameName: string;
portalName: string;
dateModified: Date;
dateCreated: Date;
dateReleased: Date;
isAvailable: boolean;
isExperiemental: boolean;
}
interface Dependency {
addonId: number;
type: number;
}
interface Module {
foldername: string;
fingerprint: unknown;
}
export interface CurseForgeFetchedFileInfo {
id: number;
displayName: string;
fileName: string;
fileDate: Date;
fileLength: number;
releaseType: number;
fileStatus: number;
downloadUrl: string;
isAlternate: boolean;
alternateFileId: number;
dependencies: Dependency[];
isAvailable: boolean;
modules: Module[];
packageFingerprint: number;
gameVersion: string[];
hasInstallScript: boolean;
gameVersionDateReleased: Date;
}

View File

@ -0,0 +1,6 @@
import { HashDef } from "./hashDef";
export type FileDef = {
url: string;
hashes?: HashDef[];
};

View File

@ -0,0 +1,27 @@
interface Artifact {
path: string;
url: string;
sha1: string;
size: number;
}
interface Downloads {
artifact: Artifact;
}
interface Library {
name: string;
downloads: Downloads;
}
export interface ForgeProfile {
_comment_: string[];
id: string;
time: Date;
releaseTime: Date;
type: string;
mainClass: string;
inheritsFrom: string;
minecraftArguments: string;
libraries: Library[];
}

View File

@ -0,0 +1,4 @@
export type HashDef = {
id: string;
hashes: unknown | Array<unknown>;
};

View File

@ -0,0 +1,19 @@
// Bare minimum.
interface Slideshow {
displayDuration: number;
fadeDuration: number;
images: string[];
}
interface Background {
image: string;
slideshow: Slideshow;
}
interface Other {
background: Background;
}
export interface MainMenuConfig {
other: Other;
}

View File

@ -0,0 +1,35 @@
interface ModLoader {
id: string;
primary: boolean;
}
interface Minecraft {
version: string;
modLoaders?: ModLoader[];
}
export interface ExternalDependency {
name: string;
url: string;
sha: string;
}
export interface ModpackManifestFile {
projectID: number;
fileID: number;
required: boolean;
sides?: ("client" | "server")[];
}
export interface ModpackManifest {
minecraft: Minecraft;
manifestType: string;
manifestVersion: number;
name: string;
version: string;
author: string;
projectID: number;
externalDependencies?: ExternalDependency[];
files: ModpackManifestFile[];
overrides: string;
}

View File

@ -0,0 +1,120 @@
interface AssetIndex {
id: string;
sha1: string;
size: number;
totalSize: number;
url: string;
}
interface Client {
sha1: string;
size: number;
url: string;
}
interface Server {
sha1: string;
size: number;
url: string;
}
interface Downloads {
client: Client;
server: Server;
}
interface Artifact {
path: string;
sha1: string;
size: number;
url: string;
}
interface NativesLinux {
path: string;
sha1: string;
size: number;
url: string;
}
interface NativesOsx {
path: string;
sha1: string;
size: number;
url: string;
}
interface NativesWindows {
path: string;
sha1: string;
size: number;
url: string;
}
interface Sources {
path: string;
sha1: string;
size: number;
url: string;
}
interface Javadoc {
path: string;
sha1: string;
size: number;
url: string;
}
interface Classifiers {
["natives-linux"]: NativesLinux;
["natives-osx"]: NativesOsx;
["natives-windows"]: NativesWindows;
sources: Sources;
javadoc: Javadoc;
}
interface Downloads2 {
artifact: Artifact;
classifiers: Classifiers;
}
interface Os {
name: string;
}
interface Rule {
action: string;
os: Os;
}
interface Extract {
exclude: string[];
}
interface Natives {
linux: string;
osx: string;
windows: string;
}
interface Library {
downloads: Downloads2;
name: string;
rules: Rule[];
extract: Extract;
natives: Natives;
}
export interface VersionManifest {
assetIndex: AssetIndex;
assets: string;
downloads: Downloads;
id: string;
libraries: Library[];
mainClass: string;
minecraftArguments: string;
minimumLauncherVersion: number;
releaseTime: Date;
time: Date;
type: string;
}

View File

@ -0,0 +1,17 @@
interface Latest {
release: string;
snapshot: string;
}
interface Version {
id: string;
type: string;
url: string;
time: Date;
releaseTime: Date;
}
export interface VersionsManifest {
latest: Latest;
versions: Version[];
}

View File

@ -0,0 +1,20 @@
{
"downloaderMaxRetries": 5,
"downloaderConcurrency": 50,
"downloaderCheckHashes": true,
"downloaderCacheDirectory": "../.cache",
"launchscriptsMinRAM": "2048M",
"launchscriptsMaxRAM": "2048M",
"launchscriptsJVMArgs": "",
"copyFromSharedServerGlobs": [
"overrides/**/*",
"!overrides/resources/**/*"
],
"copyFromSharedClientGlobs": [
"overrides/**/*"
],
"buildDestinationDirectory": "../build",
"buildSourceDirectory": "../",
"nightlyHookAvatar": "",
"nightlyHookName": ""
}

View File

@ -0,0 +1,157 @@
import bluebird from "bluebird";
import { CurseForgeFetchedFileInfo, CurseForgeModInfo as CurseForgeProject } from "../types/curseForge";
import log from "fancy-log";
import request from "requestretry";
import { ModpackManifestFile } from "../types/modpackManifest";
import Bluebird from "bluebird";
import buildConfig from "../buildConfig";
import upath from "upath";
import fs from "fs";
import { FileDef } from "../types/fileDef";
import { downloadOrRetrieveFileDef, relative, RetrievedFileDefReason } from "./util";
const curseForgeProjectCache: { [key: number]: CurseForgeProject } = {};
export async function fetchProject(toFetch: number): Promise<CurseForgeProject> {
if (curseForgeProjectCache[toFetch]) {
return curseForgeProjectCache[toFetch];
}
const project: CurseForgeProject = await request({
uri: `https://addons-ecs.forgesvc.net/api/v2/addon/${toFetch}`,
json: true,
fullResponse: false,
maxAttempts: 5,
});
if (project) {
curseForgeProjectCache[toFetch] = project;
}
return project;
}
const fetchedFileInfoCache: { [key: string]: CurseForgeFetchedFileInfo } = {};
export async function fetchFileInfo(projectID: number, fileID: number): Promise<CurseForgeFetchedFileInfo> {
const slug = `${projectID}/${fileID}`;
if (fetchedFileInfoCache[slug]) {
return fetchedFileInfoCache[slug];
}
const fileInfo: CurseForgeFetchedFileInfo = await request({
uri: `https://addons-ecs.forgesvc.net/api/v2/addon/${projectID}/file/${fileID}`,
json: true,
fullResponse: false,
});
if (fileInfo) {
fetchedFileInfoCache[slug] = fileInfo;
}
return fileInfo;
}
/**
* Fetches multiple CurseForge projects.
* Falls back to fetchModInfo in case it's impossible to bulk-fetch some projects.
*
* @param toFetch Project IDs to fetch.
* @returns CurseForge project infos.
*/
export async function fetchProjectsBulk(toFetch: number[]): Promise<CurseForgeProject[]> {
const modInfos: CurseForgeProject[] = [];
const unfetched: number[] = [];
// Determine projects that have been fetched already.
toFetch.forEach((id) => {
const cached = curseForgeProjectCache[id];
if (cached) {
modInfos.push(cached);
} else {
unfetched.push(id);
}
});
if (unfetched.length > 0) {
// Augment the array of known projects with new info.
const fetched: CurseForgeProject[] = await request.post({
uri: "https://addons-ecs.forgesvc.net/api/v2/addon/",
json: unfetched,
fullResponse: false,
maxAttempts: 5,
});
modInfos.push(...fetched);
// Cache fetched stuff.
fetched.forEach((mi) => {
curseForgeProjectCache[mi.id] = mi;
});
// In case we haven't received the proper amount of mod infos,
// try requesting them individually.
if (unfetched.length !== toFetch.length) {
const modInfoIDs = new Set(modInfos.map((mi) => mi.id));
const toFetchMissing = [...new Set(toFetch.filter((x) => !modInfoIDs.has(x)))];
log.warn(`Couldn't fetch next project IDs in bulk: ${toFetchMissing.join(", ")}`);
// Try fetching mods individually, in case they've been deleted.
let count = 0;
const missingModInfos: CurseForgeProject[] = await bluebird.map(toFetchMissing, async (id) => {
log.info(`Fetching project ID ${id} directly... (${++count} / ${toFetchMissing.length})`);
try {
// In case something fails to download; catch, rewrite, rethrow.
return await fetchProject(id);
} catch (err) {
err.message = `Couldn't fetch project ID ${id}. ${err.message || "Unknown error"}`;
throw err;
}
});
// The code above is expected to throw and terminate the further execution,
// so we can just do this.
modInfos.push(...missingModInfos);
}
}
return modInfos;
}
export async function fetchMods(toFetch: ModpackManifestFile[], destination: string): Promise<void[]> {
if (toFetch.length > 0) {
log(`Fetching ${toFetch.length} mods...`);
const modsPath = upath.join(destination, "mods");
await fs.promises.mkdir(modsPath, { recursive: true });
let fetched = 0;
return Bluebird.map(
toFetch,
async (file) => {
const fileInfo = await fetchFileInfo(file.projectID, file.fileID);
const fileDef: FileDef = {
url: fileInfo.downloadUrl,
hashes: [{ id: "murmurhash", hashes: fileInfo.packageFingerprint }],
};
const modFile = await downloadOrRetrieveFileDef(fileDef);
fetched += 1;
if (modFile.reason == RetrievedFileDefReason.Downloaded) {
log(`Downloaded ${upath.basename(fileInfo.downloadUrl)}... (${fetched} / ${toFetch.length})`);
} else if (modFile.reason == RetrievedFileDefReason.CacheHit) {
log(`Fetched ${upath.basename(fileInfo.downloadUrl)} from cache... (${fetched} / ${toFetch.length})`);
}
const dest = upath.join(destination, "mods", fileInfo.fileName);
await fs.promises.symlink(relative(dest, modFile.cachePath), dest);
},
{ concurrency: buildConfig.downloaderConcurrency },
);
} else {
log("No mods to fetch.");
}
}

110
buildtools/util/hashes.ts Normal file
View File

@ -0,0 +1,110 @@
/**
* Bytes to exclude from hashing.
*
* Why? I dunno.
*/
const MURMUR_SKIP_BYTES: { [key: number]: boolean } = {
9: true,
10: true,
13: true,
32: true,
};
import _sha1 from "sha1";
/**
* JS Implementation of MurmurHash2
*
* @author <a href="mailto:gary.court@gmail.com">Gary Court</a>
* @see http://github.com/garycourt/murmurhash-js
* @author <a href="mailto:aappleby@gmail.com">Austin Appleby</a>
* @see http://sites.google.com/site/murmurhash/
*/
function murmurhash2_32_gc(arr, seed, len = arr.length) {
let l = len,
h = seed ^ l,
i = 0,
k;
while (l >= 4) {
k = (arr[i] & 0xff) | ((arr[++i] & 0xff) << 8) | ((arr[++i] & 0xff) << 16) | ((arr[++i] & 0xff) << 24);
k = (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16);
k ^= k >>> 24;
k = (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16);
h = ((h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16)) ^ k;
l -= 4;
++i;
}
switch (l) {
case 3:
h ^= (arr[i + 2] & 0xff) << 16;
case 2:
h ^= (arr[i + 1] & 0xff) << 8;
case 1:
h ^= arr[i] & 0xff;
h = (h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16);
}
h ^= h >>> 13;
h = (h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16);
h ^= h >>> 15;
return h >>> 0;
}
/**
* Returns the hash sum of bytes of given bytes using MurmurHash v2.
*
* This is what Twitch is using to fingerprint mod files.
*/
export const murmurhash = (inputBuffer: Buffer, seed = 1): string => {
const output = new Uint8Array(inputBuffer.length);
let pos = 0;
for (let i = 0; i < inputBuffer.length; i++) {
const byte = inputBuffer.readUInt8(i);
if (!MURMUR_SKIP_BYTES[byte]) {
output[pos++] = byte;
}
}
return String(murmurhash2_32_gc(output, seed, pos));
};
import { HashDef } from "../types/hashDef";
/**
* Returns the hash sum of bytes of given bytes using SHA1.
*
* This is what Forge is using to check files.
*/
export const sha1 = (inputBuffer: Buffer): string => {
return _sha1(inputBuffer);
};
const hashFuncs: { [key: string]: (buffer: Buffer) => string } = {
murmurhash: murmurhash,
sha1: sha1,
};
/**
* Compare buffer to the given HashDef.
*
* @param {Buffer} buffer
* @param {HashDef} hashDef
*
* @throws {Error} Throws a generic error if hashes don't match.
*/
export const compareBufferToHashDef = (buffer: Buffer, hashDef: HashDef): boolean => {
if (!hashFuncs[hashDef.id]) {
throw new Error(`No hash function found for ${hashDef.id}.`);
}
const sum = hashFuncs[hashDef.id](buffer);
return (Array.isArray(hashDef.hashes) && hashDef.hashes.includes(sum)) || hashDef.hashes == sum;
};

365
buildtools/util/util.ts Normal file
View File

@ -0,0 +1,365 @@
import sha1 from "sha1";
import { FileDef } from "../types/fileDef";
import fs from "fs";
import buildConfig from "../buildConfig";
import upath from "upath";
import requestretry from "requestretry";
import http from "http";
import { compareBufferToHashDef } from "./hashes";
import { execSync } from "child_process";
import { ModpackManifest, ModpackManifestFile, ExternalDependency } from "../types/modpackManifest";
import { fetchProject, fetchProjectsBulk } from "./curseForgeAPI";
import Bluebird from "bluebird";
import { VersionManifest } from "../types/versionManifest";
import { VersionsManifest } from "../types/versionsManifest";
import request from "requestretry";
import log from "fancy-log";
const LIBRARY_REG = /^(.+?):(.+?):(.+?)$/;
/**
* Parses the library name into path following the standard package naming convention.
*
* Turns `package:name:version` into `package/name/version/name-version`.
*/
export const libraryToPath = (library: string): string => {
const parsedLibrary = LIBRARY_REG.exec(library);
if (parsedLibrary) {
const pkg = parsedLibrary[1].replace(/\./g, "/");
const name = parsedLibrary[2];
const version = parsedLibrary[3];
const newURL = `${pkg}/${name}/${version}/${name}-${version}`;
return newURL;
}
};
/**
* Checks if given environmental variables are set. Throws otherwise.
*/
export const checkEnvironmentalVariables = (vars: string[]): void => {
vars.forEach((vari) => {
if (!process.env[vari] || process.env[vari] == "") {
throw new Error(`Environmental variable ${vari} is unset.`);
}
});
};
export enum RetrievedFileDefReason {
Downloaded,
CacheHit,
}
export interface RetrievedFileDef {
reason: RetrievedFileDefReason;
cachePath: string;
}
/**
* Downloads/fetches files from the Interwebs.
*
* Internally hashes the URL of the provided FileDef and looks it up in the cache directory.
* In case of no cache hit, downloads the file and stores within the cache directory for later use.
*/
export async function downloadOrRetrieveFileDef(fileDef: FileDef): Promise<RetrievedFileDef> {
const fileNameSha = sha1(fileDef.url);
const cachedFilePath = upath.join(buildConfig.downloaderCacheDirectory, fileNameSha);
if (fs.existsSync(cachedFilePath)) {
const file = await fs.promises.readFile(cachedFilePath);
if (file.length !== 0) {
const rFileDef = {
reason: RetrievedFileDefReason.CacheHit,
cachePath: cachedFilePath,
};
// Check hashes.
if (fileDef.hashes) {
if (
fileDef.hashes.every((hashDef) => {
return compareBufferToHashDef(file, hashDef);
})
) {
return rFileDef;
}
} else {
return rFileDef;
}
}
}
if (!fs.existsSync(buildConfig.downloaderCacheDirectory)) {
await fs.promises.mkdir(buildConfig.downloaderCacheDirectory, { recursive: true });
}
let handle: fs.promises.FileHandle;
try {
handle = await fs.promises.open(cachedFilePath, "w");
let hashFailed = false;
const retryStrategy = (err: Error, response: http.IncomingMessage, body: unknown) => {
// Verify hashes.
if (!err && fileDef.hashes && body) {
const success = fileDef.hashes.every((hashDef) => {
return compareBufferToHashDef(body as Buffer, hashDef);
});
if (!success) {
if (hashFailed) {
throw new Error(`Couldn't verify checksums of ${upath.basename(fileDef.url)}`);
}
hashFailed = true;
return true;
}
}
return requestretry.RetryStrategies.HTTPOrNetworkError(err, response, body);
};
const data: Buffer = Buffer.from(
await requestretry({
url: fileDef.url,
fullResponse: false,
encoding: null,
retryStrategy: retryStrategy,
maxAttempts: 5,
}),
);
await handle.write(data);
await handle.close();
return {
reason: RetrievedFileDefReason.Downloaded,
cachePath: cachedFilePath,
};
} catch (err) {
if (handle && (await handle.stat()).isFile()) {
log(`Couldn't download ${upath.basename(fileDef.url)}, cleaning up ${fileNameSha}...`);
await handle.close();
await fs.promises.unlink(cachedFilePath);
}
throw err;
}
}
/**
* Returns artifact name body depending on environment variables.
* Mostly intended to be called by CI/CD.
*/
export function makeArtifactNameBody(baseName: string): string {
// If the tag is provided by CI, simply just glue it to the base name.
if (process.env.GITHUB_TAG) {
return `${baseName}-${process.env.GITHUB_TAG}`;
}
// If SHA is provided and the build isn't tagged, append both the branch and short SHA.
else if (process.env.GITHUB_SHA && process.env.GITHUB_REF && process.env.GITHUB_REF.startsWith("refs/heads/")) {
const shortCommit = process.env.GITHUB_SHA.substr(0, 7);
const branch = /refs\/heads\/(.+)/.exec(process.env.GITHUB_REF);
return `${baseName}-${branch[1]}-${shortCommit}`;
} else {
return baseName;
}
}
/**
* Fetches the last tag known to Git using the current branch.
* @param {string | nil} before Tag to get the tag before.
* @returns string Git tag.
* @throws
*/
export function getLastGitTag(before?: string): string {
if (before) {
before = `"${before}^"`;
}
return execSync(`git describe --abbrev=0 --tags ${before || ""}`)
.toString()
.trim();
}
/**
* Generates a changelog based on the two provided Git refs.
* @param {string} since Lower boundary Git ref.
* @param {string} to Upper boundary Git ref.
* @param {string[]} dirs Optional scopes.
*/
export function getChangeLog(since = "HEAD", to = "HEAD", dirs: string[] = undefined): string {
const command = [
"git log",
"--no-merges",
'--date="format:%d %b %Y"',
'--pretty="* %s - **%an** (%ad)"',
`${since}..${to}`,
];
if (dirs) {
command.push("--", dirs.join(" -- "));
}
return execSync(command.join(" ")).toString().trim();
}
/**
* Generates a changelog based on the two provided Git refs.
* @param {string} since Lower boundary Git ref.
* @param {string} to Upper boundary Git ref.
* @param {string[]} dirs Optional scopes.
*/
export function getFileAtRevision(path: string, revision = "HEAD"): string {
return execSync(`git show ${revision}:"${path}"`).toString().trim();
}
export interface ManifestFileListComparisonResult {
removed: string[];
modified: string[];
added: string[];
}
export async function compareAndExpandManifestDependencies(
oldFiles: ModpackManifest,
newFiles: ModpackManifest,
): Promise<ManifestFileListComparisonResult> {
// Map inputs for efficient joining.
const oldFileMap: { [key: number]: ModpackManifestFile } = oldFiles.files.reduce(
(map, file) => ((map[file.projectID] = file), map),
{},
);
const newFileMap: { [key: number]: ModpackManifestFile } = newFiles.files.reduce(
(map, file) => ((map[file.projectID] = file), map),
{},
);
const removed: string[] = [],
modified: string[] = [],
added: string[] = [];
// Create a distinct map of project IDs.
const projectIDs = Array.from(
new Set([...oldFiles.files.map((f) => f.projectID), ...newFiles.files.map((f) => f.projectID)]),
);
// Fetch projects in bulk and discard the result.
// Future calls to fetchProject() and fetchProjectsBulk() will hit the cache.
await fetchProjectsBulk(projectIDs);
await Bluebird.map(
projectIDs,
async (projectID) => {
const oldFileInfo = oldFileMap[projectID];
const newFileInfo = newFileMap[projectID];
// Doesn't exist in new, but exists in old. Removed. Left outer join.
if (!newFileInfo && oldFileInfo) {
removed.push((await fetchProject(oldFileInfo.projectID)).name);
}
// Doesn't exist in old, but exists in new. Added. Right outer join.
else if (newFileMap[projectID] && !oldFileMap[projectID]) {
added.push((await fetchProject(newFileInfo.projectID)).name);
}
// Exists in both. Modified? Inner join.
else if (oldFileInfo.fileID != newFileInfo.fileID) {
modified.push((await fetchProject(newFileInfo.projectID)).name);
}
},
{ concurrency: buildConfig.downloaderConcurrency },
);
// Compare external dependencies the same way.
const oldExternalMap: { [key: string]: ExternalDependency } = (oldFiles.externalDependencies || []).reduce(
(map, file) => ((map[file.name] = file), map),
{},
);
const newExternalMap: { [key: string]: ExternalDependency } = (newFiles.externalDependencies || []).reduce(
(map, file) => ((map[file.name] = file), map),
{},
);
const externalNames = Array.from(
new Set([
...(oldFiles.externalDependencies || []).map((dep) => dep.name),
...(newFiles.externalDependencies || []).map((dep) => dep.name),
]),
);
externalNames.forEach(async (name) => {
const oldDep = oldExternalMap[name];
const newDep = newExternalMap[name];
// Doesn't exist in new, but exists in old. Removed. Left outer join.
if (!newDep && oldDep) {
removed.push(oldDep.name);
}
// Doesn't exist in old, but exists in new. Added. Right outer join.
else if (newDep && !oldDep) {
added.push(newDep.name);
}
// Exists in both. Modified? Inner join.
else if (oldDep.url != newDep.url || oldDep.name != newDep.name) {
modified.push(newDep.name);
}
});
return {
removed: removed,
modified: modified,
added: added,
};
}
const LAUNCHERMETA_VERSION_MANIFEST = "https://launchermeta.mojang.com/mc/game/version_manifest.json";
/**
* Fetches the version manifest associated with the provided Minecraft version.
*
* @param minecraftVersion Minecraft version. (e. g., "1.12.2")
*/
export async function getVersionManifest(minecraftVersion: string): Promise<VersionManifest> {
/**
* Fetch the manifest file of all Minecraft versions.
*/
const manifest: VersionsManifest = await request({
uri: LAUNCHERMETA_VERSION_MANIFEST,
json: true,
fullResponse: false,
maxAttempts: 5,
});
const version = manifest.versions.find((x) => x.id == minecraftVersion);
if (!version) {
return null;
}
/**
* Fetch the version manifest file.
*/
const versionManifest: VersionManifest = await request({
uri: version.url,
json: true,
fullResponse: false,
maxAttempts: 5,
});
return versionManifest;
}
/**
* Returns a relative posix path from the first argument to the second.
*/
export function relative(from: string, to: string): string {
const broken = [from.split(upath.sep), to.split(upath.sep)];
while (broken.every((x) => x.length > 0) && broken[0][0] == broken[1][0]) {
broken.forEach((x) => x.shift());
}
if (broken.some((x) => x.length === 0)) {
throw new Error("Paths are not relative.");
}
return upath.join(...Array(broken[0].length - 1).fill(".."), ...broken[1]);
}

36
launchscripts/launch.bat Normal file
View File

@ -0,0 +1,36 @@
:: Server Launch Script
::
:: Thrown together by Neeve in under five minutes, Public Domain
:: https://github.com/Neeve01
::
:: Added java version check by t0suj4, Public Domain
:: https://github.com/t0su4
:: DO NOT EDIT UNLESS YOU KNOW WHAT YOU'RE DOING
@ECHO OFF
SET FORGEJAR={{forgeJar}}
SET JAVA_PARAMETERS={{jvmArgs}}
:: these you can edit
SET MIN_RAM={{minRAM}}
SET MAX_RAM={{maxRAM}}
:: DO NOT EDIT ANYTHING PAST THIS LINE
SET LAUNCHPARAMS=-server -Xms%MIN_RAM% -Xmx%MAX_RAM% %JAVA_PARAMETERS% -Dlog4j.configurationFile=log4j2_112-116.xml -jar %FORGEJAR% nogui
echo Checking java version...
echo.
java -version
echo.
echo The expected java version is 1.8. Not higher, not lower.
echo.
echo Launching the server...
echo.
echo ^> java %LAUNCHPARAMS%
echo.
java %LAUNCHPARAMS%
echo.
echo ^> The server has stopped. If it's a crash, please read the output above.
echo.
pause

38
launchscripts/launch.sh Normal file
View File

@ -0,0 +1,38 @@
#!/bin/sh
# Server Launch Script
#
# Thrown together by Neeve in under five minutes, Public Domain
# https://github.com/Neeve01
#
# Fixed and added java version check by t0suj4, Public Domain
# https://github.com/t0suj4
# DO NOT EDIT UNLESS YOU KNOW WHAT YOU'RE DOING
FORGEJAR='{{forgeJar}}'
JAVA_PARAMETERS='{{jvmArgs}}'
# these you can edit
MIN_RAM='{{minRAM}}'
MAX_RAM='{{maxRAM}}'
# DO NOT EDIT ANYTHING PAST THIS LINE
LAUNCHPARAMS="-server -Xms$MIN_RAM -Xmx$MAX_RAM $JAVA_PARAMETERS -Dlog4j.configurationFile=log4j2_112-116.xml -jar $FORGEJAR nogui"
echo "Checking java version..."
echo
java -version
echo
echo "The expected java version is 1.8. Not higher, not lower."
echo
echo "Launching the server..."
echo
echo "> java $LAUNCHPARAMS"
java $LAUNCHPARAMS
echo
echo "- The server has stopped. If it's a crash, please read the output above."
echo
read -p "- Press Return to exit..." _

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="SysOut" target="SYSTEM_OUT">
<PatternLayout pattern="[%d{HH:mm:ss}] [%t/%level]: %msg{nolookups}%n" />
</Console>
<Queue name="ServerGuiConsole">
<PatternLayout pattern="[%d{HH:mm:ss} %level]: %msg{nolookups}%n" />
</Queue>
<RollingRandomAccessFile name="File" fileName="logs/latest.log" filePattern="logs/%d{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="[%d{HH:mm:ss}] [%t/%level]: %msg{nolookups}%n" />
<Policies>
<TimeBasedTriggeringPolicy />
<OnStartupTriggeringPolicy />
</Policies>
</RollingRandomAccessFile>
</Appenders>
<Loggers>
<Root level="info">
<filters>
<MarkerFilter marker="NETWORK_PACKETS" onMatch="DENY" onMismatch="NEUTRAL" />
</filters>
<AppenderRef ref="SysOut"/>
<AppenderRef ref="File"/>
<AppenderRef ref="ServerGuiConsole"/>
</Root>
</Loggers>
</Configuration>

View File

@ -0,0 +1,32 @@
#Minecraft server properties
op-permission-level=4
level-name=world
allow-flight=true
prevent-proxy-connections=false
server-port=25565
max-world-size=29999984
level-seed=
force-gamemode=false
server-ip=
network-compression-threshold=256
max-build-height=256
spawn-npcs=true
white-list=false
spawn-animals=true
hardcore=false
snooper-enabled=true
resource-pack-sha1=
online-mode=true
resource-pack=
pvp=true
difficulty=1
enable-command-block=false
gamemode=0
player-idle-timeout=0
max-players=20
spawn-monsters=true
view-distance=10
generate-structures=true
motd=Nomifactory Server
level-type=lostcities
generator-settings=