Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { gzip, gunzip } from "node:zlib";
const compress = promisify(gzip);
const decompress = promisify(gunzip);
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
/** Original hashes remain the runtime contract; gzip is only a storage representation. */
export async function prepareContentObjects(files, { directory, baseUrl, keepOriginal = false }) {
await mkdir(directory, { recursive: true });
const unique = new Map();
for (const file of files) unique.set(`${file.sha256}${file.extension}`, file);
const objects = [...unique.entries()];
const prepared = new Map();
let next = 0;
let writtenObjects = 0;
const writeVerified = async (name, bytes) => {
const destination = path.join(directory, name);
try {
const existing = await readFile(destination);
if (!existing.equals(bytes)) throw new Error(`Published object differs from its hash: ${destination}`);
} catch (error) {
if (error.code !== "ENOENT") throw error;
await writeFile(destination, bytes, { flag: "wx" });
writtenObjects++;
}
};
await Promise.all(Array.from({ length: 4 }, async () => {
while (next < objects.length) {
const [name, file] = objects[next++];
const original = await readFile(file.sourcePath);
if (original.length !== file.bytes || digest(original) !== file.sha256) {
throw new Error(`Asset changed during packaging: ${file.logicalPath}`);
}
const compressed = await compress(original, { level: 6 });
const useful = compressed.length + 64 < original.length;
let download;
if (useful) {
if (!(await decompress(compressed)).equals(original)) throw new Error(`Gzip round trip failed: ${file.logicalPath}`);
const compressedHash = digest(compressed);
// AAPT expands and renames .gz assets even with noCompress. Keep gzip
// bytes under a neutral suffix so APK and browser catalogs stay identical.
const compressedName = `${compressedHash}${file.extension}.gzip`;
await writeVerified(compressedName, compressed);
download = { url: `${baseUrl}/${compressedName}`, encoding: "gzip", sha256: compressedHash, bytes: compressed.length };
}
if (keepOriginal || !useful) await writeVerified(name, original);
prepared.set(name, { objectUrl: `${baseUrl}/${name}`, ...(download ? { download } : {}), storedBytes: useful ? compressed.length : original.length });
}
}));
return {
files: files.map((file) => ({ ...file, ...prepared.get(`${file.sha256}${file.extension}`) })),
writtenObjects,
uniqueObjects: objects.length,
logicalBytes: files.reduce((sum, file) => sum + file.bytes, 0),
uniqueBytes: objects.reduce((sum, [, file]) => sum + file.bytes, 0),
storedBytes: [...prepared.values()].reduce((sum, object) => sum + object.storedBytes, 0),
};
}
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { gunzipSync } from "node:zlib";
import { prepareContentObjects } from "./content-objects.mjs";
import { selectBootstrapContent } from "./prepare-bootstrap.mjs";
import { createContentManifest } from "./publish-content.mjs";
test("ships one lossless object for identical assets and verifies original bytes", async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-content-compression-"));
try {
const sourcePath = path.join(directory, "original.glb");
const original = Buffer.from("original geometry, textures, and animation\n".repeat(4096));
await writeFile(sourcePath, original);
const common = { sourcePath, bytes: original.length, sha256: createHash("sha256").update(original).digest("hex"), extension: ".glb" };
const files = ["first", "second"].map((id) => ({ ...common, logicalPath: `assets/creatures/${id}/model.glb`, packId: `dungeon:${id}` }));
const result = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
assert.equal(result.uniqueObjects, 1);
assert.equal(result.uniqueBytes, original.length);
assert.equal(result.logicalBytes, original.length * 2);
assert.ok(result.storedBytes < original.length / 10);
assert.equal((await readdir(path.join(directory, "objects"))).length, 1);
assert.deepEqual(result.files[0].download, result.files[1].download);
assert.ok(result.files[0].download.url.endsWith(".gzip"), "avoid Android's automatic .gz expansion");
const stored = await readFile(path.join(directory, "objects", path.basename(result.files[0].download.url)));
assert.deepEqual(gunzipSync(stored), original);
assert.deepEqual(await readFile(sourcePath), original);
const again = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
assert.equal(again.writtenObjects, 0);
assert.equal(createContentManifest(result.files).version, createContentManifest(again.files).version);
await writeFile(sourcePath, "changed");
await assert.rejects(prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" }), /changed during packaging/);
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("bundles regular dungeons and exact map dependencies without installing Manastorm", () => {
const ids = ["core:ui", "dungeon:first", "dungeon:second", "shared:creatures", "manastorm:shared-map", "manastorm:optional-map", "characters:extra"];
const files = ids.map((packId, index) => ({ logicalPath: `assets/${index}.glb`, packId, sha256: String(index).repeat(64), bytes: 100, extension: ".glb" }));
const result = selectBootstrapContent(files, [
{ id: "dungeon:first", dependencies: ["manastorm:shared-map", "shared:creatures"] },
{ id: "dungeon:second", dependencies: [] },
]);
assert.deepEqual(result.files.map((file) => file.packId), ids.slice(0, 5));
const packs = new Map(result.packDefinitions.map((pack) => [pack.id, pack]));
assert.equal(packs.get("dungeon:first").bundledOnAndroid, true);
assert.equal(packs.get("dungeon:second").bundledOnAndroid, true);
assert.equal(packs.get("manastorm:shared-map").bundledOnAndroid, false);
assert.equal(packs.has("manastorm:optional-map"), false);
});
+2 -68
View File
@@ -1,69 +1,3 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT,
createContentManifest,
scanContentSources,
} from "./publish-content.mjs";
import { prepareBootstrap } from "./prepare-bootstrap.mjs";
const outputDirectory = path.join(PROJECT_ROOT, ".android-public");
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".android-public") {
throw new Error(`Refusing to replace unexpected Android staging directory: ${outputDirectory}`);
}
const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
},
];
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
const files = await scanContentSources(sources);
for (const file of files) {
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file.sourcePath, destination);
}
const generated = createContentManifest(files, { baseUrl: "/" });
const bootstrapManifest = {
...generated,
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
logicalPath,
{ ...entry, url: logicalPath },
])),
};
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
const bytes = files.reduce((total, file) => total + file.bytes, 0);
console.log(`Prepared Android bootstrap content: ${files.length} files (${bytes} bytes)`);
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
await prepareBootstrap("android");
+63
View File
@@ -0,0 +1,63 @@
import { copyFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BUNDLED_ANDROID_PACKS, DEFAULT_CONTENT_SOURCES, PROJECT_ROOT,
createContentManifest, loadCampaignDungeonPackDefinitions, scanContentSources,
} from "./publish-content.mjs";
import { prepareContentObjects } from "./content-objects.mjs";
export function selectBootstrapContent(files, packDefinitions) {
const manifest = createContentManifest(files, { packDefinitions });
const packs = new Map(manifest.packs.map((pack) => [pack.id, pack]));
const included = new Set();
function include(id) {
if (included.has(id)) return;
included.add(id);
for (const dependency of packs.get(id)?.dependencies ?? []) include(dependency);
}
for (const pack of manifest.packs) {
if (BUNDLED_ANDROID_PACKS.includes(pack.id) || pack.id.startsWith("dungeon:")) include(pack.id);
}
return {
files: files.filter((file) => included.has(file.packId)),
packDefinitions: manifest.packs.filter((pack) => included.has(pack.id)).map((pack) => ({
...pack,
// Shared maps can be physically bundled without installing Manastorm gameplay.
bundledOnAndroid: !pack.id.startsWith("manastorm:"),
})),
};
}
export async function prepareBootstrap(platform) {
if (!["web", "android"].includes(platform)) throw new Error(`Unknown bootstrap platform: ${platform}`);
const outputDirectory = path.resolve(PROJECT_ROOT, `.${platform}-public`);
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== `.${platform}-public`) {
throw new Error(`Refusing to replace unexpected staging directory: ${outputDirectory}`);
}
const files = await scanContentSources([
...DEFAULT_CONTENT_SOURCES,
{ directory: path.join(PROJECT_ROOT, "public/basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public/data"), logicalPrefix: "data" },
]);
const selected = selectBootstrapContent(files, await loadCampaignDungeonPackDefinitions());
await rm(outputDirectory, { recursive: true, force: true });
const prepared = await prepareContentObjects(selected.files, {
directory: path.join(outputDirectory, "content/bundled"), baseUrl: "/content/bundled",
});
const manifest = createContentManifest(prepared.files, {
baseUrl: "/content/bundled", packDefinitions: selected.packDefinitions,
});
await writeFile(path.join(outputDirectory, "content/bootstrap-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
await copyFile(path.join(PROJECT_ROOT, "public/content-cache-sw.js"), path.join(outputDirectory, "content-cache-sw.js"));
const storage = {
logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes,
duplicateBytesRemoved: prepared.logicalBytes - prepared.uniqueBytes,
uniqueObjects: prepared.uniqueObjects, logicalFiles: selected.files.length,
bundledDungeons: manifest.packs.filter((pack) => pack.id.startsWith("dungeon:")).length,
bundledManastormPacks: manifest.packs.filter((pack) => pack.id.startsWith("manastorm:") && pack.bundledOnAndroid).length,
};
await writeFile(path.join(outputDirectory, "content/storage-report.json"), `${JSON.stringify(storage, null, 2)}\n`);
console.log(`Prepared ${platform} bootstrap: ${storage.bundledDungeons} dungeons, ${prepared.uniqueObjects} shared objects`);
console.log(`Original: ${storage.logicalBytes} bytes; duplicates removed: ${storage.duplicateBytesRemoved} bytes; shipped: ${storage.storedBytes} bytes`);
return { manifest, storage, outputDirectory };
}
+2 -74
View File
@@ -1,75 +1,3 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
BASIC_HUMAN_TEXTURE_PATHS,
PROJECT_ROOT,
createContentManifest,
scanContentSources,
} from "./publish-content.mjs";
import { prepareBootstrap } from "./prepare-bootstrap.mjs";
const outputDirectory = path.join(PROJECT_ROOT, ".web-public");
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".web-public") {
throw new Error(`Refusing to replace unexpected browser staging directory: ${outputDirectory}`);
}
const sources = [
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
{ directory: path.join(PROJECT_ROOT, "public", "data"), logicalPrefix: "data" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
logicalPrefix: "assets/characters/wow335a-v1/human",
},
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
logicalPrefix: logicalPath,
})),
{
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns",
},
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns",
},
];
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
const files = await scanContentSources(sources);
for (const file of files) {
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
await mkdir(path.dirname(destination), { recursive: true });
await copyFile(file.sourcePath, destination);
}
await copyFile(
path.join(PROJECT_ROOT, "public", "content-cache-sw.js"),
path.join(outputDirectory, "content-cache-sw.js"),
);
const generated = createContentManifest(files, { baseUrl: "/" });
const bootstrapManifest = {
...generated,
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
logicalPath,
{ ...entry, url: logicalPath },
])),
};
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
await mkdir(path.dirname(manifestPath), { recursive: true });
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
const bytes = files.reduce((total, file) => total + file.bytes, 0);
console.log(`Prepared browser bootstrap content: ${files.length} files (${bytes} bytes)`);
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
await prepareBootstrap("web");
+17 -28
View File
@@ -1,7 +1,5 @@
import { createHash } from "node:crypto";
import {
constants,
copyFile,
mkdir,
readFile,
readdir,
@@ -11,6 +9,7 @@ import {
} from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { prepareContentObjects } from "./content-objects.mjs";
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
@@ -190,7 +189,7 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
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 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`);
}
@@ -305,6 +304,7 @@ export function createContentManifest(files, options = {}) {
url: `${baseUrl}/${encodeUrlPath(objectName)}`,
sha256: file.sha256,
bytes: file.bytes,
...(file.download ? { download: file.download } : {}),
};
const pack = packs.get(file.packId) ?? {
id: file.packId,
@@ -351,6 +351,11 @@ export function createContentManifest(files, options = {}) {
}
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 {
@@ -420,24 +425,6 @@ export function assertCampaignDungeonManifest(manifest, definitions) {
}
}
async function copyObjectIfMissing(file, objectDirectory) {
const objectName = `${file.sha256}${file.extension}`;
const destination = path.join(objectDirectory, objectName);
try {
const destinationStats = await stat(destination);
if (destinationStats.size !== file.bytes) throw new Error(`Published object has the wrong size: ${destination}`);
return false;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
try {
await copyFile(file.sourcePath, destination, constants.COPYFILE_EXCL);
} catch (error) {
if (error?.code !== "EEXIST") throw error;
}
return true;
}
async function replaceJsonAtomically(destination, value) {
const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
@@ -456,22 +443,24 @@ export async function publishContent(options = {}) {
...campaignPackDefinitions,
...(options.packDefinitions ?? []),
];
const manifest = createContentManifest(files, { ...options, packDefinitions });
if (campaignDefinitions) assertCampaignDungeonManifest(manifest, campaignDefinitions);
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 });
let copiedObjects = 0;
for (const file of files) {
if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1;
}
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,
copiedObjects: prepared.writtenObjects,
storage: { logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes },
manifest,
campaignDungeonPackCount: campaignDefinitions?.length ?? 0,
};
+58
View File
@@ -0,0 +1,58 @@
"""Verify the packaged bytes, including Android asset-name/compression behavior."""
import argparse
import gzip
import hashlib
import json
from pathlib import Path
from zipfile import ZIP_STORED, ZipFile
def verify_package(apk: Path) -> dict:
with ZipFile(apk) as archive:
manifest = json.loads(archive.read("assets/public/content/bootstrap-manifest.json"))
checked = set()
object_bytes = 0
for logical_path, entry in manifest["files"].items():
stored = entry.get("download", entry)
if stored["url"] in checked:
continue
checked.add(stored["url"])
name = "assets/public" + stored["url"]
data = archive.read(name)
if len(data) != stored["bytes"] or hashlib.sha256(data).hexdigest() != stored["sha256"]:
raise ValueError(f"Packaged object failed verification: {logical_path}")
object_bytes += len(data)
if "download" in entry:
if archive.getinfo(name).compress_type != ZIP_STORED:
raise ValueError(f"Android recompressed an already compressed object: {name}")
data = gzip.decompress(data)
if len(data) != entry["bytes"] or hashlib.sha256(data).hexdigest() != entry["sha256"]:
raise ValueError(f"Expanded object failed verification: {logical_path}")
dungeons = sum(pack["id"].startswith("dungeon:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
expected_objects = {"assets/public" + url for url in checked}
actual_objects = {name for name in archive.namelist() if name.startswith("assets/public/content/bundled/") and not name.endswith("/")}
if actual_objects != expected_objects:
raise ValueError("APK includes missing or unreferenced bundled objects.")
manastorms = sum(pack["id"].startswith("manastorm:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
if dungeons != 84 or manastorms != 0:
raise ValueError(f"Unexpected bundled content: {dungeons} dungeons, {manastorms} Manastorm packs")
return {
"apk": str(apk.resolve()), "apkBytes": apk.stat().st_size,
"manifestVersion": manifest["version"], "logicalFiles": len(manifest["files"]),
"verifiedUniqueObjects": len(checked), "storedObjectBytes": object_bytes,
"bundledDungeons": dungeons, "bundledManastormPacks": manastorms,
"allOriginalHashesMatch": True,
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("apk", type=Path)
parser.add_argument("--report", type=Path)
args = parser.parse_args()
result = json.dumps(verify_package(args.apk), indent=2) + "\n"
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(result, encoding="utf-8")
print(result, end="")