forked from phenom/healer-man
Release Healer Man 0.1.29
This commit is contained in:
1 parent
8c9809d3e0
commit
544c1bee91
120 files changed
+4363
-1036
No files matched your search
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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="")
|
||||
Reference in new issue
Block a user