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] 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/); +});