forked from phenom/healer-man
484 lines
20 KiB
JavaScript
484 lines
20 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import {
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { prepareContentObjects } from "./content-objects.mjs";
|
|
import contentPolicy from "../../shared/content-policy.json" with { type: "json" };
|
|
|
|
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
|
|
const WOW_CAMPAIGN_CATALOG_PATH = path.join(
|
|
PROJECT_ROOT,
|
|
"src",
|
|
"game",
|
|
"generated",
|
|
"dungeonCampaignCatalog.json",
|
|
);
|
|
export const CONTENT_SCHEMA_VERSION = 1;
|
|
export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
|
|
export const WOW_CAMPAIGN_DUNGEON_COUNT = 54;
|
|
export const BUNDLED_ANDROID_PACKS = Object.freeze([...contentPolicy.bootstrapPackIds]);
|
|
|
|
const RUNTIME_EXTENSIONS = new Set([
|
|
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
|
".svg", ".wasm", ".wav", ".webm", ".webp",
|
|
]);
|
|
|
|
export const BASIC_HUMAN_TEXTURE_PATHS = Object.freeze([
|
|
"assets/characters/wow335a-v1/textures/character/human/faciallowerhair00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/facialupperhair00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefacelower00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalefaceupper00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedpelvisskin00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemalenakedtorsoskin00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/female/humanfemaleskin00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/hair00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefacelower00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/male/humanmalefaceupper00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/male/humanmalenakedpelvisskin00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/scalplowerhair00_00.webp",
|
|
"assets/characters/wow335a-v1/textures/character/human/scalpupperhair00_00.webp",
|
|
]);
|
|
const BASIC_HUMAN_TEXTURE_PATH_SET = new Set(BASIC_HUMAN_TEXTURE_PATHS);
|
|
|
|
export const DEFAULT_CONTENT_SOURCES = Object.freeze([
|
|
{
|
|
directory: path.join(PROJECT_ROOT, "public", "assets"),
|
|
logicalPrefix: "assets",
|
|
},
|
|
{
|
|
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons"),
|
|
logicalPrefix: "assets/game/dungeons",
|
|
},
|
|
]);
|
|
|
|
function normalizedRelativePath(value) {
|
|
return value.split(path.sep).join("/").replace(/^\/+/, "");
|
|
}
|
|
|
|
function encodeUrlPath(value) {
|
|
return value.split("/").map(encodeURIComponent).join("/");
|
|
}
|
|
|
|
function packLabel(packId) {
|
|
const [category, id = category] = packId.split(":", 2);
|
|
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
|
|
if (category === "dungeon") return name;
|
|
if (packId === "characters:wow335a-v1") return "Extra Wrath Character Models & Skins";
|
|
if (packId === "characters:ascension-v1") return "Ascension Character Models & Skins";
|
|
if (packId === "characters:runewaker-v1") return "RuneWaker Character Models & Skins";
|
|
if (category === "characters") return `${name} Character Models & Skins`;
|
|
if (category === "manastorm") return `${name} Manastorm`;
|
|
if (packId === "shared:creatures") return "Shared Creatures";
|
|
if (packId === "core:ui") return "Interface";
|
|
if (packId === "core:equipment") return "Starter Equipment";
|
|
if (packId === "core:character-basic") return "Basic Human Character Model";
|
|
if (packId === "core:runtime") return "Game Runtime";
|
|
if (packId === "core:arenas") return "Rated Arenas";
|
|
if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
|
|
return name;
|
|
}
|
|
|
|
export function contentPackForPath(logicalPath) {
|
|
const normalizedPath = normalizedRelativePath(logicalPath);
|
|
const segments = normalizedPath.split("/");
|
|
if (segments[0] === "basis" || segments[0] === "data") return "core:runtime";
|
|
if (segments[0] !== "assets") return "core:misc";
|
|
if (segments[1] === "characters" && segments[2]) {
|
|
const basicWrathHuman = segments[2] === "wow335a-v1" && (
|
|
segments[3] === "manifest.json"
|
|
|| segments[3] === "human"
|
|
|| BASIC_HUMAN_TEXTURE_PATH_SET.has(normalizedPath)
|
|
);
|
|
return basicWrathHuman ? "core:character-basic" : `characters:${segments[2]}`;
|
|
}
|
|
if (segments[1] === "creatures" && segments[2]) {
|
|
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
|
|
}
|
|
if (segments[1] === "game" && segments[2] === "creatures" && segments[3]) {
|
|
return segments[3] === "shared" ? "shared:creatures" : `dungeon:${segments[3]}`;
|
|
}
|
|
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
|
return `dungeon:${segments[3]}`;
|
|
}
|
|
if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas";
|
|
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
|
|
return `manastorm:${segments[3]}`;
|
|
}
|
|
if (segments[1] === "game" && segments[2] === "combat-effects") return "core:combat-media";
|
|
if (segments[1] === "ui") return "core:ui";
|
|
if (segments[1] === "equipment") return "core:equipment";
|
|
return "core:misc";
|
|
}
|
|
|
|
async function walkFiles(directory) {
|
|
const rootStats = await stat(directory);
|
|
if (rootStats.isFile()) {
|
|
return RUNTIME_EXTENSIONS.has(path.extname(directory).toLowerCase()) ? [directory] : [];
|
|
}
|
|
const files = [];
|
|
async function visit(current) {
|
|
const entries = await readdir(current, { withFileTypes: true });
|
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(current, entry.name);
|
|
if (entry.isDirectory()) await visit(fullPath);
|
|
else if (entry.isFile() && RUNTIME_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(fullPath);
|
|
}
|
|
}
|
|
await visit(directory);
|
|
return files;
|
|
}
|
|
|
|
async function sha256File(filePath) {
|
|
const { createReadStream } = await import("node:fs");
|
|
return new Promise((resolve, reject) => {
|
|
const hash = createHash("sha256");
|
|
const stream = createReadStream(filePath);
|
|
stream.on("error", reject);
|
|
stream.on("data", (chunk) => hash.update(chunk));
|
|
stream.on("end", () => resolve(hash.digest("hex")));
|
|
});
|
|
}
|
|
|
|
export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
|
|
const discovered = new Map();
|
|
for (const source of sources) {
|
|
const sourceDirectory = path.resolve(source.directory);
|
|
const files = await walkFiles(sourceDirectory);
|
|
for (const sourcePath of files) {
|
|
const relative = normalizedRelativePath(path.relative(sourceDirectory, sourcePath));
|
|
const logicalPath = normalizedRelativePath(relative
|
|
? `${source.logicalPrefix}/${relative}`
|
|
: source.logicalPrefix);
|
|
const fileStats = await stat(sourcePath);
|
|
const sha256 = await sha256File(sourcePath);
|
|
const prior = discovered.get(logicalPath);
|
|
if (prior) {
|
|
if (prior.sha256 !== sha256) {
|
|
throw new Error(`Two content sources map different files to ${logicalPath}`);
|
|
}
|
|
continue;
|
|
}
|
|
discovered.set(logicalPath, {
|
|
logicalPath,
|
|
sourcePath,
|
|
sha256,
|
|
bytes: fileStats.size,
|
|
extension: path.extname(sourcePath).toLowerCase(),
|
|
packId: contentPackForPath(logicalPath),
|
|
});
|
|
}
|
|
}
|
|
return [...discovered.values()].sort((left, right) => left.logicalPath.localeCompare(right.logicalPath));
|
|
}
|
|
|
|
function contentVersion(files, packs) {
|
|
const digest = createHash("sha256");
|
|
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\0${JSON.stringify(file.download ?? null)}\n`);
|
|
for (const pack of packs) {
|
|
digest.update(`pack\0${pack.id}\0${pack.label}\0${pack.bundledOnAndroid}\0${pack.dependencies.join(",")}\n`);
|
|
}
|
|
return `content-${digest.digest("hex").slice(0, 16)}`;
|
|
}
|
|
|
|
function assetPackId(url) {
|
|
if (typeof url !== "string" || !url.startsWith("/")) return null;
|
|
return contentPackForPath(url.slice(1));
|
|
}
|
|
|
|
/**
|
|
* Builds the logical install topology for the 54-dungeon WoW campaign.
|
|
* Environment files intentionally remain in their independently downloadable
|
|
* Manastorm packs; a dungeon pack owns its creatures and pulls in exactly the
|
|
* environment/shared-creature packs referenced by the generated definition.
|
|
*/
|
|
export function campaignDungeonPackDefinitions(definitions) {
|
|
const packs = new Map();
|
|
for (const definition of definitions) {
|
|
const dungeonPackId = `dungeon:${definition.id}`;
|
|
if (definition.id === "wailing-caverns") {
|
|
packs.set(dungeonPackId, {
|
|
id: dungeonPackId,
|
|
label: definition.title ?? packLabel(dungeonPackId),
|
|
bundledOnAndroid: true,
|
|
dependencies: [],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const environmentUrls = [
|
|
...(definition.assets?.visual ?? []).map((asset) => asset.url),
|
|
...(definition.assets?.collision ?? []).map((asset) => asset.url),
|
|
definition.assets?.navigation?.url,
|
|
].filter(Boolean);
|
|
const resolvedEnvironmentPacks = environmentUrls.map(assetPackId);
|
|
if (!resolvedEnvironmentPacks.length || resolvedEnvironmentPacks.some((id) => !id?.startsWith("manastorm:"))) {
|
|
throw new Error(`${definition.id} has an environment asset outside a Manastorm content pack.`);
|
|
}
|
|
const environmentPackIds = new Set(resolvedEnvironmentPacks);
|
|
if (environmentPackIds.size !== 1) {
|
|
throw new Error(`${definition.id} must resolve to exactly one Manastorm environment pack.`);
|
|
}
|
|
const [environmentPackId] = environmentPackIds;
|
|
packs.set(environmentPackId, {
|
|
id: environmentPackId,
|
|
label: packLabel(environmentPackId),
|
|
bundledOnAndroid: false,
|
|
dependencies: [],
|
|
});
|
|
|
|
const creatureUrls = Object.values(definition.entities ?? {})
|
|
.map((entity) => entity.visual?.model?.url)
|
|
.filter(Boolean);
|
|
const resolvedCreaturePacks = creatureUrls.map(assetPackId);
|
|
if (resolvedCreaturePacks.some((id) => !id)) {
|
|
throw new Error(`${definition.id} has a creature asset outside a content pack.`);
|
|
}
|
|
const referencedCreaturePacks = new Set(resolvedCreaturePacks.filter((id) => id !== dungeonPackId));
|
|
const unexpectedCreaturePacks = [...referencedCreaturePacks].filter((id) => id !== "shared:creatures");
|
|
if (unexpectedCreaturePacks.length) {
|
|
throw new Error(`${definition.id} references unexpected creature packs: ${unexpectedCreaturePacks.join(", ")}`);
|
|
}
|
|
packs.set(dungeonPackId, {
|
|
id: dungeonPackId,
|
|
label: definition.title ?? packLabel(dungeonPackId),
|
|
bundledOnAndroid: false,
|
|
dependencies: [environmentPackId, ...referencedCreaturePacks],
|
|
});
|
|
}
|
|
return [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
}
|
|
|
|
export async function loadCampaignDungeonPackDefinitions(
|
|
catalogPath = WOW_CAMPAIGN_CATALOG_PATH,
|
|
) {
|
|
const definitions = await loadCampaignDungeonDefinitions(catalogPath);
|
|
return campaignDungeonPackDefinitions(definitions);
|
|
}
|
|
|
|
export async function loadCampaignDungeonDefinitions(
|
|
catalogPath = WOW_CAMPAIGN_CATALOG_PATH,
|
|
) {
|
|
const campaign = JSON.parse(await readFile(catalogPath, "utf8"));
|
|
if (!Array.isArray(campaign.definitions)) {
|
|
throw new Error(`WoW campaign catalog is missing definitions: ${catalogPath}`);
|
|
}
|
|
if (campaign.definitions.length !== WOW_CAMPAIGN_DUNGEON_COUNT) {
|
|
throw new Error(
|
|
`WoW campaign catalog must contain ${WOW_CAMPAIGN_DUNGEON_COUNT} dungeons; found ${campaign.definitions.length}.`,
|
|
);
|
|
}
|
|
const dungeonIds = campaign.definitions.map((definition) => definition?.id);
|
|
if (dungeonIds.some((id) => typeof id !== "string" || !id)
|
|
|| new Set(dungeonIds).size !== dungeonIds.length) {
|
|
throw new Error("WoW campaign catalog contains a missing or duplicate dungeon id.");
|
|
}
|
|
return campaign.definitions;
|
|
}
|
|
|
|
export function createContentManifest(files, options = {}) {
|
|
const baseUrl = String(options.baseUrl ?? "/content/objects").replace(/\/+$/, "");
|
|
const packs = new Map();
|
|
const fileEntries = {};
|
|
|
|
for (const file of files) {
|
|
const objectName = `${file.sha256}${file.extension}`;
|
|
const logicalUrl = `/${normalizedRelativePath(file.logicalPath)}`;
|
|
fileEntries[logicalUrl] = {
|
|
packId: file.packId,
|
|
url: `${baseUrl}/${encodeUrlPath(objectName)}`,
|
|
sha256: file.sha256,
|
|
bytes: file.bytes,
|
|
...(file.download ? { download: file.download } : {}),
|
|
};
|
|
const pack = packs.get(file.packId) ?? {
|
|
id: file.packId,
|
|
label: packLabel(file.packId),
|
|
bytes: 0,
|
|
fileCount: 0,
|
|
bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(file.packId),
|
|
dependencies: [],
|
|
};
|
|
pack.bytes += file.bytes;
|
|
pack.fileCount += 1;
|
|
packs.set(file.packId, pack);
|
|
}
|
|
|
|
if (packs.has("shared:creatures")) {
|
|
for (const pack of packs.values()) {
|
|
if ((pack.id.startsWith("dungeon:") && pack.id !== "dungeon:wailing-caverns")
|
|
|| pack.id.startsWith("manastorm:")) {
|
|
pack.dependencies = ["shared:creatures"];
|
|
}
|
|
}
|
|
}
|
|
if (packs.has("core:character-basic")) {
|
|
for (const pack of packs.values()) {
|
|
if (pack.id.startsWith("characters:")) pack.dependencies = ["core:character-basic"];
|
|
}
|
|
}
|
|
|
|
for (const declared of options.packDefinitions ?? []) {
|
|
const current = packs.get(declared.id) ?? {
|
|
id: declared.id,
|
|
label: packLabel(declared.id),
|
|
bytes: 0,
|
|
fileCount: 0,
|
|
bundledOnAndroid: BUNDLED_ANDROID_PACKS.includes(declared.id),
|
|
dependencies: [],
|
|
};
|
|
packs.set(declared.id, {
|
|
...current,
|
|
label: declared.label ?? current.label,
|
|
bundledOnAndroid: declared.bundledOnAndroid ?? current.bundledOnAndroid,
|
|
dependencies: [...declared.dependencies],
|
|
});
|
|
}
|
|
|
|
const sortedPacks = [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
for (const pack of sortedPacks) {
|
|
const objects = new Map(files.filter((file) => file.packId === pack.id)
|
|
.map((file) => [file.sha256, file.download?.bytes ?? file.bytes]));
|
|
pack.downloadBytes = [...objects.values()].reduce((sum, bytes) => sum + bytes, 0);
|
|
}
|
|
const version = options.version ?? contentVersion(files, sortedPacks);
|
|
|
|
return {
|
|
schemaVersion: CONTENT_SCHEMA_VERSION,
|
|
version,
|
|
createdAt: options.createdAt ?? new Date().toISOString(),
|
|
minimumAppVersion: options.minimumAppVersion ?? DEFAULT_MINIMUM_APP_VERSION,
|
|
packs: sortedPacks,
|
|
files: fileEntries,
|
|
};
|
|
}
|
|
|
|
export function assertCampaignDungeonManifest(manifest, definitions) {
|
|
if (!Array.isArray(definitions) || definitions.length !== WOW_CAMPAIGN_DUNGEON_COUNT) {
|
|
throw new Error(`Release content requires exactly ${WOW_CAMPAIGN_DUNGEON_COUNT} WoW dungeon definitions.`);
|
|
}
|
|
const expectedDefinitions = campaignDungeonPackDefinitions(definitions);
|
|
const manifestPacks = new Map(manifest.packs.map((pack) => [pack.id, pack]));
|
|
if (manifestPacks.size !== manifest.packs.length) {
|
|
throw new Error("Release content manifest contains duplicate pack ids.");
|
|
}
|
|
|
|
for (const expected of expectedDefinitions) {
|
|
const actual = manifestPacks.get(expected.id);
|
|
if (!actual) throw new Error(`Release content manifest is missing ${expected.id}.`);
|
|
if (actual.bundledOnAndroid !== expected.bundledOnAndroid
|
|
|| actual.dependencies.length !== expected.dependencies.length
|
|
|| actual.dependencies.some((dependency, index) => dependency !== expected.dependencies[index])) {
|
|
throw new Error(`Release content manifest has stale dependencies for ${expected.id}.`);
|
|
}
|
|
}
|
|
|
|
for (const pack of manifest.packs) {
|
|
const packFiles = Object.values(manifest.files).filter((entry) => entry.packId === pack.id);
|
|
const packBytes = packFiles.reduce((total, entry) => total + entry.bytes, 0);
|
|
if (pack.fileCount !== packFiles.length || pack.bytes !== packBytes) {
|
|
throw new Error(`Release content manifest has invalid file totals for ${pack.id}.`);
|
|
}
|
|
for (const dependency of pack.dependencies) {
|
|
if (!manifestPacks.has(dependency)) {
|
|
throw new Error(`Release content manifest is missing dependency ${dependency} for ${pack.id}.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const definition of definitions) {
|
|
const referencedUrls = [
|
|
...(definition.assets?.visual ?? []).map((asset) => asset.url),
|
|
...(definition.assets?.collision ?? []).map((asset) => asset.url),
|
|
definition.assets?.navigation?.url,
|
|
...Object.values(definition.entities ?? {}).map((entity) => entity.visual?.model?.url),
|
|
].filter(Boolean);
|
|
for (const logicalPath of referencedUrls) {
|
|
if (!manifest.files[logicalPath]) {
|
|
throw new Error(`Release content manifest is missing ${definition.id} asset ${logicalPath}.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const dungeonPackCount = definitions.filter((definition) => (
|
|
manifestPacks.has(`dungeon:${definition.id}`)
|
|
)).length;
|
|
if (dungeonPackCount !== WOW_CAMPAIGN_DUNGEON_COUNT) {
|
|
throw new Error(
|
|
`Release content manifest contains ${dungeonPackCount}/${WOW_CAMPAIGN_DUNGEON_COUNT} WoW dungeon packs.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function replaceJsonAtomically(destination, value) {
|
|
const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`;
|
|
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
await rename(temporary, destination);
|
|
}
|
|
|
|
export async function publishContent(options = {}) {
|
|
const contentDirectory = path.resolve(options.contentDirectory ?? process.env.CONTENT_DIR ?? path.join(PROJECT_ROOT, "content"));
|
|
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
|
|
const includeCampaignPacks = options.includeCampaignPacks !== false;
|
|
const campaignDefinitions = includeCampaignPacks ? await loadCampaignDungeonDefinitions() : null;
|
|
const campaignPackDefinitions = campaignDefinitions
|
|
? campaignDungeonPackDefinitions(campaignDefinitions)
|
|
: [];
|
|
const packDefinitions = [
|
|
...campaignPackDefinitions,
|
|
...(options.packDefinitions ?? []),
|
|
];
|
|
if (campaignDefinitions) assertCampaignDungeonManifest(createContentManifest(files, { ...options, packDefinitions }), campaignDefinitions);
|
|
|
|
const objectDirectory = path.join(contentDirectory, "objects");
|
|
const manifestDirectory = path.join(contentDirectory, "manifests");
|
|
await mkdir(objectDirectory, { recursive: true });
|
|
await mkdir(manifestDirectory, { recursive: true });
|
|
const prepared = await prepareContentObjects(files, {
|
|
directory: objectDirectory,
|
|
baseUrl: String(options.baseUrl ?? "/content/objects").replace(/\/+$/, ""),
|
|
keepOriginal: true,
|
|
});
|
|
const manifest = createContentManifest(prepared.files, { ...options, packDefinitions });
|
|
await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest);
|
|
await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest);
|
|
return {
|
|
contentDirectory,
|
|
copiedObjects: prepared.writtenObjects,
|
|
storage: { logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes },
|
|
manifest,
|
|
campaignDungeonPackCount: campaignDefinitions?.length ?? 0,
|
|
};
|
|
}
|
|
|
|
function parseCliArguments(argv) {
|
|
const options = {};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const value = argv[index];
|
|
if (value === "--content-dir") options.contentDirectory = argv[++index];
|
|
else if (value === "--base-url") options.baseUrl = argv[++index];
|
|
else if (value === "--minimum-app-version") options.minimumAppVersion = argv[++index];
|
|
else throw new Error(`Unknown content publishing argument: ${value}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const result = await publishContent(parseCliArguments(process.argv.slice(2)));
|
|
const bytes = result.manifest.packs.reduce((total, pack) => total + pack.bytes, 0);
|
|
console.log(`Published ${result.manifest.files ? Object.keys(result.manifest.files).length : 0} files (${bytes} bytes)`);
|
|
console.log(`Content version: ${result.manifest.version}`);
|
|
console.log(`WoW campaign dungeon packs: ${result.campaignDungeonPackCount}/${WOW_CAMPAIGN_DUNGEON_COUNT}`);
|
|
console.log(`New immutable objects: ${result.copiedObjects}`);
|
|
console.log(`Manifest: ${path.join(result.contentDirectory, "manifest.json")}`);
|
|
}
|