diff --git a/bds-tools/pack-manager-lifecycle.test.mjs b/bds-tools/pack-manager-lifecycle.test.mjs new file mode 100644 index 00000000..89d9a7c7 --- /dev/null +++ b/bds-tools/pack-manager-lifecycle.test.mjs @@ -0,0 +1,139 @@ +/** + * pack-manager SOLID 回归:modules-json / clear-rp / has-pack / read-manifest + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, before, after } from "node:test"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.join(__dirname, "dist", "cli-pack-manager.js"); + +function run(args) { + const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" }); + return { status: r.status, out: r.stdout ?? "", err: r.stderr ?? "" }; +} + +describe("pack-manager CLI extensions", () => { + /** @type {string} */ + let tmp; + before(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sfmc-pm-")); + }); + after(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("assemble-rp --modules-json 只用显式 map(不扫整树)", async () => { + const { loadModuleResourcePackMap, assembleResourcePack } = await import("./dist/pack-manager.js"); + const modA = path.join(tmp, "mod-a", "resource_pack"); + const modB = path.join(tmp, "mod-b", "resource_pack"); + fs.mkdirSync(modA, { recursive: true }); + fs.mkdirSync(modB, { recursive: true }); + fs.writeFileSync(path.join(modA, "a.txt"), "a"); + fs.writeFileSync(path.join(modB, "b.txt"), "b"); + const mapFile = path.join(tmp, "map.json"); + /* 仅启用 mod-a */ + fs.writeFileSync(mapFile, JSON.stringify({ "mod-a": modA })); + const map = loadModuleResourcePackMap(mapFile); + assert.deepEqual(Object.keys(map), ["mod-a"]); + + const out = path.join(tmp, "rp-out"); + await assembleResourcePack({ + moduleResourceDirs: map, + outDir: out, + projectName: "test-rp", + uuid: "11111111-1111-1111-1111-111111111111", + version: [1, 0, 0], + }); + assert.ok(fs.existsSync(path.join(out, "mod-a", "a.txt"))); + assert.ok(!fs.existsSync(path.join(out, "mod-b"))); + + const cli = run([ + "assemble-rp", + "--modules-json", + mapFile, + "--out", + path.join(tmp, "rp-cli"), + "--name", + "test-rp-cli", + "--uuid", + "22222222-2222-2222-2222-222222222222", + ]); + assert.equal(cli.status, 0, cli.err || cli.out); + }); + + it("deploy --clear-rp 删除世界内残留 RP 目录", async () => { + const { deployToBDS } = await import("./dist/pack-manager.js"); + const bds = path.join(tmp, "bds"); + const level = "Bedrock level"; + const worlds = path.join(bds, "worlds", level); + const bpSrc = path.join(tmp, "bp-src"); + const rpDst = path.join(worlds, "resource_packs", "sfmc-modules-rp"); + fs.mkdirSync(bpSrc, { recursive: true }); + fs.writeFileSync(path.join(bpSrc, "dummy.txt"), "bp"); + fs.mkdirSync(rpDst, { recursive: true }); + fs.writeFileSync(path.join(rpDst, "stale.txt"), "old"); + + await deployToBDS({ + bdsRoot: bds, + levelName: level, + behaviorPackSrc: bpSrc, + bpName: "sfmc-modules", + rpName: "sfmc-modules-rp", + clearResourcePack: true, + }); + assert.ok(!fs.existsSync(rpDst)); + assert.ok(fs.existsSync(path.join(worlds, "behavior_packs", "sfmc-modules", "dummy.txt"))); + }); + + it("read-manifest / has-pack CLI 契约", async () => { + const { assembleBehaviorPack, enablePackInWorld, worldPackListHas } = await import("./dist/pack-manager.js"); + const bpOut = path.join(tmp, "bp-assembled"); + const src = path.join(tmp, "bp-empty-src"); + fs.mkdirSync(path.join(src, "scripts"), { recursive: true }); + fs.writeFileSync(path.join(src, "scripts", "main.js"), "//\n"); + const uuid = "33333333-3333-3333-3333-333333333333"; + await assembleBehaviorPack({ + srcDir: src, + outDir: bpOut, + projectName: "sfmc-modules", + uuid, + version: [1, 2, 3], + }); + + const rm = run(["read-manifest", "--pack-dir", bpOut]); + assert.equal(rm.status, 0, rm.err); + const header = JSON.parse(rm.out.trim()); + assert.equal(header.uuid, uuid); + assert.deepEqual(header.version, [1, 2, 3]); + + const worldsDir = path.join(tmp, "worlds-root"); + await enablePackInWorld({ + worldsDir, + levelName: "L1", + kind: "behavior", + packUuid: uuid, + version: [1, 2, 3], + }); + assert.equal(worldPackListHas(worldsDir, "L1", "behavior", uuid), true); + assert.equal(worldPackListHas(worldsDir, "L1", "behavior", "00000000-0000-0000-0000-000000000000"), false); + + const hp = run([ + "has-pack", + "--worlds-dir", + worldsDir, + "--level", + "L1", + "--kind", + "behavior", + "--pack-id", + uuid, + ]); + assert.equal(hp.status, 0); + assert.equal(hp.out.trim(), "1"); + }); +}); diff --git a/bds-tools/package.json b/bds-tools/package.json index ec51949d..4a536358 100644 --- a/bds-tools/package.json +++ b/bds-tools/package.json @@ -41,7 +41,7 @@ "dev": "tsx src/dist/bds-manager.ts", "typecheck": "tsc --noEmit", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", - "test": "node --test is-main.test.mjs", + "test": "node --test is-main.test.mjs pack-manager-lifecycle.test.mjs", "update": "node dist/check-update.js", "update:check": "node dist/check-update.js --check-only", "update:force": "node dist/check-update.js --force", diff --git a/bds-tools/src/cli-pack-manager.ts b/bds-tools/src/cli-pack-manager.ts index b9ca06f3..8d0f23ed 100644 --- a/bds-tools/src/cli-pack-manager.ts +++ b/bds-tools/src/cli-pack-manager.ts @@ -4,12 +4,14 @@ * so spawnService can call it as a sub-process. * * node bds-tools/dist/pack-manager.js assemble-bp --src --out --name [--uuid ] [--module-uuid ] [--version 1,0,0] [--description "..."] [--icon ] - * node bds-tools/dist/pack-manager.js assemble-rp --modules-dir --out --name [--uuid ] [--module-uuid ] [--version 1,0,0] [--description "..."] - * node bds-tools/dist/pack-manager.js deploy --bds-root --level --bp-src [--rp-src ] --bp-name [--rp-name ] + * node bds-tools/dist/pack-manager.js assemble-rp (--modules-dir |--modules-json ) --out --name [--uuid ] [--module-uuid ] [--version 1,0,0] [--description "..."] + * node bds-tools/dist/pack-manager.js deploy --bds-root --level --bp-src [--rp-src ] --bp-name [--rp-name ] [--clear-rp] * node bds-tools/dist/pack-manager.js enable-pack --worlds-dir --level --kind behavior|resource --pack-id --version 1,0,0 * node bds-tools/dist/pack-manager.js disable-pack --worlds-dir --level --kind behavior|resource --pack-id * node bds-tools/dist/pack-manager.js ensure-permission --bds-root --pack-id * node bds-tools/dist/pack-manager.js read-level --bds-root + * node bds-tools/dist/pack-manager.js read-manifest --pack-dir + * node bds-tools/dist/pack-manager.js has-pack --worlds-dir --level --kind behavior|resource --pack-id * * The pure-function API lives in pack-manager.ts. This CLI exists so the * SEA-launched child process doesn't have to deal with module resolution — @@ -33,7 +35,13 @@ function parseArgs(argv: string[]): { [k: string]: string | undefined } { if (a && a.startsWith("--")) { const key = a.slice(2); const next = argv[++i]; - out[key] = next; + /* 布尔旗标(无值或下一参也是 --xxx) */ + if (next === undefined || next.startsWith("--")) { + out[key] = "1"; + if (next !== undefined) i--; + } else { + out[key] = next; + } } } return out; @@ -84,10 +92,16 @@ async function main(): Promise { return; } case "assemble-rp": { - const modulesDir = need(args, "modules-dir"); const out = need(args, "out"); const name = need(args, "name"); - const map = mod.scanModuleResourcePacks(path.resolve(modulesDir)); + /* OCP:显式 map 优先;否则回退扫描 --modules-dir */ + let map: Record; + if (args["modules-json"]) { + map = mod.loadModuleResourcePackMap(path.resolve(args["modules-json"])); + } else { + const modulesDir = need(args, "modules-dir"); + map = mod.scanModuleResourcePacks(path.resolve(modulesDir)); + } await mod.assembleResourcePack({ moduleResourceDirs: map, outDir: path.resolve(out), @@ -107,6 +121,7 @@ async function main(): Promise { const bpName = need(args, "bp-name"); const rpSrc = args["rp-src"]; const rpName = args["rp-name"]; + const clearRp = args["clear-rp"] === "1" || args["clear-rp"] === "true"; await mod.deployToBDS({ bdsRoot: path.resolve(bdsRoot), levelName: level, @@ -114,6 +129,7 @@ async function main(): Promise { ...(rpSrc ? { resourcePackSrc: path.resolve(rpSrc) } : {}), bpName, ...(rpName ? { rpName } : {}), + ...(clearRp ? { clearResourcePack: true } : {}), }); process.stdout.write(`[pack-manager] deployed to ${path.join(bdsRoot, "worlds", level)}\n`); return; @@ -165,6 +181,25 @@ async function main(): Promise { process.stdout.write(`${name}\n`); return; } + case "read-manifest": { + const packDir = need(args, "pack-dir"); + const header = mod.readPackManifestHeader(path.resolve(packDir)); + if (!header) { + process.stdout.write("null\n"); + return; + } + process.stdout.write(`${JSON.stringify(header)}\n`); + return; + } + case "has-pack": { + const worldsDir = need(args, "worlds-dir"); + const level = need(args, "level"); + const kind = need(args, "kind") as "behavior" | "resource"; + const packId = need(args, "pack-id"); + const ok = mod.worldPackListHas(path.resolve(worldsDir), level, kind, packId); + process.stdout.write(ok ? "1\n" : "0\n"); + return; + } default: die(`unknown verb: ${verb}`); } @@ -173,4 +208,4 @@ async function main(): Promise { main().catch((err: unknown) => { const msg = err instanceof Error ? (err.stack ?? err.message) : String(err); die(msg); -}); \ No newline at end of file +}); diff --git a/bds-tools/src/pack-manager.ts b/bds-tools/src/pack-manager.ts index 150b7661..f4ee0d10 100644 --- a/bds-tools/src/pack-manager.ts +++ b/bds-tools/src/pack-manager.ts @@ -80,6 +80,11 @@ export interface DeployOpts { bpName: string; /** RP 目标目录名 (worlds//resource_packs/);省略时复用 bpName + '-rp' */ rpName?: string; + /** + * 无 resourcePackSrc 时是否删除世界内已有 RP 目录。 + * 用于模块不再提供 RP 时清理聚合包残留(默认 false,避免误删未声明的 RP)。 + */ + clearResourcePack?: boolean; } export interface DeployResult { @@ -211,6 +216,9 @@ export async function deployToBDS(opts: DeployOpts): Promise { await fs.promises.rm(rpDst, { recursive: true, force: true }); await copyDirAsync(opts.resourcePackSrc, rpDst); rpDir = rpDst; + } else if (opts.clearResourcePack) { + /* 调用方显式声明不再部署 RP — 清掉聚合 RP 目录残留 */ + await fs.promises.rm(rpDst, { recursive: true, force: true }); } return { bpDir: bpDst, rpDir }; } @@ -264,7 +272,6 @@ export async function writePermissionsJson(bpDir: string): Promise { const tmp = path.join(bpDir, `.permissions.${process.pid}.tmp`); await fs.promises.writeFile(tmp, JSON.stringify(payload, null, 2) + "\n", "utf8"); await fs.promises.rename(tmp, path.join(bpDir, "permissions.json")); - void copyDirAsync; } /** @@ -412,4 +419,46 @@ export function scanModuleResourcePacks(modulesDir: string): Record { + const raw = JSON.parse(fs.readFileSync(jsonPath, "utf8")) as unknown; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`modules-json must be an object: ${jsonPath}`); + } + const out: Record = {}; + for (const [id, dir] of Object.entries(raw as Record)) { + if (typeof id !== "string" || !id || typeof dir !== "string" || !dir) continue; + const abs = path.resolve(dir); + if (!fs.existsSync(abs)) { + throw new Error(`modules-json entry missing: ${id} → ${abs}`); + } + out[id] = abs; + } + return out; +} + +/** 世界 enable-list 是否已含指定 pack_id(只读,供 preflight 复用)。 */ +export function worldPackListHas( + worldsDir: string, + levelName: string, + kind: "behavior" | "resource", + packUuid: string +): boolean { + const file = path.join( + worldsDir, + levelName, + kind === "behavior" ? "world_behavior_packs.json" : "world_resource_packs.json" + ); + if (!fs.existsSync(file)) return false; + try { + const arr = JSON.parse(fs.readFileSync(file, "utf8")) as Array<{ pack_id?: string }>; + return Array.isArray(arr) && arr.some((e) => e.pack_id === packUuid); + } catch { + return false; + } } \ No newline at end of file diff --git a/sfmc/src/pack-lifecycle.ts b/sfmc/src/pack-lifecycle.ts index 6dfa552c..161e1308 100644 --- a/sfmc/src/pack-lifecycle.ts +++ b/sfmc/src/pack-lifecycle.ts @@ -96,26 +96,24 @@ function catalogPath(): string { return modulePath(path.join(ROOT, "modules"), "catalog.json"); } -/** 读 BDS 路径与 level-name */ +function spawnPackManager(args: string[]): { ok: boolean; out: string; err: string } { + const proc = spawnServiceSync("pack-manager", args, { encoding: "utf8" }); + return { + ok: proc.status === 0, + out: (proc.stdout ?? "").toString(), + err: (proc.stderr ?? "").toString(), + }; +} + +/** 读 BDS 路径与 level-name(level 权威来源:pack-manager read-level,DRY/DIP) */ export function resolveBdsContext(): { bdsRoot: string; levelName: string } { const cfg = (readJson(configPath(ROOT, "bds_updater.json")) ?? {}) as BdsUpdaterConfig; const bdsRoot = cfg.bds_path; if (!bdsRoot) { throw new Error("bds_path not configured. Run `sfmc init` first."); } - let levelName = "Bedrock level"; - try { - const text = readFileSync(path.join(bdsRoot, "server.properties"), "utf8"); - for (const line of text.split(/\r?\n/)) { - const m = /^\s*level-name\s*=\s*(.+?)\s*$/.exec(line); - if (m?.[1]) { - levelName = m[1]; - break; - } - } - } catch { - /* use default */ - } + const r = spawnPackManager(["read-level", "--bds-root", bdsRoot]); + const levelName = r.ok && r.out.trim() ? r.out.trim() : "Bedrock level"; return { bdsRoot, levelName }; } @@ -131,22 +129,17 @@ export function deployedCatalogPath(bdsRoot: string, levelName: string): string return path.join(deployedBpDir(bdsRoot, levelName), DEPLOY_CATALOG_NAME); } +/** 读 BP/RP manifest header — 委托 pack-manager(与 readPackManifestHeader 单一权威) */ function readManifestHeader( packDir: string ): { uuid: string; version: [number, number, number]; moduleUuid?: string } | null { - const file = path.join(packDir, "manifest.json"); - if (!existsSync(file)) return null; + if (!existsSync(path.join(packDir, "manifest.json"))) return null; + const r = spawnPackManager(["read-manifest", "--pack-dir", packDir]); + if (!r.ok) return null; + const text = r.out.trim(); + if (!text || text === "null") return null; try { - const raw = JSON.parse(readFileSync(file, "utf8")) as { - header?: { uuid?: string; version?: number[] }; - modules?: Array<{ uuid?: string }>; - }; - const uuid = raw.header?.uuid; - const ver = raw.header?.version; - if (typeof uuid !== "string" || !Array.isArray(ver) || ver.length < 3) return null; - const version: [number, number, number] = [Number(ver[0]), Number(ver[1]), Number(ver[2])]; - const moduleUuid = raw.modules?.[0]?.uuid; - return { uuid, version, ...(typeof moduleUuid === "string" ? { moduleUuid } : {}) }; + return JSON.parse(text) as { uuid: string; version: [number, number, number]; moduleUuid?: string }; } catch { return null; } @@ -184,8 +177,8 @@ export async function scanLocalModules(): Promise< for (const m of cat.modules) { const id = typeof m.id === "string" ? m.id : ""; if (!id) continue; - /* enabledByDefault 缺省按 false(与 feature 模块一致) */ - defaults.set(id, typeof m.enabledByDefault === "boolean" ? m.enabledByDefault : false); + /* 与 db-server 契约一致:enabledByDefault !== false(缺省 true) — LSP */ + defaults.set(id, m.enabledByDefault !== false); } } @@ -346,29 +339,20 @@ export function catalogsEqual(a: DeployCatalog, b: DeployCatalog): boolean { return true; } +/** 世界 enable-list 查询 — 委托 pack-manager has-pack(DRY) */ function worldPackListHas(bdsRoot: string, levelName: string, kind: "behavior" | "resource", uuid: string): boolean { - const file = path.join( - bdsRoot, - "worlds", + const r = spawnPackManager([ + "has-pack", + "--worlds-dir", + path.join(bdsRoot, "worlds"), + "--level", levelName, - kind === "behavior" ? "world_behavior_packs.json" : "world_resource_packs.json" - ); - if (!existsSync(file)) return false; - try { - const arr = JSON.parse(readFileSync(file, "utf8")) as Array<{ pack_id?: string }>; - return Array.isArray(arr) && arr.some((e) => e.pack_id === uuid); - } catch { - return false; - } -} - -function spawnPackManager(args: string[]): { ok: boolean; out: string; err: string } { - const proc = spawnServiceSync("pack-manager", args, { encoding: "utf8" }); - return { - ok: proc.status === 0, - out: (proc.stdout ?? "").toString(), - err: (proc.stderr ?? "").toString(), - }; + "--kind", + kind, + "--pack-id", + uuid, + ]); + return r.ok && r.out.trim() === "1"; } /** esbuild 聚合启用模块 → assemble BP + RP(若有) */ @@ -377,8 +361,16 @@ export async function buildPacks(desired?: DeployCatalog): Promise m.enabled && m.entryPath).map((m) => m.entryPath!); + const rpDirs: Record = {}; + for (const m of mods) { + if (!m.enabled || !m.hasResourcePack) continue; + rpDirs[m.folderId] = path.join(packagesDir(), m.folderId, "resource_pack"); + } + const { build } = await import("esbuild"); - const entries = await listEnabledSapiEntries(); const outFile = path.join(bpSrc(), "scripts", "main.js"); await fs.mkdir(path.dirname(outFile), { recursive: true }); if (entries.length === 0) { @@ -419,28 +411,14 @@ export async function buildPacks(desired?: DeployCatalog): Promise 0 && catalog.rpUuid) { - /* assemble-rp CLI scans modules-dir; filter by writing only enabled via temp is heavy — - * pass packages dir and rely on enabled filter by assembling via inline map: - * CLI only supports --modules-dir full scan. Filter: call assemble with all scanned, - * but we need only enabled. Use --modules-dir packages and accept scan of all RPs - * OR write a temp modules mirror. Simpler: spawn with packages dir but pre-filter - * by temporarily not using CLI filter — extend CLI later. - * Workaround: assemble-rp scans all; we only want enabled. Copy enabled RPs to temp. */ - const tmpRpModules = path.join(buildRoot(), "_rp-modules-tmp"); - await fs.rm(tmpRpModules, { recursive: true, force: true }); - await fs.mkdir(tmpRpModules, { recursive: true }); - for (const [id, rpDir] of Object.entries(rpDirs)) { - const dst = path.join(tmpRpModules, id, "resource_pack"); - await fs.mkdir(path.dirname(dst), { recursive: true }); - /* 用 pack-manager copy:直接递归复制 */ - await copyDirRecursive(rpDir, dst); - } + /* OCP:显式 --modules-json 交给 pack-manager,不再临时镜像整树 */ + const mapFile = path.join(buildRoot(), "_rp-modules-map.json"); + writeJson(mapFile, rpDirs); const rpArgs = [ "assemble-rp", - "--modules-dir", - tmpRpModules, + "--modules-json", + mapFile, "--out", rpOut(), "--name", @@ -454,7 +432,7 @@ export async function buildPacks(desired?: DeployCatalog): Promise { - await fs.mkdir(dst, { recursive: true }); - for (const e of await fs.readdir(src, { withFileTypes: true })) { - const s = path.join(src, e.name); - const d = path.join(dst, e.name); - if (e.isDirectory()) await copyDirRecursive(s, d); - else await fs.copyFile(s, d); - } -} - /** 部署到世界 + enable 清单 + Script API permission */ export async function deployPacks(catalog: DeployCatalog): Promise { const { bdsRoot, levelName } = resolveBdsContext(); @@ -488,6 +456,7 @@ export async function deployPacks(catalog: DeployCatalog): Promise { throw new Error(`BP not built at ${bpOut()}. Run build first.`); } + const previous = readDeployedCatalog(bdsRoot, levelName); const deployArgs = [ "deploy", "--bds-root", @@ -498,9 +467,14 @@ export async function deployPacks(catalog: DeployCatalog): Promise { bpOut(), "--bp-name", BP_NAME, + "--rp-name", + RP_NAME, ]; if (catalog.rpUuid && existsSync(rpOut())) { - deployArgs.push("--rp-src", rpOut(), "--rp-name", RP_NAME); + deployArgs.push("--rp-src", rpOut()); + } else { + /* 不再需要 RP:清世界内聚合 RP 目录 */ + deployArgs.push("--clear-rp"); } const dep = spawnPackManager(deployArgs); if (!dep.ok) throw new Error(`deploy failed: ${dep.err || dep.out}`); @@ -544,6 +518,46 @@ export async function deployPacks(catalog: DeployCatalog): Promise { packLog(`enabled resource pack ${catalog.rpUuid} in world list`, "success"); } + /* 卸掉过期 RP:UUID 轮换或模块不再提供 RP(BLOCKER 修复) */ + if (previous?.rpUuid && previous.rpUuid !== (catalog.rpUuid ?? null)) { + const dis = spawnPackManager([ + "disable-pack", + "--worlds-dir", + worldsDir, + "--level", + levelName, + "--kind", + "resource", + "--pack-id", + previous.rpUuid, + ]); + if (!dis.ok) { + packLog(`disable stale RP ${previous.rpUuid} failed: ${dis.err || dis.out}`, "warn"); + } else { + packLog(`disabled stale resource pack ${previous.rpUuid}`, "success"); + } + } + + /* 若 BP uuid 轮换,卸掉旧 BP 清单项 */ + if (previous?.bpUuid && previous.bpUuid !== catalog.bpUuid) { + const disBp = spawnPackManager([ + "disable-pack", + "--worlds-dir", + worldsDir, + "--level", + levelName, + "--kind", + "behavior", + "--pack-id", + previous.bpUuid, + ]); + if (!disBp.ok) { + packLog(`disable stale BP ${previous.bpUuid} failed: ${disBp.err || disBp.out}`, "warn"); + } else { + packLog(`disabled stale behavior pack ${previous.bpUuid}`, "success"); + } + } + const perm = spawnPackManager([ "ensure-permission", "--bds-root",