From c158badb436490d9c76e1e0b66e1865f9bedad2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 10:18:10 +0000 Subject: [PATCH] =?UTF-8?q?fix(pack-update):=20DRY/OCP/DIP=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=20CF=20=E6=BA=90=E7=BC=96=E6=8E=92=E4=B8=8E=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查 main@005fa99 pack-update(CF 探测增强)后的可验证债: - 测试改从 dist 导入 version-policy,去掉 250+ 行内联复制 - 抽取 searchAndRankHits / tryInstallResourcePack / makeBindingFromHit - createPackSourceProvider + PackProviderId,编排层只依赖接口 - 删除未接线的 byUuidInArchive/byName;getPackMatchConfig 收敛 Demeter - formatDownloadSpeed 统一 BDS/CF 下载速度文案;移除无用 cli-progress 依赖 Co-authored-by: Shiroha --- bds-tools/package.json | 1 - bds-tools/src/check-update.ts | 8 +- bds-tools/src/pack-manager.ts | 2 +- configs-default/pack-update.json | 2 - docs/guide/pack-update.md | 3 +- modules/sdk/@sfmc-sdk/src/logs/index.ts | 1 + .../@sfmc-sdk/src/logs/terminal-progress.ts | 8 + package-lock.json | 69 +--- sfmc/pack-update-policy.test.mjs | 263 ++-------------- sfmc/package.json | 3 +- sfmc/src/pack-update/config.ts | 14 +- sfmc/src/pack-update/providers/curseforge.ts | 9 +- sfmc/src/pack-update/providers/index.ts | 12 + sfmc/src/pack-update/service.ts | 297 +++++++++--------- sfmc/src/pack-update/types.ts | 18 +- sfmc/src/pack-update/version-policy.ts | 22 +- 16 files changed, 244 insertions(+), 488 deletions(-) create mode 100644 sfmc/src/pack-update/providers/index.ts diff --git a/bds-tools/package.json b/bds-tools/package.json index b2dff9d0..03b5fce6 100644 --- a/bds-tools/package.json +++ b/bds-tools/package.json @@ -59,7 +59,6 @@ }, "dependencies": { "@sfmc-bds/sdk": "^0.1.0", - "cli-progress": "^3.12.0", "jszip": "^3.10.1", "node-html-parser": "^9.0.0" }, diff --git a/bds-tools/src/check-update.ts b/bds-tools/src/check-update.ts index 86094f3f..dd9d387e 100644 --- a/bds-tools/src/check-update.ts +++ b/bds-tools/src/check-update.ts @@ -14,6 +14,7 @@ import { createLogger, createStdoutSink, createTerminalProgress, + formatDownloadSpeed, } from "@sfmc-bds/sdk/logs"; import fs from "node:fs"; import os from "node:os"; @@ -317,12 +318,7 @@ export async function runUpdate(): Promise { const timeDelta = (now - lastTime) / 1000; // 秒 const bytesDelta = dl - lastLoaded; const speed = timeDelta > 0 ? bytesDelta / timeDelta : 0; - const speedStr = - speed > 1024 * 1024 - ? `${(speed / 1024 / 1024).toFixed(1)} MB/s` - : speed > 1024 - ? `${(speed / 1024).toFixed(1)} KB/s` - : `${speed.toFixed(0)} B/s`; + const speedStr = formatDownloadSpeed(speed); const totalMb = total > 0 ? total / (1024 * 1024) : 0; const dlMb = dl / (1024 * 1024); const pct = total > 0 ? (dl / total) * 100 : 0; diff --git a/bds-tools/src/pack-manager.ts b/bds-tools/src/pack-manager.ts index 39401024..c6022cc2 100644 --- a/bds-tools/src/pack-manager.ts +++ b/bds-tools/src/pack-manager.ts @@ -12,7 +12,7 @@ * 的一部分内嵌起来,而 SEA 是个单 exe (除 Node 内置 + bds-tools 已有依赖外)。 * * 已有依赖 (bds-tools/package.json): - * - cli-progress: 进度条 + * - @sfmc-bds/sdk/logs createTerminalProgress: 进度条(BDS 更新等) * - node-html-parser: changelog 抓取 (本文件用不到) * - jszip: zip 解压(模块 zip / BDS server.zip),与 sfmc/wizard 共享同一个库 * diff --git a/configs-default/pack-update.json b/configs-default/pack-update.json index 9b656b73..085ce0b9 100644 --- a/configs-default/pack-update.json +++ b/configs-default/pack-update.json @@ -16,8 +16,6 @@ "pageSize": 10, "preferredReleaseTypes": ["release", "beta", "alpha"], "match": { - "byUuidInArchive": true, - "byName": true, "nameMinScore": 0.6, "stripFolderTags": true } diff --git a/docs/guide/pack-update.md b/docs/guide/pack-update.md index a5c22dfb..5604aa5c 100644 --- a/docs/guide/pack-update.md +++ b/docs/guide/pack-update.md @@ -99,8 +99,6 @@ flowchart TD "pageSize": 10, "preferredReleaseTypes": ["release", "beta", "alpha"], "match": { - "byUuidInArchive": true, - "byName": true, "nameMinScore": 0.6, "stripFolderTags": true } @@ -129,6 +127,7 @@ flowchart TD | `baseUrl` | 官方 Core API:getMod / files / download-url。 | | `searchBaseUrl` | 搜索镜像;官方 search 403 时回退。 | | `nameMinScore` | 安装后自动绑定的最低相似度阈值。 | +| `stripFolderTags` | 清洗时是否去掉方括号标签(如 `[BP]`/`[玩法]`)。 | ### 3.4 绑定文件示例 diff --git a/modules/sdk/@sfmc-sdk/src/logs/index.ts b/modules/sdk/@sfmc-sdk/src/logs/index.ts index 91082836..17b5ca81 100644 --- a/modules/sdk/@sfmc-sdk/src/logs/index.ts +++ b/modules/sdk/@sfmc-sdk/src/logs/index.ts @@ -51,6 +51,7 @@ export { resumeAllProgress, withProgressPaused, hasActiveProgress, + formatDownloadSpeed, } from "./terminal-progress.js"; export type { TerminalProgressOptions, diff --git a/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts b/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts index 0a663e8b..c0d2ad93 100644 --- a/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts +++ b/modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts @@ -92,6 +92,14 @@ function isWritableTty(stream: NodeJS.WritableStream): boolean { return !!(stream as WriteStream).isTTY; } +/** 下载速度文案(BDS / CF 等共用,避免各处复制阈值分支) */ +export function formatDownloadSpeed(bytesPerSec: number): string { + const speed = Number.isFinite(bytesPerSec) ? Math.max(0, bytesPerSec) : 0; + if (speed > 1024 * 1024) return `${(speed / 1024 / 1024).toFixed(1)} MB/s`; + if (speed > 1024) return `${(speed / 1024).toFixed(1)} KB/s`; + return `${speed.toFixed(0)} B/s`; +} + function renderBarLine( value: number, total: number, diff --git a/package-lock.json b/package-lock.json index 4ff99603..e0844fc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,6 @@ "license": "ISC", "dependencies": { "@sfmc-bds/sdk": "^0.1.0", - "cli-progress": "^3.12.0", "jszip": "^3.10.1", "node-html-parser": "^9.0.0" }, @@ -80,6 +79,7 @@ "modules/packages/afk": { "name": "@sfmc-bds/module-afk", "version": "0.1.0", + "extraneous": true, "dependencies": { "@sfmc-bds/sdk": "*" } @@ -823,10 +823,6 @@ "resolved": "modules/sdk/@sfmc-eslint-plugin", "link": true }, - "node_modules/@sfmc-bds/module-afk": { - "resolved": "modules/packages/afk", - "link": true - }, "node_modules/@sfmc-bds/qq-bridge": { "resolved": "qq-bridge", "link": true @@ -2001,15 +1997,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", @@ -2140,18 +2127,6 @@ "node": ">= 6" } }, - "node_modules/cli-progress": { - "version": "3.12.0", - "resolved": "https://registry.npmmirror.com/cli-progress/-/cli-progress-3.12.0.tgz", - "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2278,12 +2253,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", @@ -2747,15 +2716,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", @@ -3487,32 +3447,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", @@ -3750,7 +3684,6 @@ "@sfmc-bds/bds-tools": "^0.1.0", "@sfmc-bds/sdk": "^0.1.0", "chalk": "^5.4.1", - "cli-progress": "^3.12.0", "jsonc-parser": "^3.3.1", "ws": "^8.21.1" }, diff --git a/sfmc/pack-update-policy.test.mjs b/sfmc/pack-update-policy.test.mjs index 050a3655..02e5f577 100644 --- a/sfmc/pack-update-policy.test.mjs +++ b/sfmc/pack-update-policy.test.mjs @@ -1,252 +1,30 @@ /** - * version-policy / slug 匹配测试(无构建依赖) + * version-policy / slug 匹配测试 + * 从 dist 导入权威实现,禁止再内联复制(DRY / LSP:测生产契约)。 + * 需先 `npm run build -w @sfmc-bds/cli`。 */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; - -function compareSemVer3(a, b) { - for (let i = 0; i < 3; i++) { - const d = (a[i] ?? 0) - (b[i] ?? 0); - if (d !== 0) return d > 0 ? 1 : -1; - } - return 0; -} - -function decideVersionPolicy(localBp, remoteBp, policy) { - const remoteNewer = compareSemVer3(remoteBp, localBp) > 0; - const majorHigher = remoteBp[0] > localBp[0]; - let shouldBumpRp = false; - if (remoteNewer && policy.onUpdateOverwriteBoth) { - if (majorHigher && policy.majorHigherSkipRpBump) shouldBumpRp = false; - else if (policy.rpBumpWhenSameMajor) shouldBumpRp = true; - } - return { remoteNewer, majorHigher, shouldBumpRp }; -} - -function nextVersionGreaterThan(current, floor, component = "patch") { - let next = - component === "minor" - ? [current[0], current[1] + 1, 0] - : [current[0], current[1], current[2] + 1]; - while (compareSemVer3(next, floor) <= 0) { - if (component === "minor") next = [next[0], next[1] + 1, 0]; - else next = [next[0], next[1], next[2] + 1]; - } - return next; -} - -const PACK_ROLE_TOKEN = - /^(?:bp|rp|ba|behavior|behaviours?|resource|resources|pack|packs|addon|addons|mcpack|mcaddon|behavior.?pack|resource.?pack|行为包|资源包|附加包|基岩|bedrock)$/iu; - -function insertCjkLatinBoundaries(name) { - return String(name ?? "") - .replace(/([\u3400-\u9fff\uF900-\uFAFF])([A-Za-z])/g, "$1 $2") - .replace(/([A-Za-z])([\u3400-\u9fff\uF900-\uFAFF])/g, "$1 $2"); -} - -function stripPackRoleTokens(name) { - return String(name ?? "") - .split(/[\s_]+/) - .filter((t) => t && !PACK_ROLE_TOKEN.test(t)) - .join(" ") - .replace(/\s+/g, " ") - .trim(); -} - -function stripVersionTokens(name) { - return String(name ?? "") - .replace(/\bv\d+(?:\.\d+)*\b/gi, " ") - .replace(/\b\d+\.\d+(?:\.\d+)?\b/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function extractLatinPhrase(name) { - const words = String(name ?? "").match(/[A-Za-z][A-Za-z0-9]*/g); - return words ? words.join(" ") : ""; -} - -function normalizePackSearchName(name, stripFolderTags = true) { - let s = String(name ?? ""); - s = s.replace(/§[0-9a-zA-Z]/g, ""); - if (stripFolderTags) s = s.replace(/\[[^\]]*]/g, " "); - s = s.replace(/\.(zip|mcpack|mcaddon)$/i, ""); - s = insertCjkLatinBoundaries(s); - s = s.replace(/[_]+/g, " ").replace(/\s+/g, " ").trim(); - s = stripPackRoleTokens(s); - s = stripVersionTokens(s); - return s; -} - -function collectQuerySeeds(raw, stripFolderTags = true) { - const seeds = []; - const push = (q) => { - const t = q.replace(/\s+/g, " ").trim(); - if (!t) return; - if (!seeds.some((x) => x.toLowerCase() === t.toLowerCase())) seeds.push(t); - }; - - let s = String(raw ?? ""); - s = s.replace(/§[0-9a-zA-Z]/g, ""); - if (stripFolderTags) s = s.replace(/\[[^\]]*]/g, " "); - s = s.replace(/\.(zip|mcpack|mcaddon)$/i, ""); - s = insertCjkLatinBoundaries(s); - s = s.replace(/[_]+/g, " ").replace(/\s+/g, " ").trim(); - push(s); - - const noRole = stripPackRoleTokens(s); - push(noRole); - push(stripVersionTokens(s)); - push(stripVersionTokens(noRole)); - - const latin = extractLatinPhrase(s); - push(latin); - const latinNoRole = stripPackRoleTokens(latin); - push(latinNoRole); - const latinCore = stripVersionTokens(latinNoRole); - push(latinCore); - - let tokens = latinCore.split(/\s+/).filter(Boolean); - while (tokens.length >= 2) { - push(tokens.join(" ")); - tokens = tokens.slice(0, -1); - } - - push(normalizePackSearchName(raw, stripFolderTags)); - return seeds; -} - -function toCfSlugCandidate(name, stripFolderTags = true) { - return normalizePackSearchName(name, stripFolderTags) - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .replace(/-+/g, "-"); -} - -function toCfTitleSlug(slug) { - return String(slug ?? "") - .split("-") - .filter(Boolean) - .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) - .join("-"); -} - -function buildSearchQueries(name, stripFolderTags = true, maxQueries = 14) { - const inputs = (Array.isArray(name) ? name : [name]) - .map((x) => String(x ?? "").trim()) - .filter(Boolean) - .map((raw, i) => ({ id: `src${i}`, raw })); - return buildSearchQueriesFromSources(inputs, stripFolderTags, maxQueries).queries; -} - -function buildSearchQueriesFromSources(sources, stripFolderTags = true, maxQueries = 14) { - const cleaned = sources - .map((s) => ({ id: s.id, raw: String(s.raw ?? "").trim() })) - .filter((s) => s.raw); - - const hasCjk = (q) => /[\u3400-\u9fff]/.test(q); - const hasLatin = (q) => /[A-Za-z]/.test(q); - const kindRank = (q) => { - if (/^[a-z0-9]+(?:-[a-z0-9]+)*-addon$/.test(q)) return 0; - if (/^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(q)) return 1; - if (/^[A-Z][a-z0-9]*(?:-[A-Z][a-z0-9]*)+$/.test(q)) return 2; - if (hasCjk(q) && !hasLatin(q)) return 5; - if (hasCjk(q)) return 4; - return 3; - }; - const sortQueries = (list) => - [...list].sort((a, b) => { - const ka = kindRank(a); - const kb = kindRank(b); - if (ka !== kb) return ka - kb; - const ta = a.split(/[\s\-]+/).filter(Boolean).length; - const tb = b.split(/[\s\-]+/).filter(Boolean).length; - if (ta !== tb) return ta - tb; - return a.length - b.length; - }); - - const expandOne = (raw) => { - const out = []; - const push = (q) => { - const t = q.trim(); - if (!t) return; - if (!out.some((x) => x.toLowerCase() === t.toLowerCase())) out.push(t); - }; - for (const seed of collectQuerySeeds(raw, stripFolderTags)) { - push(seed); - const slug = toCfSlugCandidate(seed, false); - push(slug); - if (slug) { - push(toCfTitleSlug(slug)); - if (!slug.endsWith("-addon")) push(`${slug}-addon`); - } - } - return sortQueries(out); - }; - - const perSource = {}; - const perSourcePreferred = []; - for (const src of cleaned) { - const all = expandOne(src.raw); - const latinish = all.filter((q) => hasLatin(q)); - const preferred = latinish.length > 0 ? latinish : all; - perSource[src.id] = preferred; - perSourcePreferred.push(preferred); - } - - const merged = []; - const pushMerged = (q) => { - if (!q) return; - if (merged.some((x) => x.toLowerCase() === q.toLowerCase())) return; - merged.push(q); - }; - const maxLen = Math.max(0, ...perSourcePreferred.map((x) => x.length)); - for (let i = 0; i < maxLen; i++) { - for (const list of perSourcePreferred) { - if (i < list.length) pushMerged(list[i]); - } - } - - const anyLatin = merged.some((q) => hasLatin(q)); - const filtered = anyLatin ? merged.filter((q) => hasLatin(q)) : merged; - const queries = sortQueries(filtered).slice(0, Math.max(1, maxQueries)); - return { queries, sources: cleaned, perSource }; -} - -function nameSimilarity(a, b) { - const na = normalizePackSearchName(a).toLowerCase(); - const nb = normalizePackSearchName(b).toLowerCase(); - if (!na || !nb) return 0; - if (na === nb) return 1; - if (na.includes(nb) || nb.includes(na)) return 0.85; - const ta = new Set(na.split(/[\s_\-]+/).filter(Boolean)); - const tb = new Set(nb.split(/[\s_\-]+/).filter(Boolean)); - if (ta.size === 0 || tb.size === 0) return 0; - let inter = 0; - for (const t of ta) if (tb.has(t)) inter++; - return inter / Math.max(ta.size, tb.size); -} - -function packSourceScore(query, hit, stripFolderTags = true) { - const byName = nameSimilarity(query, hit.name); - const qSlug = toCfSlugCandidate(query, stripFolderTags); - const hSlug = String(hit.slug ?? "").toLowerCase(); - let bySlug = 0; - if (qSlug && hSlug) { - if (hSlug === qSlug) bySlug = 1; - else if (hSlug === `${qSlug}-addon` || hSlug.startsWith(`${qSlug}-`)) bySlug = 0.96; - else if (hSlug.includes(qSlug) || qSlug.includes(hSlug)) bySlug = 0.88; - else bySlug = nameSimilarity(qSlug.replace(/-/g, " "), hSlug.replace(/-/g, " ")); - } - return Math.max(byName, bySlug); -} +import { + buildSearchQueries, + buildSearchQueriesFromSources, + collectQuerySeeds, + decideVersionPolicy, + extractLatinPhrase, + insertCjkLatinBoundaries, + nextVersionGreaterThan, + normalizePackSearchName, + packSourceScore, + toCfSlugCandidate, +} from "./dist/pack-update/version-policy.js"; describe("version-policy", () => { it("同 major 更新应 bump RP", () => { const d = decideVersionPolicy([1, 21, 100], [1, 21, 110], { + authority: "behavior_pack", onUpdateOverwriteBoth: true, rpBumpWhenSameMajor: true, + rpBumpComponent: "patch", majorHigherSkipRpBump: true, }); assert.equal(d.remoteNewer, true); @@ -255,8 +33,10 @@ describe("version-policy", () => { it("major 更高则不额外 bump RP", () => { const d = decideVersionPolicy([1, 21, 100], [2, 0, 0], { + authority: "behavior_pack", onUpdateOverwriteBoth: true, rpBumpWhenSameMajor: true, + rpBumpComponent: "patch", majorHigherSkipRpBump: true, }); assert.equal(d.majorHigher, true); @@ -275,7 +55,10 @@ describe("version-policy", () => { it("CJK 粘连分界并提取拉丁核心", () => { const folder = "[BP] [BA] [玩法] 拔刀剑Slash Blade v4 BP"; assert.equal(normalizePackSearchName(folder), "拔刀剑 Slash Blade"); - assert.equal(extractLatinPhrase(insertCjkLatinBoundaries(folder.replace(/\[[^\]]*\]/g, " "))), "Slash Blade v4 BP"); + assert.equal( + extractLatinPhrase(insertCjkLatinBoundaries(folder.replace(/\[[^\]]*\]/g, " "))), + "Slash Blade v4 BP" + ); const seeds = collectQuerySeeds(folder); assert.ok(seeds.includes("Slash Blade v4 BP")); assert.ok(seeds.includes("Slash Blade v4")); diff --git a/sfmc/package.json b/sfmc/package.json index 724dfdd8..897b47d8 100644 --- a/sfmc/package.json +++ b/sfmc/package.json @@ -45,7 +45,7 @@ "start": "node ./dist/main.js", "build": "node ../scripts/esbuild-transpile.mjs --dts", "typecheck": "tsc7 --noEmit -p tsconfig.json", - "test": "node --test pack-update-policy.test.mjs terminal-progress.test.mjs", + "test": "npm run build && node --test pack-update-policy.test.mjs terminal-progress.test.mjs", "prepublishOnly": "npm run build" }, "dependencies": { @@ -53,7 +53,6 @@ "@sfmc-bds/sdk": "^0.1.0", "@clack/prompts": "^1.7.0", "chalk": "^5.4.1", - "cli-progress": "^3.12.0", "jsonc-parser": "^3.3.1", "ws": "^8.21.1" }, diff --git a/sfmc/src/pack-update/config.ts b/sfmc/src/pack-update/config.ts index 3ca41d61..e9189b43 100644 --- a/sfmc/src/pack-update/config.ts +++ b/sfmc/src/pack-update/config.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { ROOT, resolveDefaultsDir, seedMissingConfigsFromDefaults } from "../runtime.js"; -import type { PackUpdateConfig } from "./types.js"; +import type { PackUpdateConfig, PackUpdateMatchConfig } from "./types.js"; const DEFAULTS: PackUpdateConfig = { enabled: true, @@ -23,8 +23,6 @@ const DEFAULTS: PackUpdateConfig = { pageSize: 10, preferredReleaseTypes: ["release", "beta", "alpha"], match: { - byUuidInArchive: true, - byName: true, nameMinScore: 0.6, stripFolderTags: true, }, @@ -95,9 +93,19 @@ export function loadPackUpdateConfig(): PackUpdateConfig { merged.providers.curseforge.apiKey = envKey; } + /* 兼容旧配置里未接线的 byUuidInArchive/byName,避免 deepMerge 残留脏字段影响契约 */ + const match = merged.providers.curseforge.match as PackUpdateMatchConfig & Record; + delete match.byUuidInArchive; + delete match.byName; + return merged; } +/** 匹配策略访问器:编排层勿直接挖 providers.curseforge.match(Demeter) */ +export function getPackMatchConfig(cfg: PackUpdateConfig): PackUpdateMatchConfig { + return cfg.providers.curseforge.match; +} + /** * 确保 configs/pack-update.json 存在。 * 优先走与其它配置相同的 configs-default 播种;若仍缺失则写内置 DEFAULTS。 diff --git a/sfmc/src/pack-update/providers/curseforge.ts b/sfmc/src/pack-update/providers/curseforge.ts index 503ba21a..c1e644ff 100644 --- a/sfmc/src/pack-update/providers/curseforge.ts +++ b/sfmc/src/pack-update/providers/curseforge.ts @@ -5,7 +5,7 @@ * - 搜索:优先官方;若 403 则回退 api.curse.tools(部分 key 对 /v1/mods/search 被拒) * - Bedrock gameId = 78022,Addons classId = 4984 */ -import { createTerminalProgress } from "@sfmc-bds/sdk/logs"; +import { createTerminalProgress, formatDownloadSpeed } from "@sfmc-bds/sdk/logs"; import fs from "node:fs"; import path from "node:path"; import type { @@ -290,12 +290,7 @@ export class CurseForgeBedrockProvider implements PackSourceProvider { const now = Date.now(); const dt = (now - lastTime) / 1000; const speed = dt > 0 ? (loaded - lastLoaded) / dt : 0; - const speedStr = - speed > 1024 * 1024 - ? `${(speed / 1024 / 1024).toFixed(1)} MB/s` - : speed > 1024 - ? `${(speed / 1024).toFixed(1)} KB/s` - : `${speed.toFixed(0)} B/s`; + const speedStr = formatDownloadSpeed(speed); bar.update(loaded / (1024 * 1024), { speed: speedStr }); onProgress?.(loaded, total); if (dt >= 0.25) { diff --git a/sfmc/src/pack-update/providers/index.ts b/sfmc/src/pack-update/providers/index.ts new file mode 100644 index 00000000..3764da4e --- /dev/null +++ b/sfmc/src/pack-update/providers/index.ts @@ -0,0 +1,12 @@ +/** + * 源提供者注册表:按配置构造 PackSourceProvider(OCP:新源加分支/注册,不改编排层)。 + */ +import type { PackSourceProvider, PackUpdateConfig } from "../types.js"; +import { CurseForgeBedrockProvider } from "./curseforge.js"; + +export function createPackSourceProvider(cfg: PackUpdateConfig): PackSourceProvider { + /* 当前仅 curseforge;后续源在此扩展,service 只依赖 PackSourceProvider */ + return new CurseForgeBedrockProvider(cfg.providers.curseforge); +} + +export { CurseForgeBedrockProvider }; diff --git a/sfmc/src/pack-update/service.ts b/sfmc/src/pack-update/service.ts index 3c988e54..370484de 100644 --- a/sfmc/src/pack-update/service.ts +++ b/sfmc/src/pack-update/service.ts @@ -24,23 +24,81 @@ import { pushLog } from "../logs.js"; import { resolveBdsContext } from "../pack-lifecycle.js"; import { c } from "../theme.js"; import { getBinding, listBindings, packSourcesPath, setBinding } from "./bindings.js"; -import { ensurePackUpdateConfigFile, loadPackUpdateConfig, packUpdateConfigPath } from "./config.js"; -import { CurseForgeBedrockProvider } from "./providers/curseforge.js"; -import type { PackSourceBinding, PackUpdateConfig, SemVer3, SourceSearchHit } from "./types.js"; -import { decideVersionPolicy, buildSearchQueries, buildSearchQueriesFromSources, packSourceScore } from "./version-policy.js"; +import { + ensurePackUpdateConfigFile, + getPackMatchConfig, + loadPackUpdateConfig, + packUpdateConfigPath, +} from "./config.js"; +import { createPackSourceProvider } from "./providers/index.js"; +import type { + PackProviderId, + PackSourceBinding, + PackSourceProvider, + PackUpdateConfig, + SemVer3, + SourceSearchHit, +} from "./types.js"; +import { + decideVersionPolicy, + buildSearchQueries, + buildSearchQueriesFromSources, + packSourceScore, +} from "./version-policy.js"; function logPack(text: string, level: "info" | "warn" | "error" | "success" = "info"): void { pushLog(text, "pack", level); } -function getProvider(cfg: PackUpdateConfig): CurseForgeBedrockProvider { - return new CurseForgeBedrockProvider(cfg.providers.curseforge); +function getProvider(cfg: PackUpdateConfig): PackSourceProvider { + return createPackSourceProvider(cfg); } function fmtVer(v: SemVer3): string { return v.join("."); } +function providerShortLabel(id: PackProviderId): string { + return id === "curseforge" ? "cf" : id; +} + +/** 多查询搜索 + 综合打分排序(probe / searchRemote 共用) */ +async function searchAndRankHits( + provider: PackSourceProvider, + queries: string[], + stripFolderTags: boolean +): Promise> { + const byId = new Map(); + for (const q of queries) { + const hits = await provider.search(q); + for (const h of hits) byId.set(h.projectId, h); + } + return [...byId.values()] + .map((h) => ({ + hit: h, + score: Math.max(...queries.map((q) => packSourceScore(q, h, stripFolderTags))), + })) + .sort((a, b) => b.score - a.score); +} + +function makeBindingFromHit( + hit: SourceSearchHit, + pairedResourceUuid: string | null, + prev?: PackSourceBinding | null +): PackSourceBinding { + return { + enabled: true, + provider: hit.provider, + projectId: hit.projectId, + slug: hit.slug, + websiteUrl: hit.websiteUrl, + pairedResourceUuid, + lastFileId: prev?.lastFileId ?? null, + lastCheckedAt: null, + lastAppliedFileId: prev?.lastAppliedFileId ?? null, + }; +} + /** 从已安装 BP 提取配对 RP uuid */ export function pairedRpUuidFromBpDir(bpDir: string): string | null { const deps = readPackDependencyUuids(bpDir); @@ -65,7 +123,7 @@ export async function probeSourceAfterInstall(opts: { return; } - const strip = cfg.providers.curseforge.match.stripFolderTags; + const match = getPackMatchConfig(cfg); /* header 常本地化;文件夹名往往含拉丁核心 — 两源分别派生再合并,有拉丁则不搜纯中文 */ const folderName = (opts.folderName && String(opts.folderName).trim()) || @@ -75,7 +133,7 @@ export async function probeSourceAfterInstall(opts: { { id: "header", raw: opts.info.name }, { id: "folder", raw: folderName }, ], - strip + match.stripFolderTags ); const queries = plan.queries; if (queries.length === 0) { @@ -92,33 +150,22 @@ export async function probeSourceAfterInstall(opts: { "info" ); - const byId = new Map(); + let scored: Array<{ hit: SourceSearchHit; score: number }>; try { - for (const q of queries) { - const hits = await provider.search(q); - for (const h of hits) byId.set(h.projectId, h); - } + scored = await searchAndRankHits(provider, queries, match.stripFolderTags); } catch (e) { logPack(t("packUpdate.probeFail", { message: (e as Error).message }), "warn"); return; } - const hitList = [...byId.values()]; - if (hitList.length === 0) { + if (scored.length === 0) { logPack(t("packUpdate.probeMiss", { name: queries.join(", ") }), "info"); return; } const primary = queries.find((q) => /[A-Za-z]/.test(q)) ?? queries[0]!; - const scored = hitList - .map((h) => ({ - hit: h, - score: Math.max(...queries.map((q) => packSourceScore(q, h, strip))), - })) - .sort((a, b) => b.score - a.score); const best = scored[0]!; - const minScore = cfg.providers.curseforge.match.nameMinScore; - if (best.score < minScore) { + if (best.score < match.nameMinScore) { logPack( t("packUpdate.probeLowScore", { name: primary, @@ -163,18 +210,7 @@ export async function probeSourceAfterInstall(opts: { } const paired = (opts.packDir ? pairedRpUuidFromBpDir(opts.packDir) : null) ?? null; - const binding: PackSourceBinding = { - enabled: true, - provider: "curseforge", - projectId: best.hit.projectId, - slug: best.hit.slug, - websiteUrl: best.hit.websiteUrl, - pairedResourceUuid: paired, - lastFileId: null, - lastCheckedAt: null, - lastAppliedFileId: null, - }; - setBinding(opts.info.uuid, binding); + setBinding(opts.info.uuid, makeBindingFromHit(best.hit, paired)); logPack( t("packUpdate.bindOk", { uuid: opts.info.uuid, @@ -191,24 +227,13 @@ export async function searchRemote(query: string): Promise { if (!provider.isConfigured()) { return c.yellow(t("packUpdate.needKey", { path: packUpdateConfigPath() })); } - const strip = cfg.providers.curseforge.match.stripFolderTags; - const queries = buildSearchQueries(query, strip); - const byId = new Map(); - for (const q of queries) { - const hits = await provider.search(q); - for (const h of hits) byId.set(h.projectId, h); - } - const hitList = [...byId.values()]; - if (hitList.length === 0) return c.dim(t("packUpdate.searchEmpty", { query })); - const ranked = hitList - .map((h) => ({ - h, - score: Math.max(...queries.map((q) => packSourceScore(q, h, strip))), - })) - .sort((a, b) => b.score - a.score); + const match = getPackMatchConfig(cfg); + const queries = buildSearchQueries(query, match.stripFolderTags); + const ranked = await searchAndRankHits(provider, queries, match.stripFolderTags); + if (ranked.length === 0) return c.dim(t("packUpdate.searchEmpty", { query })); return ranked .map( - ({ h, score }, i) => + ({ hit: h, score }, i) => `${c.cyan(String(i + 1).padStart(2))}. ${h.name} ${c.dim(`id=${h.projectId} slug=${h.slug} score=${score.toFixed(2)}`)}\n ${h.websiteUrl}` ) .join("\n"); @@ -230,17 +255,7 @@ export async function bindPackSource(packId: string, ref: string): Promise, localBp: InstalledWorld return localBp ? { ...base, localBp } : { ...base }; } +function baseCheckFields( + bpUuid: string, + name: string, + localVer: SemVer3, + binding: PackSourceBinding, + extras: Partial & { message: string } +): Omit { + return { + bpUuid, + name, + localVer, + remoteVer: null, + updateAvailable: false, + majorHigher: false, + shouldBumpRp: false, + fileId: null, + binding, + ...extras, + }; +} + async function prepareCheck( bpUuid: string, binding: PackSourceBinding, @@ -303,18 +342,7 @@ async function prepareCheck( if (!binding.enabled) { return withLocalBp( - { - bpUuid, - name, - localVer, - remoteVer: null, - updateAvailable: false, - majorHigher: false, - shouldBumpRp: false, - fileId: null, - message: t("packUpdate.bindingDisabled"), - binding, - }, + baseCheckFields(bpUuid, name, localVer, binding, { message: t("packUpdate.bindingDisabled") }), localBp ); } @@ -322,18 +350,7 @@ async function prepareCheck( const file = await provider.getLatestFile(binding.projectId); if (!file) { return withLocalBp( - { - bpUuid, - name, - localVer, - remoteVer: null, - updateAvailable: false, - majorHigher: false, - shouldBumpRp: false, - fileId: null, - message: t("packUpdate.noRemoteFile"), - binding, - }, + baseCheckFields(bpUuid, name, localVer, binding, { message: t("packUpdate.noRemoteFile") }), localBp ); } @@ -342,20 +359,13 @@ async function prepareCheck( /* 仅文件 id 比较的轻量检查 */ const updateAvailable = binding.lastAppliedFileId == null || binding.lastAppliedFileId !== file.fileId; return withLocalBp( - { - bpUuid, - name, - localVer, - remoteVer: null, + baseCheckFields(bpUuid, name, localVer, binding, { updateAvailable, - majorHigher: false, - shouldBumpRp: false, fileId: file.fileId, message: updateAvailable ? t("packUpdate.fileNewer", { file: file.fileName, id: String(file.fileId) }) : t("packUpdate.upToDate"), - binding, - }, + }), localBp ); } @@ -375,18 +385,10 @@ async function prepareCheck( /* ignore */ } return withLocalBp( - { - bpUuid, - name, - localVer, - remoteVer: null, - updateAvailable: false, - majorHigher: false, - shouldBumpRp: false, + baseCheckFields(bpUuid, name, localVer, binding, { fileId: file.fileId, message: t("packUpdate.remoteNoBp"), - binding, - }, + }), localBp ); } @@ -429,6 +431,31 @@ function cleanupCheck(r: CheckResult): void { } } +/** 尝试安装配对 RP;accept 为过滤谓词(DRY:两处循环合一) */ +async function tryInstallResourcePack(opts: { + rpRoots: string[]; + bdsRoot: string; + levelName: string; + folderName?: string; + accept: (info: PackManifestInfo) => boolean; +}): Promise<{ info: PackManifestInfo; destDir: string } | null> { + for (const root of opts.rpRoots) { + const info = readPackManifestInfo(root); + if (!info || !opts.accept(info)) continue; + const rpDest = worldPackParentDir(opts.bdsRoot, opts.levelName, "resource"); + const rpInstall = await installPackDirectory({ + srcDir: root, + destParent: rpDest, + force: true, + ...(opts.folderName ? { folderName: opts.folderName } : {}), + }); + if (rpInstall.ok && rpInstall.info && rpInstall.destDir) { + return { info: rpInstall.info, destDir: rpInstall.destDir }; + } + } + return null; +} + async function applyUpdate(r: CheckResult, cfg: PackUpdateConfig): Promise { if (!r.updateAvailable || !r.remoteRoots || !r.remoteBpInfo) { return r.message; @@ -460,47 +487,35 @@ async function applyUpdate(r: CheckResult, cfg: PackUpdateConfig): Promise !wantRp || info.uuid.toLowerCase() === wantRp, + }); + if (installed) { + rpInfo = installed.info; + rpDir = installed.destDir; } - /* 若 binding 无 paired,尝试从新 BP dependency 安装 */ if (!rpInfo && rpRoots.length > 0) { const deps = readPackDependencyUuids(bpInstall.destDir); - for (const root of rpRoots) { - const info = readPackManifestInfo(root); - if (!info) continue; - if (deps.length && !deps.some((d) => d.toLowerCase() === info.uuid.toLowerCase())) continue; - const rpDest = worldPackParentDir(bdsRoot, levelName, "resource"); - const rpInstall = await installPackDirectory({ - srcDir: root, - destParent: rpDest, - force: true, - }); - if (rpInstall.ok && rpInstall.info && rpInstall.destDir) { - rpInfo = rpInstall.info; - rpDir = rpInstall.destDir; - r.binding.pairedResourceUuid = info.uuid; - break; - } + const fallback = await tryInstallResourcePack({ + rpRoots, + bdsRoot, + levelName, + accept: (info) => + deps.length === 0 || deps.some((d) => d.toLowerCase() === info.uuid.toLowerCase()), + }); + if (fallback) { + rpInfo = fallback.info; + rpDir = fallback.destDir; + r.binding.pairedResourceUuid = fallback.info.uuid; } } @@ -595,5 +610,5 @@ export function bindingLabelForUuid(uuid: string): string { const b = getBinding(uuid); if (!b) return "src=-"; if (!b.enabled) return "src=off"; - return `src=cf:${b.slug || b.projectId}`; + return `src=${providerShortLabel(b.provider)}:${b.slug || b.projectId}`; } diff --git a/sfmc/src/pack-update/types.ts b/sfmc/src/pack-update/types.ts index 4fd5dc24..59ecc269 100644 --- a/sfmc/src/pack-update/types.ts +++ b/sfmc/src/pack-update/types.ts @@ -4,9 +4,14 @@ export type PackReleaseType = "release" | "beta" | "alpha"; +/** 已实现的源提供者 id(扩展时只加字面量,编排层走 PackSourceProvider) */ +export type PackProviderId = "curseforge"; + +/** + * 名称/slug 匹配策略。 + * 探测与 search 共用;勿再加未接线的开关(曾出现 byUuidInArchive/byName 死配置)。 + */ export interface PackUpdateMatchConfig { - byUuidInArchive: boolean; - byName: boolean; nameMinScore: number; stripFolderTags: boolean; } @@ -60,7 +65,7 @@ export interface PackUpdateConfig { export interface PackSourceBinding { enabled: boolean; - provider: "curseforge"; + provider: PackProviderId; projectId: number; slug: string; websiteUrl: string; @@ -75,7 +80,7 @@ export interface PackSourcesFile { } export interface SourceSearchHit { - provider: "curseforge"; + provider: PackProviderId; projectId: number; slug: string; name: string; @@ -85,7 +90,7 @@ export interface SourceSearchHit { } export interface SourceFileRef { - provider: "curseforge"; + provider: PackProviderId; projectId: number; fileId: number; fileName: string; @@ -95,8 +100,9 @@ export interface SourceFileRef { releaseType?: PackReleaseType; } +/** 源提供者契约:编排层只依赖此接口(DIP),具体实现可互换(LSP) */ export interface PackSourceProvider { - readonly id: "curseforge"; + readonly id: PackProviderId; isConfigured(): boolean; search(query: string): Promise; getLatestFile(projectId: number): Promise; diff --git a/sfmc/src/pack-update/version-policy.ts b/sfmc/src/pack-update/version-policy.ts index 7c2b7377..36b887b7 100644 --- a/sfmc/src/pack-update/version-policy.ts +++ b/sfmc/src/pack-update/version-policy.ts @@ -91,10 +91,10 @@ export function extractLatinPhrase(name: string): string { } /** - * 基础清洗:§码、方括号标签、扩展名、CJK/拉丁分界、包角色、版本号。 - * 例:"[BP] [BA] [玩法] 拔刀剑Slash Blade v4 BP" → "拔刀剑 Slash Blade" + * 共享预处理:§码、方括号标签、扩展名、CJK/拉丁分界、下划线→空格。 + * normalize / collectQuerySeeds 共用,避免两处各写一遍(DRY)。 */ -export function normalizePackSearchName(name: string, stripFolderTags = true): string { +export function preprocessPackNameRaw(name: string, stripFolderTags = true): string { let s = String(name ?? ""); s = s.replace(/§[0-9a-zA-Z]/g, ""); if (stripFolderTags) { @@ -103,6 +103,15 @@ export function normalizePackSearchName(name: string, stripFolderTags = true): s s = s.replace(/\.(zip|mcpack|mcaddon)$/i, ""); s = insertCjkLatinBoundaries(s); s = s.replace(/[_]+/g, " ").replace(/\s+/g, " ").trim(); + return s; +} + +/** + * 基础清洗:预处理 + 包角色 + 版本号。 + * 例:"[BP] [BA] [玩法] 拔刀剑Slash Blade v4 BP" → "拔刀剑 Slash Blade" + */ +export function normalizePackSearchName(name: string, stripFolderTags = true): string { + let s = preprocessPackNameRaw(name, stripFolderTags); s = stripPackRoleTokens(s); s = stripVersionTokens(s); return s; @@ -121,12 +130,7 @@ export function collectQuerySeeds(raw: string, stripFolderTags = true): string[] if (!seeds.some((x) => x.toLowerCase() === t.toLowerCase())) seeds.push(t); }; - let s = String(raw ?? ""); - s = s.replace(/§[0-9a-zA-Z]/g, ""); - if (stripFolderTags) s = s.replace(/\[[^\]]*]/g, " "); - s = s.replace(/\.(zip|mcpack|mcaddon)$/i, ""); - s = insertCjkLatinBoundaries(s); - s = s.replace(/[_]+/g, " ").replace(/\s+/g, " ").trim(); + const s = preprocessPackNameRaw(raw, stripFolderTags); push(s); const noRole = stripPackRoleTokens(s);