Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+206 -7
View File
@@ -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")}`);
}