forked from phenom/healer-man
Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
constants,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
@@ -13,8 +14,16 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
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([
|
||||
"core:ui",
|
||||
"core:equipment",
|
||||
@@ -179,15 +188,112 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
|
||||
return [...discovered.values()].sort((left, right) => left.logicalPath.localeCompare(right.logicalPath));
|
||||
}
|
||||
|
||||
function contentVersion(files) {
|
||||
function contentVersion(files, packs) {
|
||||
const digest = createHash("sha256");
|
||||
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\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 version = options.version ?? contentVersion(files);
|
||||
const packs = new Map();
|
||||
const fileEntries = {};
|
||||
|
||||
@@ -227,16 +333,93 @@ export function createContentManifest(files, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
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: [...packs.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
||||
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 copyObjectIfMissing(file, objectDirectory) {
|
||||
const objectName = `${file.sha256}${file.extension}`;
|
||||
const destination = path.join(objectDirectory, objectName);
|
||||
@@ -263,20 +446,35 @@ async function replaceJsonAtomically(destination, value) {
|
||||
|
||||
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 ?? []),
|
||||
];
|
||||
const manifest = createContentManifest(files, { ...options, packDefinitions });
|
||||
if (campaignDefinitions) assertCampaignDungeonManifest(manifest, campaignDefinitions);
|
||||
|
||||
const objectDirectory = path.join(contentDirectory, "objects");
|
||||
const manifestDirectory = path.join(contentDirectory, "manifests");
|
||||
await mkdir(objectDirectory, { recursive: true });
|
||||
await mkdir(manifestDirectory, { recursive: true });
|
||||
|
||||
const files = await scanContentSources(options.sources ?? DEFAULT_CONTENT_SOURCES);
|
||||
let copiedObjects = 0;
|
||||
for (const file of files) {
|
||||
if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1;
|
||||
}
|
||||
const manifest = createContentManifest(files, options);
|
||||
await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest);
|
||||
await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest);
|
||||
return { contentDirectory, copiedObjects, manifest };
|
||||
return {
|
||||
contentDirectory,
|
||||
copiedObjects,
|
||||
manifest,
|
||||
campaignDungeonPackCount: campaignDefinitions?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCliArguments(argv) {
|
||||
@@ -296,6 +494,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
|
||||
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")}`);
|
||||
}
|
||||
Reference in new issue
Block a user