From 7aa8f88364428a5794476266a23e1121d8136730 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 17 Oct 2026 21:31:12 +0800 Subject: [PATCH 1/2] fix(data): merge product object roles and read localized live parts Combine compatible file roles from shared objects and reject conflicting metadata or file entries. Support region directories in product paths. Keep language-indexed UI resources separate from shared live part paths. --- docs/data-format.md | 7 ++++ src/data/products.js | 55 +++++++++++++++++++++++++++ src/live/data.js | 3 +- src/live/store.js | 33 ++-------------- tests/unit/live-store.test.mjs | 22 +++++++++-- tests/unit/product-manifests.test.mjs | 43 +++++++++++++++++++++ 6 files changed, 129 insertions(+), 34 deletions(-) create mode 100644 src/data/products.js create mode 100644 tests/unit/product-manifests.test.mjs diff --git a/docs/data-format.md b/docs/data-format.md index 5d98c7e..b24b525 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -119,6 +119,10 @@ Rules: ### Objects +Several products may retain different file roles of the same object. Loading their manifests combines these +roles; overlapping roles and object metadata must agree. Product paths may include region directories, and are +relative to the layout root. + An object is one Unity object, converted. Its id is `:` (the path id as decimal digits, possibly negative), the same in every product that uses it. An object's JSON names another object by a reference `{ "$ref": { "file": "", "pathId": "" } }`, the object `:`; `null` names none. @@ -180,6 +184,9 @@ A chart manifest of a [chart site](#chart-sites) is read as a live as well. | `notesMirror` | Optional. The score converted mirrored (the game's `MirrorChart` option: lanes and flick directions mirrored while the score is converted), same format as `notes`. Without it the `MirrorChart` option is not offered. | | `parts` | Part → the manifest path of the product, relative to the root ([Lives](#lives)). | +The optional `live.json` field `parts.ui` maps language codes to UI product manifests. These entries are +language-specific resources; the shared scene, score and audio parts remain ordinary manifest paths. + ## Score The file `live.json` `notes` names ([schema](../schema/score.schema.json)): the notes and lines of one chart, as the diff --git a/src/data/products.js b/src/data/products.js new file mode 100644 index 0000000..7a6c831 --- /dev/null +++ b/src/data/products.js @@ -0,0 +1,55 @@ +import { PRODUCT_MANIFEST } from "./assets.js"; + +// The directory holding a product's files within an AssetStore. +export const productDir = (manifestPath) => { + const p = String(manifestPath), parts = p.split("/"); + if (parts.length < 2 || parts.some((s) => !s || s === "." || s === "..") || !p.endsWith(".json") || /[?#\\]/.test(p)) + throw new Error(`${p}: not a product manifest path /.json`); + return p.slice(0, -".json".length); +}; + +export const isProduct = (m, product) => !!m && m.schema === PRODUCT_MANIFEST && m.product === product + && !!m.files && typeof m.files === "object" && !Array.isArray(m.files) + && !!m.objects && typeof m.objects === "object" && !Array.isArray(m.objects); + +const same = (a, b) => { + if (a === b) return true; + if (!a || !b || typeof a !== "object" || typeof b !== "object" || Array.isArray(a) !== Array.isArray(b)) return false; + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && same(a[k], b[k])); +}; + +// Merge compatible object roles. Each product may retain a different subset of one object's files. +export const mergeProductManifests = (loaded) => { + if (!loaded.length) throw new Error("no product manifests"); + const files = {}, objects = {}, products = {}; + for (const [dir, m] of loaded) { + const subjects = (products[m.product] ||= {}); + if (m.subject in subjects) { + if (subjects[m.subject] !== dir) throw new Error(`${m.product} ${m.subject}: two manifests (${subjects[m.subject]}, ${dir})`); + } + subjects[m.subject] = dir; + for (const [p, f] of Object.entries(m.files)) { + const path = `${dir}/${p}`; + if (Object.hasOwn(files, path) && !same(files[path], f)) throw new Error(`${path}: differing product files`); + files[path] = f; + } + for (const [id, o] of Object.entries(m.objects)) { + const target = (objects[id] ||= { files: {} }); + for (const [key, value] of Object.entries(o)) { + if (key === "files") continue; + if (Object.hasOwn(target, key) && !same(target[key], value)) + throw new Error(`object ${id} differs between the manifests of the products (${key})`); + target[key] = value; + } + for (const [role, file] of Object.entries(o.files || {})) { + if (Object.hasOwn(target.files, role) && !same(target.files[role], file)) + throw new Error(`object ${id} differs between the manifests of the products (${role})`); + target.files[role] = file; + } + } + } + const [dir, man] = loaded[0]; + const { files: _f, objects: _o, ...info } = man; + return { ...info, dir, products, files, objects }; +}; diff --git a/src/live/data.js b/src/live/data.js index 36d94c3..14b9848 100644 --- a/src/live/data.js +++ b/src/live/data.js @@ -27,7 +27,8 @@ export class LiveData { this.dir = info.dir; this.products = info.products; this.live = checkFormat(assets.json(`${this.dir}/live.json`), LIVE_JSON_FORMAT, `${this.dir}/live.json`); - this.parts = Object.fromEntries(Object.entries(this.live.parts).map(([part, p]) => [part, productDir(p)])); + this.parts = Object.fromEntries(Object.entries(this.live.parts).filter(([, p]) => typeof p === "string") + .map(([part, p]) => [part, productDir(p)])); this._docs = new Map(); } diff --git a/src/live/store.js b/src/live/store.js index 7f4f345..7d10efd 100644 --- a/src/live/store.js +++ b/src/live/store.js @@ -1,4 +1,6 @@ import { AssetStore, PRODUCT_MANIFEST, decodeAsset } from "../data/assets.js"; +import { isProduct, mergeProductManifests, productDir } from "../data/products.js"; +export { productDir } from "../data/products.js"; // Loading a live: the product manifest of the live (lives/_.json, the `live` product of nnnotes' // product layout), the manifests of the products its live.json `parts` names, and any further products of note skins, @@ -17,16 +19,6 @@ export const LIVE_VARIANTS = Object.freeze(["live.noteskin", "live.noteeffect", const here = () => (globalThis.document ? globalThis.document.baseURI : globalThis.location ? globalThis.location.href : undefined); -// the directory of a product's files in a live store: its manifest path without ".json" -export const productDir = (manifestPath) => { - const p = String(manifestPath); - if (!/^[^/]+\/[^/]+\.json$/.test(p)) throw new Error(`${p}: not a product manifest path /.json`); - return p.slice(0, -".json".length); -}; - -const isProduct = (m, product) => !!m && m.schema === PRODUCT_MANIFEST && m.product === product - && !!m.files && typeof m.files === "object" && !!m.objects && typeof m.objects === "object"; - // A chart manifest of the site layout (docs/data-format.md "Chart manifest"): no `schema`, a `live.json` file. Its // store is read through legacy.js. export const isChartManifest = (m) => !!m && m.schema === undefined && !!m.files && !!m.files["live.json"]; @@ -86,23 +78,4 @@ export const loadLiveStore = async (url, { variants = [], fetch = globalThis.fet // first: the live manifest without its file lists, `dir` and `products` (as the store's `info` of loadLiveStore), every // product's files under its directory and the objects of all of them in one table (an object in several manifests // must be the same in each). -export const liveManifest = (loaded) => { - const files = {}, objects = {}, products = {}; - for (const [dir, m] of loaded) { - const subjects = (products[m.product] ||= {}); - if (m.subject in subjects) { - if (subjects[m.subject] !== dir) throw new Error(`${m.product} ${m.subject}: two manifests (${subjects[m.subject]}, ${dir})`); - continue; // a variant that is also a part - } - subjects[m.subject] = dir; - for (const [p, f] of Object.entries(m.files)) files[`${dir}/${p}`] = f; - for (const [id, o] of Object.entries(m.objects)) { - if (id in objects && JSON.stringify(objects[id]) !== JSON.stringify(o)) - throw new Error(`object ${id} differs between the manifests of the live's products`); - objects[id] = o; - } - } - const [liveDir, man] = loaded[0]; - const { files: _f, objects: _o, ...info } = man; - return { ...info, dir: liveDir, products, files, objects }; -}; +export const liveManifest = mergeProductManifests; diff --git a/tests/unit/live-store.test.mjs b/tests/unit/live-store.test.mjs index a5c1dbe..2239303 100644 --- a/tests/unit/live-store.test.mjs +++ b/tests/unit/live-store.test.mjs @@ -66,10 +66,26 @@ const live = () => { return L; }; -test("productDir: a manifest path without .json, one directory and a file", () => { +test("productDir: a product manifest path without .json", () => { assert.equal(productDir("scenes/default.json"), "scenes/default"); assert.equal(productDir("sounds/NoteSePatternId=1,3.json"), "sounds/NoteSePatternId=1,3"); - for (const bad of ["scene.json", "a/b/c.json", "scenes/default"]) assert.throws(() => productDir(bad), /not a product manifest path/); + assert.equal(productDir("region/scenes/default.json"), "region/scenes/default"); + for (const bad of ["scene.json", "a/../c.json", "scenes/default"]) + assert.throws(() => productDir(bad), /not a product manifest path/); +}); + +test("LiveData accepts language-indexed UI parts beside shared parts", async () => { + const L = live(); + const manifest = L.files.get("lives/7_expert.json"), path = manifest.files["live.json"].asset; + const doc = JSON.parse(new TextDecoder().decode(L.files.get(path))); + doc.parts.ui = { ja: "liveui/7_expert_ja.json", en: "liveui/7_expert_en.json" }; + const bytes = new TextEncoder().encode(JSON.stringify(doc)); + L.files.set(path, bytes); + manifest.files["live.json"].size = bytes.byteLength; + const data = new LiveData(await loadLiveStore(`${ROOT}lives/7_expert.json`, { fetch: L.fetch })); + assert.equal(data.scene.format, 1); + assert.deepEqual(data.score().notes, [1]); + assert.equal(data.live.parts.ui.ja, "liveui/7_expert_ja.json"); }); test("loadLiveStore: the live, its parts and the variants, each under its directory, one object table", async () => { @@ -194,4 +210,4 @@ test("legacyLive: the note assets, the skin's arrow clip and the effect sets' re assert.deepEqual(ps.m_VertexStreams, { $hex: "00010304" }); } assert.equal(store.mono(data.scene.spriteAssets.combo).$script, "LiveComboSpriteAsset"); -}); \ No newline at end of file +}); diff --git a/tests/unit/product-manifests.test.mjs b/tests/unit/product-manifests.test.mjs new file mode 100644 index 0000000..f32f4fc --- /dev/null +++ b/tests/unit/product-manifests.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mergeProductManifests, productDir } from "../../src/data/products.js"; + +const json = { asset: "assets/texture.json", size: 2 }; +const image = { asset: "assets/texture.png", size: 17 }; +const product = (name, files) => [name, { + schema: "nnnotes.product/1", product: name, subject: "default", files: {}, + objects: { "cab:1": { class: "Texture2D", name: "texture", files } }, +}]; + +test("product objects combine complementary roles and compare file fields by value", () => { + const left = product("scene", { json }); + const right = product("ui", { image, json: { size: 2, asset: json.asset } }); + const result = mergeProductManifests([left, right]); + assert.deepEqual(result.objects["cab:1"].files, { json, image }); + assert.deepEqual(left[1].objects["cab:1"].files, { json }); + assert.deepEqual(result.products, { scene: { default: "scene" }, ui: { default: "ui" } }); +}); + +test("shared object metadata and overlapping file roles must agree", () => { + const left = product("scene", { json }); + const right = product("ui", { json: { ...json, size: 3 } }); + assert.throws(() => mergeProductManifests([left, right]), /object cab:1 differs.*json/); + right[1].objects["cab:1"].files = { json }; + right[1].objects["cab:1"].class = "Material"; + assert.throws(() => mergeProductManifests([left, right]), /object cab:1 differs.*class/); +}); + +test("duplicate product paths retain the same file content", () => { + const left = product("scene", { json }); + left[1].files = { "scene.json": json }; + const right = structuredClone(left); + right[1].files["scene.json"].size++; + assert.throws(() => mergeProductManifests([left, right]), /differing product files/); + assert.throws(() => mergeProductManifests([]), /no product manifests/); +}); + +test("product paths may include region directories and must remain relative", () => { + assert.equal(productDir("regions/a/stories/7.json"), "regions/a/stories/7"); + for (const path of ["/stories/7.json", "stories/../7.json", "stories/7.json?x", "a//7.json", "a\\7.json"]) + assert.throws(() => productDir(path), /not a product manifest path/); +}); From 208e18db26c49974bd7c75029d482f0a9bc1e836 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 17 Oct 2026 21:35:19 +0800 Subject: [PATCH 2/2] ci: check pull requests targeting any branch --- .github/workflows/ci.yml | 1 - .github/workflows/commitlint.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1932b8e..5f4dbe6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ name: ci # step when either changes. on: pull_request: - branches: [main] push: branches: [main] diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 7b0e22a..6d922d6 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -3,7 +3,6 @@ name: lint-commits # Enforces conventional commit titles so git-cliff can drive the release notes. See cliff.toml for the type groups. on: pull_request: - branches: [main] push: branches: [main]