diff --git a/.changeset/safe-world-processing.md b/.changeset/safe-world-processing.md new file mode 100644 index 0000000..5f61404 --- /dev/null +++ b/.changeset/safe-world-processing.md @@ -0,0 +1,5 @@ +--- +"@blurengine/cli": minor +--- + +Add deterministic world processors with immutable Bedrock observations, canonical JSON or TypeScript runtime pointers, verified staged-world mutations, content-addressed artifacts, processed development and packaging, and authored-versus-processed publication safety. Update archive, compiler, and LevelDB dependencies, and preserve caller-owned chunk coordinates across the LevelDB compatibility boundary. diff --git a/.gitignore b/.gitignore index 3f32360..30a115e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ npm-debug.log* .env.local .blr/ +docs/superpowers/ diff --git a/package-lock.json b/package-lock.json index 0b4caa6..639da32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -785,9 +785,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -801,9 +801,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -817,9 +817,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -833,9 +833,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -849,9 +849,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -865,9 +865,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -881,9 +881,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -897,9 +897,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -913,9 +913,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -929,9 +929,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -945,9 +945,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -961,9 +961,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -977,9 +977,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -993,9 +993,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -1009,9 +1009,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -1025,9 +1025,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -1041,9 +1041,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -1057,9 +1057,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -1073,9 +1073,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -1089,9 +1089,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -1105,9 +1105,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1121,9 +1121,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1137,9 +1137,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1153,9 +1153,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1169,9 +1169,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1185,9 +1185,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1464,16 +1464,6 @@ "node": ">=14.0.0" } }, - "node_modules/@types/adm-zip": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", - "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1525,12 +1515,12 @@ } }, "node_modules/adm-zip": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", - "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/agent-base": { @@ -1665,13 +1655,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -2133,9 +2123,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -2145,32 +2135,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -2351,16 +2341,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -2605,9 +2595,9 @@ "license": "ISC" }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2866,12 +2856,14 @@ } }, "node_modules/mcbe-leveldb": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/mcbe-leveldb/-/mcbe-leveldb-1.15.0.tgz", - "integrity": "sha512-5E0y+AdvvHIwhGb4g2ni9CXSHGOaQ8+VL1gsyu/Q9mkD+0xwjGdRkhElIc5fWk3J034a3zco4siGxHeWCp3Btw==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/mcbe-leveldb/-/mcbe-leveldb-1.21.0.tgz", + "integrity": "sha512-HrRWYwcaQlMiq3D8IKHEZIAnvTn1BoCISDVoKW1druzYQQOUWrE5mlIlstN8u+ikG+y/8u1MUR628Fhqt5Tg+A==", "license": "MIT", "dependencies": { - "prismarine-nbt": "^2.7.0" + "prismarine-nbt": "^2.7.0", + "protodef": "1.18.x||1.19.x", + "type-fest": "^5.8.0" }, "optionalDependencies": { "@8crafter/leveldb-zlib": "^1.3.0" @@ -3755,6 +3747,18 @@ "anynum": "^1.0.0" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -3863,6 +3867,21 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4032,17 +4051,17 @@ }, "packages/blr": { "name": "@blurengine/cli", - "version": "0.13.0", + "version": "0.14.0", "license": "Apache-2.0", "dependencies": { "@8crafter/leveldb-zlib": "^1.6.0", "@aws-sdk/client-s3": "3.1068.0", - "adm-zip": "^0.5.17", + "adm-zip": "^0.6.0", "chokidar": "^5.0.0", "commander": "^14.0.3", - "esbuild": "^0.28.0", + "esbuild": "^0.28.2", "kleur": "^4.1.5", - "mcbe-leveldb": "^1.15.0", + "mcbe-leveldb": "^1.21.0", "node-pty": "^1.1.0", "picomatch": "^4.0.4", "pngjs": "^7.0.0", @@ -4055,7 +4074,6 @@ "blr": "dist/blr.js" }, "devDependencies": { - "@types/adm-zip": "^0.5.8", "@types/pngjs": "^6.0.5", "ts-json-schema-generator": "^2.4.0" }, diff --git a/packages/blr/README.md b/packages/blr/README.md index 7d4aba0..90282fe 100644 --- a/packages/blr/README.md +++ b/packages/blr/README.md @@ -28,6 +28,7 @@ npm run dev - [Command Reference](./docs/reference/commands.md) - [Config Reference](./docs/reference/config.md) - [Generated Project Reference](./docs/reference/generated-project.md) +- [World Processing](./docs/reference/world-processing.md) - [Changelog](./CHANGELOG.md) ## Development diff --git a/packages/blr/docs/README.md b/packages/blr/docs/README.md index 236fc49..e577c35 100644 --- a/packages/blr/docs/README.md +++ b/packages/blr/docs/README.md @@ -7,6 +7,7 @@ This is the canonical reference set for the `blr` tool and the projects it gener - [Command Reference](./reference/commands.md) - [Config Reference](./reference/config.md) - [Generated Project Reference](./reference/generated-project.md) +- [World Processing](./reference/world-processing.md) ## Standards @@ -26,6 +27,7 @@ These docs cover: - machine-local overrides via environment variables and CLI flags - generated project files and optional state files - staged build output and packaged artifacts under `dist/` +- immutable world observations, derived artifacts, and verified processed worlds - shared Bedrock engineering standards used by generated projects ## Maintenance diff --git a/packages/blr/docs/reference/commands.md b/packages/blr/docs/reference/commands.md index c33bcd1..003d3f7 100644 --- a/packages/blr/docs/reference/commands.md +++ b/packages/blr/docs/reference/commands.md @@ -6,6 +6,7 @@ - Generated projects run the installed binary through package-manager scripts, for example `npm run dev`, `pnpm run dev`, `yarn run dev`, or `bun run dev` - Generated projects also expose a `system` package script for support diagnostics - Generated projects also expose a `world` package script for world backend operations +- Generated projects expose `assets:check` for a read-only world-processor freshness check Windows PowerShell note: @@ -231,6 +232,7 @@ Build output: - generated project code should not import `@blurengine/bebe/tooling/*`; those Node-only build surfaces are resolved by `blr` when it bakes Bebe assets - dynamic Link usage such as assigning or destructuring `Link.event` or `Link.snapshot` fails the offline build with a clear error because `blr` cannot safely erase it - `local-deploy` consumes the offline staged behavior pack; `local-server` consumes the BDS staged behavior pack +- configured artifact processors run before content staging; transform processors are validated but only materialise a world when a consuming workflow needs one Bebe integration: @@ -541,6 +543,7 @@ Purpose: Remote object layout for the S3 backend: - `/.zip` +- `/processed/.zip` - `/.lock.json` Notes: @@ -567,6 +570,36 @@ Notes: - internal runtime and materialization bookkeeping lives under `.blr/state/world-state.json` - generated projects ignore raw world contents by default but still allow `worlds/worlds.json` to be committed - if the remote fingerprint drifts, `blr` ignores the stale pin until the next successful remote world action refreshes it +- authored and processed world publication use separate lineage state; processed output never replaces the authored project world + +### `blr world build` + +Runs the configured processor graph against an immutable snapshot and produces or verifies a content-addressed processed world. + +Syntax: + +```text +blr world build [worldName] +``` + +Behavior: + +- never opens or modifies the authored world database +- publishes configured immutable artifact sets and runtime pointers +- applies declarative transform mutations only to a disposable staged copy +- reopens and verifies the processed world before publishing its lineage +- retains the previous coherent build after any failure or superseded run + +Flags: + +- `--check`: verify committed artifacts and the processed-world lineage without project or cache writes +- `--dry-run`: alias for `--check` +- `--processor `: narrow the run to named processors plus dependencies +- `--audit`: write configured audit reports after a successful build +- `--output `: copy the verified world to a new explicit directory +- `--json`: emit the result as JSON + +See [World Processing](./world-processing.md) for the processor and mutation contracts. ### `blr world list` @@ -974,5 +1007,8 @@ Generated projects expose these scripts: - ` run minecraft -- ` -> `blr minecraft ` - ` run system -- ` -> `blr system ` - ` run world -- ` -> `blr world ` +- ` run assets:check` -> `blr world build --check` - ` run clean` -> `blr clean` - ` run upgrade` -> `blr upgrade` + +New projects also default `check` to `npm run assets:check && blr build`. `blr upgrade` reconciles `assets:check` but preserves a project's existing custom `check` workflow. diff --git a/packages/blr/docs/reference/config.md b/packages/blr/docs/reference/config.md index e90cb98..05959b4 100644 --- a/packages/blr/docs/reference/config.md +++ b/packages/blr/docs/reference/config.md @@ -157,6 +157,7 @@ Optional world source backend configuration. Fields: - `backend`: `local | s3` +- `pushPolicy`: `authored | processed` - `s3.bucket`: bucket name for remote world storage - `s3.region`: region for the S3 client - `s3.endpoint`: optional custom S3-compatible endpoint @@ -168,6 +169,7 @@ Fields: Defaults if omitted: - `backend`: `local` +- `pushPolicy`: `authored` - `s3.bucket`: empty - `s3.region`: empty in config, then resolved from `AWS_REGION` / `AWS_DEFAULT_REGION`, then `us-east-1` - `s3.endpoint`: empty @@ -226,6 +228,59 @@ Notes: - `blr world push` - `blr dev` project-world remote sync - `blr dev` stays lenient about local world edits and uses `worlds/worlds.json` as the project truth instead of trying to infer remote freshness from local files alone +- `pushPolicy: processed` makes `blr world push` require and publish the exact current verified processed-world build on a separate remote channel + +### `worldProcessors` + +Optional ordered trusted processors that derive artifacts or staged mutations from an immutable world snapshot. + +Example: + +```json +{ + "worldProcessors": [ + { + "id": "project-locations", + "module": "./tooling/world/locations.ts", + "export": "createLocationsProcessor", + "sourceWorld": "Bedrock level", + "capabilities": ["artifact", "transform"], + "dependsOn": [], + "inputPaths": ["world-data/Bedrock level/locations.source.json"], + "outputRoot": "world-data/Bedrock level/generated/locations", + "payloadFileNames": { + "locations": "locations.json" + }, + "runtimePointerPath": "world-data/Bedrock level/generated/locations/current.generated.json", + "applyOn": { + "dev": true, + "build": true, + "package": true, + "check": true, + "worldBuild": true, + "worldPush": true + } + } + ] +} +``` + +Fields: + +- `id`: portable unique processor id +- `module`: explicit project-relative module or bare package export +- `export`: named factory export +- `sourceWorld`: authoritative selected authored world +- `capabilities`: any of `observer`, `artifact`, `transform` +- `dependsOn`: preceding processor ids whose results are provided as immutable dependencies +- `inputPaths`: declared project-relative logical input files +- `outputRoot`: required immutable-set root for artifact processors +- `payloadFileNames`: artifact id to relative payload filename map +- `runtimePointerPath`: optional generated `.json` or `.ts` pointer to the current immutable set. Prefer JSON in world-owned data directories; use TypeScript only when the consumer explicitly owns generated source code. +- `auditOutputPath`: optional audit output outside the runtime artifact set +- `applyOn`: intent switches for `dev`, `build`, `package`, `check`, `worldBuild`, and `worldPush` + +Processor ids, input paths, and outputs are validated for portability and project containment. Output ownership cannot overlap across processors. See [World Processing](./world-processing.md) for source-snapshot, observation, cache, publication, and staged-mutation guarantees. ### `package` diff --git a/packages/blr/docs/reference/generated-project.md b/packages/blr/docs/reference/generated-project.md index efb5e08..16025a6 100644 --- a/packages/blr/docs/reference/generated-project.md +++ b/packages/blr/docs/reference/generated-project.md @@ -52,6 +52,7 @@ Generated scripts: - `minecraft` - `system` - `world` +- `assets:check` - `clean` - `upgrade` @@ -287,6 +288,7 @@ Usage: - `blr package mctemplate` and `blr package mcworld` use this as the source world payload - `blr world pull` can materialize this folder from a remote S3-compatible backend - `blr world push` can publish this folder back to the configured remote backend +- `blr world build` derives a verified processed copy without opening or mutating this folder - `blr create` does not generate this folder by default because an empty folder looks like a valid world when it is not - world-aware commands require a real Bedrock world with a `db/` directory @@ -400,6 +402,7 @@ Typical contents may include: - BDS zip cache - provisioned BDS server files - cached remote world archives and extracted world sources under `.blr/cache/worlds/` +- content-addressed processor caches and verified processed-world lineages - CLI-owned transient state under `.blr/state/`, such as prompt-silence state in `.blr/state/cli.json` - other CLI-owned runtime state @@ -412,6 +415,8 @@ During `local-server` runs, `blr` may also manage runtime world hook files such Those files are only written when the corresponding pack automation is enabled for the current project and current run. +Configured `worldProcessors` may also publish immutable JSON sets and a stable generated TypeScript pointer at project-owned paths declared in `blr.config.json`. These are generated inputs, not hand-authored runtime state. Transform results live under the CLI workspace or an explicit output path; they never overwrite `worlds//`. + ## Build Output ### `dist/stage/` diff --git a/packages/blr/docs/reference/world-processing.md b/packages/blr/docs/reference/world-processing.md new file mode 100644 index 0000000..4d6fe43 --- /dev/null +++ b/packages/blr/docs/reference/world-processing.md @@ -0,0 +1,139 @@ +# World Processing + +## Purpose + +World processors turn a selected authored Bedrock world into deterministic build inputs without opening or modifying that authored world. A project can use several processors for independent jobs such as extracting sign-authored locations, compiling zones or paths, auditing a build, and preparing a processed world for development or publication. + +`blr` owns the unsafe and repetitive infrastructure: + +- resolving the selected and, when configured, remotely pinned world +- taking a verified immutable snapshot before opening LevelDB +- decoding blocks, block states, all subchunk layers, block entities, and both sign faces +- bounding and ordering observation queries +- loading trusted TypeScript processor modules +- tracking logical inputs and invalidating cached work +- publishing content-addressed artifact sets and typed runtime pointers +- checking declarative world mutations for conflicts and preconditions +- applying mutations only to a disposable staged copy +- reopening and verifying the processed copy before it becomes usable +- keeping authored and processed remote publication lineages separate + +The project or reusable package owns meaning. `blr` does not decide what `@name`, `@object`, a location, a zone, furniture, or a train means. + +## Processor contract + +Processors import the supported SDK from `@blurengine/cli/world-processing` and are created with `defineWorldProcessor`. + +```ts +import { defineWorldProcessor } from "@blurengine/cli/world-processing"; + +export const createLocationsProcessor = defineWorldProcessor(() => ({ + implementationRevision: "locations-v1", + logicalInputs: [ + { id: "definition", kind: "file", path: "locations.source.json" }, + ], + async run(input) { + return { + logicalInputs: input.logicalInputs, + artifacts: [], + diagnostics: [], + mutations: [], + }; + }, +})); +``` + +Processor modules are trusted local build code, not a sandbox. They receive no LevelDB handle or output path from `blr`, and cancellation is cooperative. Publication tokens prevent a superseded run from publishing stale results. + +Pipeline intent and check/bake mode select when BLR invokes a processor and how +it publishes the result; they are not processor inputs. A processor therefore +returns the same semantic artifacts, diagnostics, and mutation plan for the +same immutable inputs across `dev`, `build`, `package`, and world workflows. +This keeps one declared output path content-addressed and stable across commands. + +A processor may return optional canonical JSON in `audit`. When its config declares `auditOutputPath`, `blr world build --audit` writes that value beside the processor identity, revision, logical-input hash, and diagnostics. Audit data is excluded from runtime artifact sets and generated pointers unless the processor also chooses to publish it as an artifact. + +Capabilities are explicit: + +- `observer`: diagnostics only +- `artifact`: immutable JSON payloads and a generated runtime pointer +- `transform`: a declarative mutation plan for a staged world + +All selected processors observe the same immutable base snapshot. They do not read one another's mutations. Dependencies can consume preceding processor results, while conflicting mutation claims fail instead of using last-writer-wins ordering. + +## Sign observations + +Sign observations are data, not a directive standard. For each supported sign, the SDK preserves: + +- its integer block location and complete palette state +- front and back faces independently +- exact raw text +- newline-normalised text +- every row, including empty rows and rows beyond four +- a discriminated orientation + +Standing signs expose all 16 `ground_sign_direction` states. Wall signs expose the four horizontal `facing_direction` states. Hanging signs retain their states for consumers that support them. Processors decide which face syntax is valid and which observations become artifacts. + +Use `input.observations.signs()` for location-style processors. It walks the world's block-entity records once, resolves each coherent sign's exact layer-zero block and orientation, and yields canonical dimension/X/Y/Z order. This cost scales with block entities instead of the volume between distant signs. An optional dimension and inclusive bounds filter the results; independent per-query and per-run sign limits protect builds without charging the ordinary block-position budget. Use `blocks(...)` for spatial block evidence such as routes, terrain, and bounded assemblies. + +For example, a project may treat line 1 as an explicit id or `~`, line 2 as any `@directive`, and all later lines as opaque consumer data. That grammar and any automatic id allocation belong to the processor or feature package, not to `blr`. + +## Artifacts + +An artifact processor declares its output root, payload filenames, and optional runtime pointer path in `blr.config.json`. `blr` canonicalises JSON, hashes payload bytes, writes an immutable set under `/sets//`, and replaces the stable pointer only after the complete set is valid. A detached manifest lists payload members and never hashes itself. + +Runtime pointers may be canonical `.json` data with payload values embedded, or generated `.ts` modules that statically import the immutable payloads. World-derived data should normally use a project-level world sidecar such as `world-data//generated/`; placing it under an authored `src/` tree is a consumer choice, not a BLR requirement. + +Processors that bind one payload to another can call +`canonicalizeWorldDerivedJson(value)` or `hashWorldDerivedJson(value)` from +`@blurengine/cli/world-processing`. These are the exact canonical byte and +lowercase SHA-256 contracts used by BLR publication, including lexical object +keys, finite-number validation, `-0` normalisation, two-space indentation, and +one terminal newline. Pair the embedded hash with an artifact `hashReferences` +entry so BLR independently verifies the binding before publication. + +`blr world build --check` recomputes and compares the contract without writing project files or caches. It is suitable for CI and the generated `assets:check` package script. + +When no processor applies to the selected world, the check is a successful read-only no-op and does not require a world directory. This keeps the managed script safe for ordinary projects before they add processors. + +## World mutations + +Transform processors return typed, declarative operations rather than editing LevelDB: + +- set one block with an exact observed/literal palette entry +- replace matching blocks in a bounded volume +- remove an expected block entity +- consume a sign as one block-and-block-entity operation + +Every operation has a stable id, exact dimension/location/layer, and explicit preconditions. Palette entries include type id, complete states, and Bedrock palette version. The writer preserves untouched layers and block entities, updates affected height data, applies the raw key changes atomically to the staged database, then reopens and verifies the result. Initial support intentionally edits only existing chunks and subchunks. + +Authored marker signs are retained by default. A consumer must explicitly emit a mutation if its processed world should remove or replace one. + +## Commands and workflow + +Use these commands from a configured project: + +```text +blr world build +blr world build --check +blr world build --dry-run --json +blr world build --processor locations --audit +blr world build --output dist/world-preview +``` + +`build` and `package` run the processors enabled for those intents. Packages that include a world consume the verified processed world; pack-only targets do not materialise unnecessary world transforms. + +In `dev`, artifact processors may trigger a script/content reload. A world transform causes a stopped-server rebuild and reseed. Processed-world development cannot be combined with `watch-world`: capturing a derived runtime world back over the marker-bearing authored source is rejected. Use two explicit modes: + +- author mode: raw world with capture enabled +- play/verification mode: processed world with capture disabled + +## Remote publication + +Set `world.pushPolicy` to `processed` to make ordinary `blr world push` require a current verified `blr world build`. The processed world is uploaded to a separate remote channel from the authored source. A push acquires the world lock before checking and uploading the exact current build. + +`--channel authored|processed` overrides the configured policy for an explicit invocation. Pulling continues to operate on the authored channel; processed output is a publication artifact and is never materialised over the project source. + +## Source safety + +No processor run opens the authored LevelDB. `blr` inventories source contents, copies them to a unique temporary snapshot, inventories source and snapshot again, and proceeds only when they agree. Failed, aborted, conflicting, or stale runs leave the source and previous published artifacts untouched. diff --git a/packages/blr/package.json b/packages/blr/package.json index 4f281da..7572275 100644 --- a/packages/blr/package.json +++ b/packages/blr/package.json @@ -21,6 +21,12 @@ "url": "https://github.com/BlurEngine/cli/issues" }, "type": "module", + "exports": { + "./world-processing": { + "types": "./dist/world-processing.d.ts", + "import": "./dist/world-processing.js" + } + }, "bin": { "blr": "./dist/blr.js" }, @@ -34,6 +40,7 @@ "CHANGELOG.md", "NOTICE" ], + "types": "./dist/world-processing.d.ts", "scripts": { "sync:legal": "node ./scripts/sync-legal-files.mjs", "generate:schema": "node ./scripts/generate-config-schema.mjs", @@ -58,12 +65,12 @@ "dependencies": { "@8crafter/leveldb-zlib": "^1.6.0", "@aws-sdk/client-s3": "3.1068.0", - "adm-zip": "^0.5.17", + "adm-zip": "^0.6.0", "chokidar": "^5.0.0", "commander": "^14.0.3", - "esbuild": "^0.28.0", + "esbuild": "^0.28.2", "kleur": "^4.1.5", - "mcbe-leveldb": "^1.15.0", + "mcbe-leveldb": "^1.21.0", "node-pty": "^1.1.0", "picomatch": "^4.0.4", "pngjs": "^7.0.0", @@ -73,7 +80,6 @@ "typescript": "^5.9.3" }, "devDependencies": { - "@types/adm-zip": "^0.5.8", "@types/pngjs": "^6.0.5", "ts-json-schema-generator": "^2.4.0" } diff --git a/packages/blr/schema/blr.config.schema.json b/packages/blr/schema/blr.config.schema.json index dd54b9a..c2913b5 100644 --- a/packages/blr/schema/blr.config.schema.json +++ b/packages/blr/schema/blr.config.schema.json @@ -49,6 +49,13 @@ "dev": { "$ref": "#/definitions/BlurConfigDevFile", "description": "Project-level defaults for `blr dev`." + }, + "worldProcessors": { + "type": "array", + "items": { + "$ref": "#/definitions/BlurConfigWorldProcessorFile" + }, + "description": "Trusted project processors that derive artifacts or staged world mutations from an immutable selected world." } }, "additionalProperties": false, @@ -95,6 +102,10 @@ "$ref": "#/definitions/WorldBackend", "description": "World source backend. Use `s3` to enable `blr world pull/push` against an S3-compatible store." }, + "pushPolicy": { + "$ref": "#/definitions/WorldPushPolicy", + "description": "Which remote channel `blr world push` publishes by default. Processed pushes require a current verified `blr world build` output." + }, "s3": { "$ref": "#/definitions/BlurConfigWorldS3File", "description": "S3-compatible world backend coordinates." @@ -107,6 +118,10 @@ "type": "string", "enum": ["local", "s3"] }, + "WorldPushPolicy": { + "type": "string", + "enum": ["authored", "processed"] + }, "BlurConfigWorldS3File": { "type": "object", "properties": { @@ -558,6 +573,90 @@ }, "additionalProperties": false, "description": "Local Link dashboard settings." + }, + "BlurConfigWorldProcessorFile": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "module": { + "type": "string" + }, + "export": { + "type": "string" + }, + "sourceWorld": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "$ref": "#/definitions/WorldProcessorCapability" + } + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string" + } + }, + "inputPaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "outputRoot": { + "type": "string" + }, + "payloadFileNames": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "runtimePointerPath": { + "type": "string" + }, + "auditOutputPath": { + "type": "string" + }, + "applyOn": { + "$ref": "#/definitions/BlurConfigWorldProcessorApplyOnFile" + } + }, + "required": ["id", "module", "sourceWorld"], + "additionalProperties": false, + "description": "Trusted project processor that derives artifacts or staged world mutations." + }, + "WorldProcessorCapability": { + "type": "string", + "enum": ["observer", "artifact", "transform"] + }, + "BlurConfigWorldProcessorApplyOnFile": { + "type": "object", + "properties": { + "dev": { + "type": "boolean" + }, + "build": { + "type": "boolean" + }, + "package": { + "type": "boolean" + }, + "check": { + "type": "boolean" + }, + "worldBuild": { + "type": "boolean" + }, + "worldPush": { + "type": "boolean" + } + }, + "additionalProperties": false } }, "$id": "https://blurengine.dev/schema/blr.config.schema.json", diff --git a/packages/blr/scripts/pack-smoke.mjs b/packages/blr/scripts/pack-smoke.mjs index bde8e7b..439a85a 100644 --- a/packages/blr/scripts/pack-smoke.mjs +++ b/packages/blr/scripts/pack-smoke.mjs @@ -156,6 +156,10 @@ async function main() { packedFiles.has("README.md"), "Packed tarball must include a package README.", ); + assert.ok( + packedFiles.has("dist/world-processing.d.ts"), + "Packed tarball must include the world-processing SDK declarations.", + ); const smokeRoot = await mkdtemp(path.join(os.tmpdir(), "blr-pack-smoke-")); @@ -171,6 +175,60 @@ async function main() { run(npmCommand, ["init", "-y"], smokeRoot); run(npmCommand, ["install", tarballPath], smokeRoot); + await writeFile( + path.join(smokeRoot, "world-processor.ts"), + `import { + defineWorldProcessor, + type WorldBlockLocation, + type WorldProcessorInput, + type WorldProcessorResult, +} from "@blurengine/cli/world-processing"; + +const location: WorldBlockLocation = { x: 0, y: 64, z: 0 }; + +export const createSmokeWorldProcessor = defineWorldProcessor(() => ({ + implementationRevision: "pack-smoke-v1", + logicalInputs: [], + async run(input: WorldProcessorInput): Promise { + return { + logicalInputs: input.logicalInputs, + artifacts: [], + diagnostics: [{ + code: "pack-smoke", + severity: "info", + message: "World-processing SDK is type-safe from the packed package.", + location, + }], + mutations: [], + }; + }, +})); +`, + "utf8", + ); + await writeFile( + path.join(smokeRoot, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + }, + include: ["world-processor.ts"], + }, + null, + 2, + )}\n`, + "utf8", + ); + run( + npmCommand, + ["exec", "--", "tsc", "--noEmit", "-p", "tsconfig.json"], + smokeRoot, + ); + const installedCliEntry = path.join( smokeRoot, "node_modules", diff --git a/packages/blr/src/bds.ts b/packages/blr/src/bds.ts index 9f288bc..674c4cf 100644 --- a/packages/blr/src/bds.ts +++ b/packages/blr/src/bds.ts @@ -31,6 +31,7 @@ import type { BlurProject, PermissionLevel, } from "./types.js"; +import type { ResolvedWorldInput } from "./world-input.js"; import { appendWorldSourceHint, assertValidProjectWorldSource, @@ -1122,12 +1123,26 @@ async function syncProjectWorldSource( projectRoot: string, config: BlurProject, state: ResolvedBdsState, - options: { worldMode: BdsWorldApplyMode; requireWorldSource: boolean }, + options: { + worldMode: BdsWorldApplyMode; + requireWorldSource: boolean; + worldInput?: ResolvedWorldInput; + }, debug?: DebugLogger, ): Promise { - const hasWorldSource = await exists(state.worldSourceDirectory); + const inputDirectory = + options.worldInput?.directory ?? state.worldSourceDirectory; + if ( + options.worldInput && + options.worldInput.worldName !== state.worldName + ) { + throw new Error( + `Resolved world input ${options.worldInput.worldName} cannot seed BDS world ${state.worldName}.`, + ); + } + const hasWorldSource = await exists(inputDirectory); if (!hasWorldSource) { - if (options.requireWorldSource) { + if (options.requireWorldSource && !options.worldInput) { try { await assertValidProjectWorldSource( projectRoot, @@ -1143,12 +1158,12 @@ async function syncProjectWorldSource( } } debug?.log("bds", "no project world source present", { - worldSourceDirectory: state.worldSourceDirectory, + worldSourceDirectory: inputDirectory, }); return; } - if (options.requireWorldSource) { + if (options.requireWorldSource && !options.worldInput) { try { await assertValidProjectWorldSource( projectRoot, @@ -1176,9 +1191,18 @@ async function syncProjectWorldSource( return; } - await copyDirectory(state.worldSourceDirectory, state.worldDirectory); + if ( + options.worldInput && + !(await exists(path.join(inputDirectory, "db"))) + ) { + throw new Error( + `Resolved ${options.worldInput.kind} world input is missing its db directory: ${inputDirectory}.`, + ); + } + await copyDirectory(inputDirectory, state.worldDirectory); debug?.log("bds", "copied project world source into runtime world", { - source: state.worldSourceDirectory, + source: inputDirectory, + inputKind: options.worldInput?.kind ?? "authored", destination: state.worldDirectory, worldMode: options.worldMode, }); @@ -1241,7 +1265,10 @@ export async function replaceRuntimeWorldFromProjectSource( projectRoot: string, config: BlurProject, state: ResolvedBdsState, - options: { requireWorldSource?: boolean } = {}, + options: { + requireWorldSource?: boolean; + worldInput?: ResolvedWorldInput; + } = {}, debug?: DebugLogger, ): Promise { await syncProjectWorldSource( @@ -1251,6 +1278,7 @@ export async function replaceRuntimeWorldFromProjectSource( { worldMode: "replace", requireWorldSource: options.requireWorldSource ?? true, + worldInput: options.worldInput, }, debug, ); @@ -1435,6 +1463,7 @@ export async function syncProjectToBds( requireWorldSource: boolean; copyPacks?: PackFeatureSelectionOverride; attachPacks?: PackFeatureSelectionOverride; + worldInput?: ResolvedWorldInput; }, debug?: DebugLogger, ): Promise { @@ -1450,6 +1479,7 @@ export async function syncProjectToBds( { worldMode: options.worldMode, requireWorldSource: options.requireWorldSource, + worldInput: options.worldInput, }, debug, ); @@ -1525,6 +1555,7 @@ export class BdsServerController { options: { worldMode?: BdsWorldApplyMode; requireWorldSource?: boolean; + worldInput?: ResolvedWorldInput; } = {}, ): Promise { this.options.debug?.log("bds", "applying mode", { @@ -1563,6 +1594,7 @@ export class BdsServerController { requireWorldSource, copyPacks: this.options.copyPacks, attachPacks: this.options.attachPacks, + worldInput: options.worldInput, }, this.options.debug, ); @@ -1579,6 +1611,7 @@ export class BdsServerController { requireWorldSource, copyPacks: this.options.copyPacks, attachPacks: this.options.attachPacks, + worldInput: options.worldInput, }, this.options.debug, ); diff --git a/packages/blr/src/blr.ts b/packages/blr/src/blr.ts index d92931f..7938079 100644 --- a/packages/blr/src/blr.ts +++ b/packages/blr/src/blr.ts @@ -19,6 +19,7 @@ import { import { runUpgradeCommand } from "./commands/upgrade.js"; import { runWorldCaptureCommand, + runWorldBuildCommand, runWorldImageCommand, runWorldLevelDatDiffCommand, runWorldLevelDatEditCommand, @@ -31,6 +32,7 @@ import { runWorldUnlockCommand, runWorldUseCommand, runWorldVersionsCommand, + type WorldBuildCommandOptions, } from "./commands/world.js"; import { isPromptCancelledError } from "./prompt.js"; @@ -513,6 +515,48 @@ async function main(): Promise { "Inspect, edit, and compare Bedrock level.dat data for a project world or explicit file path.", ); + world + .command("build") + .description( + "Build or verify a content-addressed processed world without modifying its authored source.", + ) + .argument( + "[worldName]", + "World name. Defaults to dev.localServer.worldName", + ) + .option( + "--check", + "Verify that processed artifacts and world lineage are current without project writes", + false, + ) + .option( + "--dry-run", + "Alias for --check; compute and validate without project writes", + false, + ) + .option( + "--processor ", + "Run only the selected processor ids and their declared dependencies", + ) + .option( + "--audit", + "Write configured processor audit reports during a successful build", + false, + ) + .option( + "--output ", + "Also copy the verified processed world to a new explicit directory", + ) + .option("--json", "Print the processed-world result as JSON", false) + .action( + async ( + worldName: string | undefined, + opts: WorldBuildCommandOptions, + ) => { + await runWorldBuildCommand(worldName, opts); + }, + ); + world .command("use") .description("Set the active project world in blr.config.json.") @@ -849,6 +893,10 @@ async function main(): Promise { "--reason ", "Optional lock reason recorded in remote lock metadata", ) + .option( + "--channel ", + "World publication channel: authored or processed (defaults to world.pushPolicy)", + ) .option( "--debug [enabled]", "Enable or disable debug logs for world backend activity", diff --git a/packages/blr/src/commands/create.ts b/packages/blr/src/commands/create.ts index 259037b..9c28122 100644 --- a/packages/blr/src/commands/create.ts +++ b/packages/blr/src/commands/create.ts @@ -33,6 +33,7 @@ import { syncManagedProjectInstructions } from "../project-instructions.js"; import { MANAGED_PACKAGE_SCRIPTS, MANAGED_NODE_ENGINE, + NEW_PROJECT_CHECK_SCRIPT, reconcileManagedGitIgnore, } from "../managed-project.js"; import { runPrompt } from "../prompt.js"; @@ -260,7 +261,10 @@ function createPackageJson( private: true, type: "module", packageManager: PACKAGE_MANAGER_VERSION_HINTS[packageManager], - scripts: { ...MANAGED_PACKAGE_SCRIPTS }, + scripts: { + ...MANAGED_PACKAGE_SCRIPTS, + check: NEW_PROJECT_CHECK_SCRIPT, + }, dependencies, devDependencies, engines: { diff --git a/packages/blr/src/commands/dev.ts b/packages/blr/src/commands/dev.ts index bf80ba9..bc4a87a 100644 --- a/packages/blr/src/commands/dev.ts +++ b/packages/blr/src/commands/dev.ts @@ -56,7 +56,15 @@ import { pullWorldFromS3, type WorldStatus, } from "../world-backend.js"; -import { resolveSelectedWorld } from "../world.js"; +import { + resolveProjectWorldSourceDirectory, + resolveSelectedWorld, +} from "../world.js"; +import { + buildProcessedWorld, + type ProcessedWorldBuildResult, +} from "../world-processing/processor-build.js"; +import type { ResolvedWorldInput } from "../world-input.js"; type DevCommandOptions = { localDeploy?: boolean; @@ -175,9 +183,15 @@ type ProjectWatchChangeAction = | { kind: "reload"; pipelineMode: "reload"; + } + | { + kind: "restart"; + pipelineMode: "restart"; + reloadConfig: boolean; }; type ProjectWatchChangeActionOptions = { readonly assetSourcePaths?: readonly string[]; + readonly worldProcessorPaths?: readonly string[]; }; type WatchPlan = { patterns: string[]; @@ -218,6 +232,15 @@ export function resolveProjectWatchChangeAction( ): ProjectWatchChangeAction { const normalizedPath = normalizeWatchPath(targetPath).replace(/\/+$/, ""); + const worldProcessorPaths = options.worldProcessorPaths ?? []; + if (normalizedPath === BLR_CONFIG_FILE && worldProcessorPaths.length > 0) { + return { + kind: "restart", + pipelineMode: "restart", + reloadConfig: true, + }; + } + if (normalizedPath === BLR_CONFIG_FILE) { return { kind: "ignore", @@ -233,6 +256,20 @@ export function resolveProjectWatchChangeAction( }; } + if ( + worldProcessorPaths.some((pattern) => + picomatch(normalizeWatchPath(pattern), { dot: true })( + normalizedPath, + ), + ) + ) { + return { + kind: "restart", + pipelineMode: "restart", + reloadConfig: false, + }; + } + if (isAssetSourcePath(normalizedPath, options.assetSourcePaths ?? [])) { return { kind: "reload", @@ -333,6 +370,122 @@ export function createWatchPlan(patterns: readonly string[]): WatchPlan { }; } +type DevWorldProcessorSummary = { + readonly id: string; + readonly sourceWorld: string; + readonly capabilities: readonly string[]; + readonly applyOn: { readonly dev?: boolean }; +}; + +/** + * Author mode may capture a runtime world back into its source. Processed play + * mode must never do that because its BDS world contains generated mutations. + */ +export function assertDevWorldProcessingCompatibility(options: { + readonly watchWorld: boolean; + readonly worldName: string; + readonly worldProcessors: readonly DevWorldProcessorSummary[]; +}): void { + if (!options.watchWorld) return; + const transforms = options.worldProcessors.filter( + (processor) => + processor.sourceWorld === options.worldName && + processor.applyOn.dev === true && + processor.capabilities.includes("transform"), + ); + if (transforms.length === 0) return; + throw new Error( + `Processed play mode for ${options.worldName} cannot be combined with watch-world because runtime capture would overwrite the authored world. Disable watch-world or disable the dev transform processors: ${transforms.map((processor) => processor.id).join(", ")}.`, + ); +} + +/** Builds an independent watcher for processor configuration and inputs. */ +export function createWorldProcessorWatchPlan(options: { + readonly worldSourcePath: string; + readonly processors: readonly { + readonly module: string; + readonly inputPaths: readonly string[]; + }[]; +}): WatchPlan { + const patterns = new Set([ + BLR_CONFIG_FILE, + "worlds/worlds.json", + "package.json", + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lock", + "bun.lockb", + ]); + const worldPath = normalizeWatchPath(options.worldSourcePath).replace( + /\/+$/, + "", + ); + if (worldPath.length > 0) patterns.add(`${worldPath}/**/*`); + for (const processor of options.processors) { + for (const inputPath of processor.inputPaths) { + const normalized = normalizeWatchPath(inputPath).replace( + /\/+$/, + "", + ); + if (normalized.length > 0) { + patterns.add(normalized); + patterns.add(`${normalized}/**/*`); + } + } + if (!processor.module.startsWith("./")) continue; + const modulePath = normalizeWatchPath(processor.module.slice(2)); + patterns.add(modulePath); + const lastSlash = modulePath.lastIndexOf("/"); + const moduleDirectory = + lastSlash < 0 ? "." : modulePath.slice(0, lastSlash); + patterns.add(`${moduleDirectory}/**/*`); + } + return createWatchPlan([...patterns].sort()); +} + +function selectDevWorldProcessors( + config: Pick, + worldName: string, +) { + return config.worldProcessors.filter( + (processor) => + processor.sourceWorld === worldName && processor.applyOn.dev, + ); +} + +function assertDevConfigChangeIsProcessorOnly( + previous: BlurProject, + next: BlurProject, +): void { + const previousComparable = { ...previous, worldProcessors: [] }; + const nextComparable = { ...next, worldProcessors: [] }; + if (JSON.stringify(previousComparable) !== JSON.stringify(nextComparable)) { + throw new Error( + "blr.config.json changed outside worldProcessors while dev was running. Restart blr dev so the server, deploy, and watch controllers all use one configuration.", + ); + } +} + +function toProcessedWorldInput( + result: ProcessedWorldBuildResult, +): ResolvedWorldInput { + return Object.freeze({ + kind: "processed", + worldName: result.worldName, + directory: result.worldDirectory, + seedIdentity: `processed:${result.worldBuildId}`, + }); +} + +function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError") + ); +} + function resolveFlag( explicit: boolean | undefined, fallback: boolean, @@ -1704,6 +1857,7 @@ export async function runDevCommand(options: DevCommandOptions): Promise { ); const debug = createDebugLogger(resolveDebugEnabled(options.debug)); const selectedWorld = resolveSelectedWorld(config, options.world); + let activeProcessorConfig = config; const resolveCurrentMachine = () => resolveMachineSettings( projectRoot, @@ -2042,6 +2196,18 @@ export async function runDevCommand(options: DevCommandOptions): Promise { }, }, ); + let devWorldProcessors = selectDevWorldProcessors( + activeProcessorConfig, + selectedWorld.worldName, + ); + let processedPlayMode = devWorldProcessors.some((processor) => + processor.capabilities.includes("transform"), + ); + assertDevWorldProcessingCompatibility({ + watchWorld: resolved.watchWorld, + worldName: selectedWorld.worldName, + worldProcessors: devWorldProcessors, + }); const machineSettings = machine ?? resolveCurrentMachine(); machine = machineSettings; const bebeAssetWatchConfig = await resolveBebeAssetWatchConfig(projectRoot); @@ -2058,9 +2224,14 @@ export async function runDevCommand(options: DevCommandOptions): Promise { normalizedWorldSourcePath.length > 0 ? createWatchPlan([`${normalizedWorldSourcePath}/**/*`]) : createWatchPlan([]); + let worldProcessorWatchPlan = createWorldProcessorWatchPlan({ + worldSourcePath: selectedWorld.worldSourcePath, + processors: devWorldProcessors, + }); debug.log("dev", "resolved dev command", { projectRoot, selectedWorld, + worldProcessorWatchPatterns: worldProcessorWatchPlan.patterns, bebeAssetSourcePaths, bebeAssetWatchPatterns: bebeAssetWatchConfig.watchPatterns, watchPaths: scriptWatchPlan.patterns, @@ -2069,7 +2240,10 @@ export async function runDevCommand(options: DevCommandOptions): Promise { resolved, }); - if (resolved.abortBeforeStart || !resolved.selectedAnyAction) { + if ( + resolved.abortBeforeStart || + (!resolved.selectedAnyAction && devWorldProcessors.length === 0) + ) { logDevExit( resolved.exitMessage ?? "No dev actions selected. Exiting.", resolved.exitIsError, @@ -2160,7 +2334,7 @@ export async function runDevCommand(options: DevCommandOptions): Promise { ) : undefined; - if (runtimeState && resolved.watchWorld) { + if (runtimeState && resolved.watchWorld && !processedPlayMode) { const bootstrapResult = await bootstrapProjectWorldSourceFromBds( runtimeState, debug, @@ -2185,7 +2359,7 @@ export async function runDevCommand(options: DevCommandOptions): Promise { } } - if (runtimeState) { + if (runtimeState && !processedPlayMode) { const runtimeDecision = await resolveRuntimeWorldDecision({ projectRoot, config, @@ -2244,11 +2418,17 @@ export async function runDevCommand(options: DevCommandOptions): Promise { let shutdownPromise: Promise | undefined; let detachLocalServerExit: (() => void) | undefined; let pendingMode: PipelineMode | undefined; + let pendingRunToken: number | undefined; + let pendingProcessorConfigReload = false; + let pendingProcessedWorldRebuild = false; + let nextRunToken = 0; + let activePipelineController: AbortController | undefined; let running = false; let runtimeWorldDirty = false; let allowlistCapturePending = false; let allowlistCaptureInFlight = false; let watchingAnnounced = false; + let currentProcessedWorldInput: ResolvedWorldInput | undefined; const announceWatching = () => { if (watchingAnnounced) { @@ -2357,6 +2537,14 @@ export async function runDevCommand(options: DevCommandOptions): Promise { stopServer: options.stopServer !== false, }); pendingMode = undefined; + pendingRunToken = undefined; + activePipelineController?.abort( + new DOMException( + "Dev shutdown superseded world processing.", + "AbortError", + ), + ); + activePipelineController = undefined; if (debounceHandle) { clearTimeout(debounceHandle); @@ -2395,17 +2583,93 @@ export async function runDevCommand(options: DevCommandOptions): Promise { }); } - const runPipeline = async (mode: PipelineMode) => { + const runPipeline = async ( + mode: PipelineMode, + runToken: number, + options: { + reloadProcessorConfig: boolean; + rebuildProcessedWorld: boolean; + }, + ) => { if (shuttingDown) { return; } - debug.log("dev", "running pipeline", { mode }); + const controller = new AbortController(); + activePipelineController = controller; + const isCurrent = () => + !shuttingDown && + !controller.signal.aborted && + runToken === nextRunToken; + debug.log("dev", "running pipeline", { mode, runToken, ...options }); + + if (options.reloadProcessorConfig) { + const reloaded = await loadBlurConfig(projectRoot); + assertDevConfigChangeIsProcessorOnly( + activeProcessorConfig, + reloaded.config, + ); + const nextProcessors = selectDevWorldProcessors( + reloaded.config, + selectedWorld.worldName, + ); + const nextProcessedPlayMode = nextProcessors.some((processor) => + processor.capabilities.includes("transform"), + ); + if (nextProcessedPlayMode !== processedPlayMode) { + throw new Error( + "Changing between author mode and processed play mode requires restarting blr dev.", + ); + } + assertDevWorldProcessingCompatibility({ + watchWorld: resolved.watchWorld, + worldName: selectedWorld.worldName, + worldProcessors: nextProcessors, + }); + activeProcessorConfig = reloaded.config; + devWorldProcessors = nextProcessors; + processedPlayMode = nextProcessedPlayMode; + worldProcessorWatchPlan = createWorldProcessorWatchPlan({ + worldSourcePath: selectedWorld.worldSourcePath, + processors: devWorldProcessors, + }); + } - await buildProject(projectRoot, config, { + if ( + processedPlayMode && + (options.rebuildProcessedWorld || !currentProcessedWorldInput) + ) { + const processed = await buildProcessedWorld({ + projectRoot, + worldName: selectedWorld.worldName, + sourceWorldDirectory: resolveProjectWorldSourceDirectory( + projectRoot, + selectedWorld.worldSourcePath, + ), + configs: activeProcessorConfig.worldProcessors, + pipeline: "dev", + mode: "bake", + signal: controller.signal, + isCurrent, + }); + if (processed.status === "stale" || !isCurrent()) { + throw new DOMException( + "A newer dev world-processing run superseded this one.", + "AbortError", + ); + } + currentProcessedWorldInput = toProcessedWorldInput(processed); + } + + await buildProject(projectRoot, activeProcessorConfig, { production: resolved.production, debug, pipeline: "dev", + worldProcessing: { + enabled: !processedPlayMode, + signal: controller.signal, + isCurrent, + }, link: linkServer ? { baseUrl: linkServer.url, @@ -2415,6 +2679,12 @@ export async function runDevCommand(options: DevCommandOptions): Promise { enabled: !resolved.localServer, }, }); + if (!isCurrent()) { + throw new DOMException( + "A newer dev build superseded this one.", + "AbortError", + ); + } console.log("[dev] build completed."); if (shuttingDown) { @@ -2450,6 +2720,51 @@ export async function runDevCommand(options: DevCommandOptions): Promise { ); } + if (currentProcessedWorldInput) { + const lastSeed = await readRuntimeWorldSeedState( + projectRoot, + currentProcessedWorldInput.worldName, + ); + const runtimeWorldExists = runtimeState + ? Boolean( + await stat(runtimeState.worldDirectory).catch( + () => undefined, + ), + ) + : false; + const replaceProcessedWorld = + !runtimeWorldExists || + lastSeed?.sourceIdentity !== + currentProcessedWorldInput.seedIdentity; + const processedApplyMode: PipelineMode = replaceProcessedWorld + ? "restart" + : mode; + await waitForPromiseIfSlow( + localServer.apply(processedApplyMode, { + worldMode: replaceProcessedWorld + ? "replace" + : "preserve", + requireWorldSource: replaceProcessedWorld, + worldInput: replaceProcessedWorld + ? currentProcessedWorldInput + : undefined, + }), + "Preparing processed local-server world...", + 250, + { animate: false }, + ); + if (replaceProcessedWorld) { + await writeRuntimeWorldSeedState(projectRoot, { + worldName: currentProcessedWorldInput.worldName, + sourceIdentity: currentProcessedWorldInput.seedIdentity, + }); + } + console.log( + `[dev] local-server ${replaceProcessedWorld ? "reseeded from the processed world" : processedApplyMode === "reload" ? "reloaded" : "synchronized"}.`, + ); + return; + } + let localServerApplyMode: PipelineMode = mode; let localServerWorldMode: "preserve" | "replace" = "preserve"; let nextRuntimeSeedIdentity: string | undefined; @@ -2527,42 +2842,88 @@ export async function runDevCommand(options: DevCommandOptions): Promise { } }; - const enqueue = async (mode: PipelineMode) => { + const enqueue = async ( + mode: PipelineMode, + runToken: number, + options: { + reloadProcessorConfig?: boolean; + rebuildProcessedWorld?: boolean; + } = {}, + ) => { if (shuttingDown) { return; } if (running) { pendingMode = mergePipelineModes(pendingMode, mode); + pendingRunToken = runToken; + pendingProcessorConfigReload ||= Boolean( + options.reloadProcessorConfig, + ); + pendingProcessedWorldRebuild ||= Boolean( + options.rebuildProcessedWorld, + ); debug.log("watch", "queued follow-up pipeline mode", { requestedMode: mode, pendingMode, + runToken, }); return; } running = true; let nextMode: PipelineMode | undefined = mode; + let nextToken = runToken; + let nextReloadProcessorConfig = Boolean(options.reloadProcessorConfig); + let nextRebuildProcessedWorld = Boolean(options.rebuildProcessedWorld); while (nextMode && !shuttingDown) { pendingMode = undefined; + pendingRunToken = undefined; + pendingProcessorConfigReload = false; + pendingProcessedWorldRebuild = false; try { - await runPipeline(nextMode); + await runPipeline(nextMode, nextToken, { + reloadProcessorConfig: nextReloadProcessorConfig, + rebuildProcessedWorld: nextRebuildProcessedWorld, + }); } catch (error) { if (shuttingDown) { break; } - const message = - error instanceof Error ? error.message : String(error); - console.error(`[dev] ${message}`); + if (isAbortError(error)) { + debug.log("watch", "discarded superseded pipeline", { + mode: nextMode, + runToken: nextToken, + }); + } else { + const message = + error instanceof Error ? error.message : String(error); + console.error(`[dev] ${message}`); + } + } finally { + if ( + activePipelineController?.signal.aborted || + nextToken === nextRunToken + ) { + activePipelineController = undefined; + } } nextMode = shuttingDown ? undefined : pendingMode; + if (nextMode) { + nextToken = pendingRunToken ?? nextRunToken; + nextReloadProcessorConfig = pendingProcessorConfigReload; + nextRebuildProcessedWorld = pendingProcessedWorldRebuild; + } } running = false; }; - await enqueue("start"); + nextRunToken += 1; + await enqueue("start", nextRunToken, { + rebuildProcessedWorld: true, + }); if (shuttingDown) { return; @@ -2579,23 +2940,47 @@ export async function runDevCommand(options: DevCommandOptions): Promise { return; } - if (!hasActiveDevTargets(resolved)) { + if (!hasActiveDevTargets(resolved) && devWorldProcessors.length === 0) { await stopLinkServerIfLocalServerIsIdle(); return; } const debounceMs = Math.max(25, config.dev.watch.debounceMs); let scheduledMode: PipelineMode | undefined; + let scheduledRunToken: number | undefined; + let scheduledProcessorConfigReload = false; + let scheduledProcessedWorldRebuild = false; - const schedule = (mode: PipelineMode) => { + const schedule = ( + mode: PipelineMode, + options: { + reloadProcessorConfig?: boolean; + rebuildProcessedWorld?: boolean; + } = {}, + ) => { if (shuttingDown) { return; } + nextRunToken += 1; + scheduledRunToken = nextRunToken; scheduledMode = mergePipelineModes(scheduledMode, mode); + scheduledProcessorConfigReload ||= Boolean( + options.reloadProcessorConfig, + ); + scheduledProcessedWorldRebuild ||= Boolean( + options.rebuildProcessedWorld, + ); + activePipelineController?.abort( + new DOMException( + "A newer watched change superseded this dev run.", + "AbortError", + ), + ); debug.log("watch", "scheduled pipeline mode", { requestedMode: mode, scheduledMode, + scheduledRunToken, debounceMs, }); if (debounceHandle) { @@ -2603,8 +2988,17 @@ export async function runDevCommand(options: DevCommandOptions): Promise { } debounceHandle = setTimeout(() => { const modeToRun = scheduledMode ?? "start"; + const tokenToRun = scheduledRunToken ?? nextRunToken; + const reloadProcessorConfig = scheduledProcessorConfigReload; + const rebuildProcessedWorld = scheduledProcessedWorldRebuild; scheduledMode = undefined; - void enqueue(modeToRun); + scheduledRunToken = undefined; + scheduledProcessorConfigReload = false; + scheduledProcessedWorldRebuild = false; + void enqueue(modeToRun, tokenToRun, { + reloadProcessorConfig, + rebuildProcessedWorld, + }); }, debounceMs); }; @@ -2696,6 +3090,50 @@ export async function runDevCommand(options: DevCommandOptions): Promise { }); } + if (devWorldProcessors.length > 0) { + const processorWatcher = chokidar.watch(".", { + cwd: projectRoot, + ignoreInitial: true, + atomic: true, + ignored: [ + "**/.git/**", + "**/.blr/**", + "**/dist/**", + "**/node_modules/**", + ], + }); + watchers.add(processorWatcher); + debug.log("watch", "armed world processor watcher", { + cwd: projectRoot, + patterns: worldProcessorWatchPlan.patterns, + }); + processorWatcher.once("ready", () => { + announceWatching(); + debug.log("watch", "world processor watcher ready"); + }); + processorWatcher.on("error", (error: unknown) => { + const message = + error instanceof Error ? error.message : String(error); + console.error(`[dev] world processor watcher error: ${message}`); + }); + processorWatcher.on("all", (eventName: string, targetPath: string) => { + if (shuttingDown) return; + const normalizedPath = normalizeWatchPath(targetPath); + if (!worldProcessorWatchPlan.matches(normalizedPath)) return; + const action = resolveProjectWatchChangeAction(normalizedPath, { + worldProcessorPaths: worldProcessorWatchPlan.patterns, + }); + if (action.kind !== "restart") return; + console.log( + `[dev] world processor change detected: ${eventName} ${normalizedPath}`, + ); + schedule("restart", { + reloadProcessorConfig: action.reloadConfig, + rebuildProcessedWorld: true, + }); + }); + } + const state = localServer?.resolvedState; if ( localServer && diff --git a/packages/blr/src/commands/package.ts b/packages/blr/src/commands/package.ts index 346c488..da99c3f 100644 --- a/packages/blr/src/commands/package.ts +++ b/packages/blr/src/commands/package.ts @@ -52,6 +52,8 @@ import { type ExportedWorldImageFile, type ExportWorldImageResult, } from "../world-image.js"; +import { buildProcessedWorld } from "../world-processing/processor-build.js"; +import { withVerifiedWorldSnapshot } from "../world-processing/world-snapshot.js"; type PackageCommandOptions = { production?: boolean; @@ -366,7 +368,7 @@ async function packageRawWorldTarget( projectRoot: string, config: BlurProject, worldName: string, - worldSourcePath: string, + worldInputDirectory: string, format: WorldPackageFormat, layout: WorldPackageLayout, ): Promise<{ @@ -386,10 +388,7 @@ async function packageRawWorldTarget( layout === "com" ? path.join(workspaceRoot, "worlds", "world") : workspaceRoot; - await copyRawWorldPackageDirectory( - resolveProjectWorldSourceDirectory(projectRoot, worldSourcePath), - worldWorkspaceRoot, - ); + await copyRawWorldPackageDirectory(worldInputDirectory, worldWorkspaceRoot); await removeDirectory(outputFile); await writeWorldPackageArchive(workspaceRoot, outputFile, format); @@ -403,7 +402,7 @@ async function packageAssetsTarget( projectRoot: string, config: BlurProject, worldName: string, - worldSourcePath: string, + worldInputDirectory: string, ): Promise<{ workspaceRoot: string; outputFile: string; @@ -431,15 +430,19 @@ async function packageAssetsTarget( worldName, fileName, ); - const exported = await exportWorldImage({ - worldSourceDirectory: resolveProjectWorldSourceDirectory( - projectRoot, - worldSourcePath, - ), - outputPath: imageOutputPath, - dimension: config.package.assets.worldImage.dimension, - scale: config.package.assets.worldImage.scale, - }); + const exported = await withVerifiedWorldSnapshot( + { + worldName, + sourceWorldDirectory: worldInputDirectory, + }, + (snapshot) => + exportWorldImage({ + worldSourceDirectory: snapshot.worldDirectory, + outputPath: imageOutputPath, + dimension: config.package.assets.worldImage.dimension, + scale: config.package.assets.worldImage.scale, + }), + ); for (const output of exported.outputs) { manifest.assets.push( toWorldImageAssetManifestEntry( @@ -613,7 +616,7 @@ async function packageProjectTarget( projectRoot: string, config: BlurProject, worldName: string, - worldSourcePath: string, + worldInputDirectory: string, includeSelection: { behaviorPack: boolean; resourcePack: boolean; @@ -664,13 +667,7 @@ async function packageProjectTarget( await copyDirectory(packSource, workspaceRoot); } else { if (targetDefinition.includeWorld) { - await copyDirectory( - resolveProjectWorldSourceDirectory( - projectRoot, - worldSourcePath, - ), - workspaceRoot, - ); + await copyDirectory(worldInputDirectory, workspaceRoot); } else { await ensureDirectory(workspaceRoot); } @@ -829,10 +826,53 @@ export async function runPackageCommand( } } + const requiresWorld = targets.some((target) => + packageTargetRequiresWorld(target, config), + ); + const authoredWorldDirectory = resolveProjectWorldSourceDirectory( + projectRoot, + selectedWorld.worldSourcePath, + ); + const hasPackageProcessors = config.worldProcessors.some( + (processor) => + processor.sourceWorld === selectedWorld.worldName && + processor.applyOn.package, + ); + const hasPackageArtifactProcessors = config.worldProcessors.some( + (processor) => + processor.sourceWorld === selectedWorld.worldName && + processor.applyOn.package && + processor.capabilities.includes("artifact"), + ); + let worldInputDirectory = authoredWorldDirectory; + let worldProcessingHandled = false; + if (requiresWorld && hasPackageProcessors) { + const processed = await buildProcessedWorld({ + projectRoot, + worldName: selectedWorld.worldName, + sourceWorldDirectory: authoredWorldDirectory, + configs: config.worldProcessors, + pipeline: "package", + mode: "bake", + signal: new AbortController().signal, + }); + if (processed.status === "stale") { + throw new Error( + `Processed package world ${selectedWorld.worldName} was superseded.`, + ); + } + worldInputDirectory = processed.worldDirectory; + worldProcessingHandled = true; + } + await buildProject(projectRoot, config, { production, debug, pipeline: "package", + worldProcessing: { + enabled: !worldProcessingHandled && hasPackageArtifactProcessors, + verifyMutations: requiresWorld, + }, }); for (const target of targets) { @@ -842,7 +882,7 @@ export async function runPackageCommand( projectRoot, config, selectedWorld.worldName, - selectedWorld.worldSourcePath, + worldInputDirectory, worldPackageFormat, config.package.world.layout, ) @@ -851,14 +891,14 @@ export async function runPackageCommand( projectRoot, config, selectedWorld.worldName, - selectedWorld.worldSourcePath, + worldInputDirectory, ) : await packageProjectTarget( target, projectRoot, config, selectedWorld.worldName, - selectedWorld.worldSourcePath, + worldInputDirectory, includeSelection, ); console.log( diff --git a/packages/blr/src/commands/world.ts b/packages/blr/src/commands/world.ts index 8ff9880..7d89281 100644 --- a/packages/blr/src/commands/world.ts +++ b/packages/blr/src/commands/world.ts @@ -29,12 +29,14 @@ import { } from "../interactive-prompt.js"; import { readTrackedProjectWorldState } from "../project-world-state.js"; import { isPromptCancelledError, runPrompt } from "../prompt.js"; -import type { BlurProject } from "../types.js"; +import type { BlurProject, WorldPushPolicy } from "../types.js"; import { readActiveLocalServerSession, + readRuntimeWorldSeedState, writeRuntimeWorldSeedState, } from "../world-internal-state.js"; import { computeProjectWorldSourceIdentity } from "../world-source-identity.js"; +import { buildProcessedWorld } from "../world-processing/processor-build.js"; import { acquireRemoteWorldLock, describeWorldStatus, @@ -85,6 +87,7 @@ type PullWorldCommandOptions = WorldSharedOptions & { type PushWorldCommandOptions = WorldSharedOptions & { unlock?: boolean; forceLock?: boolean; + channel?: string; }; type LockWorldCommandOptions = WorldSharedOptions & { @@ -101,6 +104,24 @@ type CaptureWorldCommandOptions = WorldRuntimeOptions & { }; type WorldCommandOptions = WorldSharedOptions; +export type WorldBuildCommandOptions = WorldCommandOptions & { + check?: boolean; + dryRun?: boolean; + processor?: string | string[]; + audit?: boolean; + output?: string; + json?: boolean; +}; + +export function assertRuntimeWorldCaptureIsAuthored( + sourceIdentity: string | undefined, +): void { + if (sourceIdentity?.startsWith("processed:")) { + throw new Error( + "Refusing to capture a processed runtime world into the authored source. Restart dev in author mode before capturing builder changes.", + ); + } +} type UseWorldCommandOptions = WorldCommandOptions; type ListWorldCommandOptions = WorldCommandOptions & { json?: boolean; @@ -675,6 +696,71 @@ export async function runWorldStatusCommand( console.log(JSON.stringify(status, null, 2)); } +export async function runWorldBuildCommand( + requestedWorldName: string | undefined, + options: WorldBuildCommandOptions, +): Promise { + const { projectRoot, config } = await loadBlurConfig(process.cwd()); + const selection = resolveSelectedWorld(config, requestedWorldName); + const processorIds = options.processor + ? Array.isArray(options.processor) + ? options.processor + : [options.processor] + : undefined; + const check = options.check === true || options.dryRun === true; + const hasApplicableProcessor = config.worldProcessors.some( + (processor) => + processor.sourceWorld === selection.worldName && + processor.applyOn.worldBuild, + ); + if (!processorIds && !hasApplicableProcessor) { + const result = Object.freeze({ + status: "current" as const, + worldName: selection.worldName, + processorIds: Object.freeze([]), + diagnostics: Object.freeze([]), + }); + console.log( + options.json + ? JSON.stringify(result, null, 2) + : `No world processors apply to ${selection.worldName}; world processing is current.`, + ); + return; + } + const sourceWorldDirectory = await assertValidProjectWorldSource( + projectRoot, + selection.worldSourcePath, + `build processed world ${selection.worldName}`, + ); + const result = await buildProcessedWorld({ + projectRoot, + worldName: selection.worldName, + sourceWorldDirectory, + configs: config.worldProcessors, + ...(processorIds ? { processorIds } : {}), + ...(options.output + ? { outputDirectory: path.resolve(process.cwd(), options.output) } + : {}), + audit: options.audit === true, + mode: check ? "check" : "bake", + signal: new AbortController().signal, + }); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log( + check + ? `Processed world ${selection.worldName} is ${result.status} (${result.worldBuildId}).` + : `Processed world ${selection.worldName} ${result.status} at ${result.worldDirectory}.`, + ); + } + if (check && result.status !== "current") { + throw new Error( + `Processed world ${selection.worldName} is stale. Run blr world build ${JSON.stringify(selection.worldName)}.`, + ); + } +} + export async function runWorldLevelDatDumpCommand( requestedWorldName: string | undefined, options: WorldLevelDatDumpCommandOptions, @@ -1101,14 +1187,27 @@ export async function runWorldPushCommand( requestedWorldName, config.dev.localServer.worldName, ); + const channel = resolveWorldPushChannel(options.channel, config); + const pushOnce = (allowRemoteConflict = false) => + channel === "processed" + ? pushCurrentProcessedWorld({ + projectRoot, + config, + worldName, + options, + allowRemoteConflict, + debug, + }) + : pushWorldToS3(projectRoot, config, worldName, { + unlock: options.unlock, + forceLock: options.forceLock, + reason: options.reason, + allowRemoteConflict, + debug, + }); let pushed; try { - pushed = await pushWorldToS3(projectRoot, config, worldName, { - unlock: options.unlock, - forceLock: options.forceLock, - reason: options.reason, - debug, - }); + pushed = await pushOnce(); } catch (error) { if (!(error instanceof WorldPushRemoteConflictError)) { throw error; @@ -1125,13 +1224,7 @@ export async function runWorldPushCommand( return; } - pushed = await pushWorldToS3(projectRoot, config, worldName, { - unlock: options.unlock, - forceLock: options.forceLock, - reason: options.reason, - allowRemoteConflict: true, - debug, - }); + pushed = await pushOnce(true); } const { context, versionId } = pushed; const versionSuffix = @@ -1139,10 +1232,103 @@ export async function runWorldPushCommand( ? ` as version ${versionId}` : ""; console.log( - `[world] Pushed "${worldName}" from ${context.worldSourcePath} to s3://${context.bucket}/${context.objectKey}${versionSuffix}`, + `[world] Pushed ${pushed.channel} world "${worldName}" from ${path.relative(projectRoot, pushed.inputDirectory)} to s3://${context.bucket}/${context.objectKey}${versionSuffix}`, + ); +} + +function resolveWorldPushChannel( + value: string | undefined, + config: BlurProject, +): WorldPushPolicy { + if (value === undefined || value.trim().length === 0) { + return config.world.pushPolicy; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "authored" || normalized === "processed") { + return normalized; + } + throw new Error( + `Unknown world push channel "${value}". Expected authored or processed.`, ); } +async function pushCurrentProcessedWorld(input: { + projectRoot: string; + config: BlurProject; + worldName: string; + options: PushWorldCommandOptions; + allowRemoteConflict: boolean; + debug: ReturnType; +}) { + const acquired = await acquireRemoteWorldLock( + input.projectRoot, + input.config, + input.worldName, + { + command: "push-processed", + force: input.options.forceLock, + reason: input.options.reason, + debug: input.debug, + }, + ); + let handedToPublisher = false; + try { + const worldSourcePath = resolveSelectedWorld( + input.config, + input.worldName, + ).worldSourcePath; + const checked = await buildProcessedWorld({ + projectRoot: input.projectRoot, + worldName: input.worldName, + sourceWorldDirectory: resolveProjectWorldSourceDirectory( + input.projectRoot, + worldSourcePath, + ), + configs: input.config.worldProcessors, + pipeline: "world-build", + mode: "check", + signal: new AbortController().signal, + }).catch((error: unknown) => { + const detail = + error instanceof Error ? error.message : String(error); + throw new Error( + `Processed push for "${input.worldName}" requires a current verified world build. Run \`blr world build ${input.worldName}\` first. ${detail}`, + ); + }); + if (checked.status !== "current") { + throw new Error( + `Processed push for "${input.worldName}" is stale. Run \`blr world build ${input.worldName}\` and retry.`, + ); + } + handedToPublisher = true; + return await pushWorldToS3( + input.projectRoot, + input.config, + input.worldName, + { + channel: "processed", + worldInputDirectory: checked.worldDirectory, + worldBuildId: checked.worldBuildId, + acquiredLock: acquired, + unlock: input.options.unlock, + reason: input.options.reason, + allowRemoteConflict: input.allowRemoteConflict, + debug: input.debug, + }, + ); + } catch (error) { + if (!handedToPublisher && acquired.releaseOnFailure) { + await releaseRemoteWorldLock( + input.projectRoot, + input.config, + input.worldName, + { debug: input.debug }, + ).catch(() => undefined); + } + throw error; + } +} + export async function runWorldLockCommand( requestedWorldName: string | undefined, options: LockWorldCommandOptions, @@ -1216,6 +1402,8 @@ export async function runWorldCaptureCommand( machine, worldName, ); + const runtimeSeed = await readRuntimeWorldSeedState(projectRoot, worldName); + assertRuntimeWorldCaptureIsAuthored(runtimeSeed?.sourceIdentity); if (!(await exists(state.worldDirectory))) { throw new Error( diff --git a/packages/blr/src/config.ts b/packages/blr/src/config.ts index 88fe587..3a1b975 100644 --- a/packages/blr/src/config.ts +++ b/packages/blr/src/config.ts @@ -54,6 +54,7 @@ import { DEFAULT_WORLD_PACKAGE_FORMAT, isWorldPackageFormat, } from "./world-package-formats.js"; +import { normalizeWorldProcessorConfigs } from "./world-processing/config.js"; type LoadedBlurConfig = { projectRoot: string; @@ -216,6 +217,13 @@ function ensureWorldBackend( return value === "local" || value === "s3" ? value : fallback; } +function ensureWorldPushPolicy( + value: unknown, + fallback: import("./types.js").WorldPushPolicy, +): import("./types.js").WorldPushPolicy { + return value === "authored" || value === "processed" ? value : fallback; +} + function ensureWorldSyncProjectMode( value: unknown, fallback: WorldSyncProjectMode, @@ -368,6 +376,9 @@ function coerceBlurConfigFile( projectVersion: typeof projectVersion === "number" ? projectVersion : undefined, namespace, + worldProcessors: raw.worldProcessors as + | BlurConfigFile["worldProcessors"] + | undefined, minecraft: { channel: ensureMinecraftChannel( minecraft.channel, @@ -387,6 +398,7 @@ function coerceBlurConfigFile( }, world: { backend: ensureWorldBackend(world.backend, DEFAULT_WORLD_BACKEND), + pushPolicy: ensureWorldPushPolicy(world.pushPolicy, "authored"), s3: { bucket: ensureString(worldS3.bucket, ""), region: ensureString(worldS3.region, ""), @@ -769,6 +781,10 @@ export async function loadBlurConfig( configPath, packageJsonPath, namespace: configFile.namespace ?? "", + worldProcessors: normalizeWorldProcessorConfigs( + configFile.worldProcessors, + projectRoot, + ), minecraft: { channel: ensureMinecraftChannel( configFile.minecraft?.channel, @@ -990,6 +1006,10 @@ export async function loadBlurConfig( configFile.world?.backend, DEFAULT_WORLD_BACKEND, ), + pushPolicy: ensureWorldPushPolicy( + configFile.world?.pushPolicy, + "authored", + ), s3: { bucket: ensureString(configFile.world?.s3?.bucket, ""), region: ensureString(configFile.world?.s3?.region, ""), diff --git a/packages/blr/src/managed-project.ts b/packages/blr/src/managed-project.ts index bbbaef0..af0f7ec 100644 --- a/packages/blr/src/managed-project.ts +++ b/packages/blr/src/managed-project.ts @@ -5,10 +5,13 @@ export const MANAGED_PACKAGE_SCRIPTS = { minecraft: "blr minecraft", system: "blr system", world: "blr world", + "assets:check": "blr world build --check", clean: "blr clean", upgrade: "blr upgrade", } as const; +export const NEW_PROJECT_CHECK_SCRIPT = "npm run assets:check && blr build"; + export const MANAGED_NODE_ENGINE = ">=22.12.0"; const GITIGNORE_MANAGED_BEGIN = "# BEGIN MANAGED BY @blurengine/cli"; diff --git a/packages/blr/src/mcbe-leveldb-adapter.ts b/packages/blr/src/mcbe-leveldb-adapter.ts index 2f88282..46d3265 100644 --- a/packages/blr/src/mcbe-leveldb-adapter.ts +++ b/packages/blr/src/mcbe-leveldb-adapter.ts @@ -27,7 +27,7 @@ export type McbeLeveldbHelpers = { getChunkKeyIndices(key: Buffer): SubChunkIndexDimensionVectorXZ; generateChunkKeyFromIndices( indices: SubChunkIndexDimensionVectorXZ, - chunkKeyType: "Data3D" | "SubChunkPrefix", + chunkKeyType: "Data3D" | "SubChunkPrefix" | "BlockEntity", ): Buffer; getContentTypeFromDBKey(key: Buffer): string; getBiomeTypeFromID(id: number): string | undefined; @@ -36,6 +36,21 @@ export type McbeLeveldbHelpers = { parse(data: Buffer): Promise; serialize(data: unknown): Buffer; }; + BlockEntity: { + parse(data: Buffer): Promise<{ + type: "compound"; + value: { + blockEntities: { + type: "list"; + value: { + type: "compound"; + value: Record[]; + }; + }; + }; + }>; + serialize(data: unknown): Buffer; + }; }; }; @@ -77,6 +92,27 @@ function assertSubChunkPrefixFormat( } } +function assertBlockEntityFormat( + moduleName: string, + moduleValue: Record, +): void { + const formats = moduleValue.entryContentTypeToFormatMap; + const blockEntity = + formats && typeof formats === "object" + ? (formats as Record | undefined>) + .BlockEntity + : undefined; + if ( + !blockEntity || + typeof blockEntity.parse !== "function" || + typeof blockEntity.serialize !== "function" + ) { + throw new Error( + `${moduleName} did not provide the expected BlockEntity parse and serialize helpers.`, + ); + } +} + async function loadHelpers(): Promise { const require = createRequire(import.meta.url); const modulePath = require.resolve("mcbe-leveldb"); @@ -92,8 +128,30 @@ async function loadHelpers(): Promise { assertFunction(moduleName, moduleValue, "getContentTypeFromDBKey"); assertFunction(moduleName, moduleValue, "getBiomeTypeFromID"); assertSubChunkPrefixFormat(moduleName, moduleValue); + assertBlockEntityFormat(moduleName, moduleValue); - return moduleValue as McbeLeveldbHelpers; + const helpers = moduleValue as McbeLeveldbHelpers; + return Object.freeze({ + readData3dValue: helpers.readData3dValue, + writeData3DValue: helpers.writeData3DValue, + getChunkKeyIndices: helpers.getChunkKeyIndices, + generateChunkKeyFromIndices( + indices: Parameters< + McbeLeveldbHelpers["generateChunkKeyFromIndices"] + >[0], + chunkKeyType: Parameters< + McbeLeveldbHelpers["generateChunkKeyFromIndices"] + >[1], + ) { + return helpers.generateChunkKeyFromIndices( + { ...indices }, + chunkKeyType, + ); + }, + getContentTypeFromDBKey: helpers.getContentTypeFromDBKey, + getBiomeTypeFromID: helpers.getBiomeTypeFromID, + entryContentTypeToFormatMap: helpers.entryContentTypeToFormatMap, + }); } export function loadMcbeLeveldbHelpers(): Promise { diff --git a/packages/blr/src/runtime.ts b/packages/blr/src/runtime.ts index a1e5405..fdbdea5 100644 --- a/packages/blr/src/runtime.ts +++ b/packages/blr/src/runtime.ts @@ -37,6 +37,11 @@ import type { BlurProject, MinecraftProduct, } from "./types.js"; +import { + assertValidProjectWorldSource, + resolveConfiguredWorldSourcePath, +} from "./world.js"; +import { runWorldProcessorArtifactPipeline } from "./world-processing/processor-build.js"; export type MinecraftDevelopmentRootResolution = { explicitPath?: string; @@ -60,6 +65,12 @@ type BuildProjectOptions = { zoneEditor?: { enabled?: boolean; }; + worldProcessing?: { + enabled?: boolean; + signal?: AbortSignal; + isCurrent?: () => boolean; + verifyMutations?: boolean; + }; }; type RuntimeBuildEntry = { @@ -861,6 +872,9 @@ export async function buildProject( throw new Error("Runtime scripts require a BDS behavior pack."); } + if (options.worldProcessing?.enabled !== false) { + await runBuildWorldProcessors(projectRoot, config, pipeline, options); + } await stageProjectContent(projectRoot, config, artifacts, options.debug); const bakedBebeAssets = await bakeBebeAssets({ debug: options.debug, @@ -1018,6 +1032,61 @@ export async function buildProject( return artifacts; } +async function runBuildWorldProcessors( + projectRoot: string, + config: BlurProject, + pipeline: BebePipelineIntent, + options: BuildProjectOptions, +): Promise { + const worldNames = [ + ...new Set( + config.worldProcessors + .filter((processor) => { + switch (pipeline) { + case "dev": + return processor.applyOn.dev; + case "package": + return processor.applyOn.package; + case "build": + return processor.applyOn.build; + case "check": + return processor.applyOn.check; + } + }) + .map((processor) => processor.sourceWorld), + ), + ].sort(); + const signal = + options.worldProcessing?.signal ?? new AbortController().signal; + for (const worldName of worldNames) { + const worldSourcePath = resolveConfiguredWorldSourcePath( + config, + worldName, + ); + const sourceWorldDirectory = await assertValidProjectWorldSource( + projectRoot, + worldSourcePath, + `run ${pipeline} world processors for ${worldName}`, + ); + const result = await runWorldProcessorArtifactPipeline({ + projectRoot, + worldName, + sourceWorldDirectory, + configs: config.worldProcessors, + pipeline, + mode: pipeline === "check" ? "check" : "bake", + signal, + isCurrent: options.worldProcessing?.isCurrent, + verifyMutations: options.worldProcessing?.verifyMutations, + }); + if (result.status === "stale") { + throw new Error( + `World processor run for ${worldName} was superseded before publication.`, + ); + } + } +} + export async function cleanProject(projectRoot: string): Promise { await removeDirectory(path.resolve(projectRoot, "dist")); } diff --git a/packages/blr/src/types.ts b/packages/blr/src/types.ts index acde251..cfb9772 100644 --- a/packages/blr/src/types.ts +++ b/packages/blr/src/types.ts @@ -4,6 +4,7 @@ export type Language = "ts" | "js"; export type VersionTuple = [number, number, number]; export type WorldBackend = "local" | "s3"; +export type WorldPushPolicy = "authored" | "processed"; export type MinecraftChannel = "stable" | "preview"; export type WorldSyncProjectMode = "prompt" | "auto" | "manual"; export type WorldSyncRuntimeMode = "prompt" | "preserve" | "replace" | "backup"; @@ -13,6 +14,57 @@ export type WorldImageDimension = "overworld" | "nether" | "end"; export type BebeDiagnosticSeverity = "ignore" | "warn" | "error"; export type BebeDiagnosticPipeline = "dev" | "build" | "package" | "check"; +export type WorldProcessorCapability = "observer" | "artifact" | "transform"; + +export interface WorldProcessorApplyOn { + dev: boolean; + build: boolean; + package: boolean; + check: boolean; + worldBuild: boolean; + worldPush: boolean; +} + +export interface BlurConfigWorldProcessorApplyOnFile { + dev?: boolean; + build?: boolean; + package?: boolean; + check?: boolean; + worldBuild?: boolean; + worldPush?: boolean; +} + +/** Trusted project processor that derives artifacts or staged world mutations. */ +export interface BlurConfigWorldProcessorFile { + id: string; + module: string; + export?: string; + sourceWorld: string; + capabilities?: WorldProcessorCapability[]; + dependsOn?: string[]; + inputPaths?: string[]; + outputRoot?: string; + payloadFileNames?: Record; + runtimePointerPath?: string; + auditOutputPath?: string; + applyOn?: BlurConfigWorldProcessorApplyOnFile; +} + +export interface ResolvedWorldProcessorConfig { + id: string; + module: string; + export: string; + sourceWorld: string; + capabilities: readonly WorldProcessorCapability[]; + dependsOn: readonly string[]; + inputPaths: readonly string[]; + outputRoot?: string; + payloadFileNames: Readonly>; + runtimePointerPath?: string; + auditOutputPath?: string; + applyOn: Readonly; +} + export type MinecraftProduct = | "BedrockGDK" | "PreviewGDK" @@ -116,6 +168,11 @@ export interface BlurConfigWorldFile { * World source backend. Use `s3` to enable `blr world pull/push` against an S3-compatible store. */ backend?: WorldBackend; + /** + * Which remote channel `blr world push` publishes by default. Processed + * pushes require a current verified `blr world build` output. + */ + pushPolicy?: WorldPushPolicy; /** * S3-compatible world backend coordinates. */ @@ -501,6 +558,10 @@ export interface BlurConfigFile { * Project-level defaults for `blr dev`. */ dev?: BlurConfigDevFile; + /** + * Trusted project processors that derive artifacts or staged world mutations from an immutable selected world. + */ + worldProcessors?: BlurConfigWorldProcessorFile[]; } export interface PackManifestConfig { @@ -531,6 +592,7 @@ export interface BlurProject { configPath: string; packageJsonPath: string; namespace: string; + worldProcessors: readonly ResolvedWorldProcessorConfig[]; minecraft: { channel: MinecraftChannel; targetVersion: string; @@ -624,6 +686,7 @@ export interface BlurProject { }; world: { backend: WorldBackend; + pushPolicy: WorldPushPolicy; s3: { bucket: string; region: string; diff --git a/packages/blr/src/world-backend.ts b/packages/blr/src/world-backend.ts index 9789008..a7a4eb7 100644 --- a/packages/blr/src/world-backend.ts +++ b/packages/blr/src/world-backend.ts @@ -33,9 +33,11 @@ import { } from "./project-world-state.js"; import { markProjectWorldMaterializedFromRemote, + markProcessedWorldPublished, readMaterializedProjectWorldRemoteState, + readProcessedWorldPublicationState, } from "./world-internal-state.js"; -import type { BlurProject } from "./types.js"; +import type { BlurProject, WorldPushPolicy } from "./types.js"; import { getCliPackageVersion } from "./utils.js"; import { assertValidProjectWorldSource, @@ -67,12 +69,14 @@ type RemoteLockSnapshot = { etag?: string; }; -type AcquiredWorldLock = { +export type AcquiredWorldLock = { + worldName: string; lock: WorldLockRecord; releaseOnFailure: boolean; }; type ResolvedWorldS3Context = { + channel: WorldPushPolicy; worldName: string; worldSourcePath: string; worldSourceDirectory: string; @@ -111,6 +115,10 @@ type PushWorldOptions = { reason?: string; allowRemoteConflict?: boolean; debug?: DebugLogger; + channel?: WorldPushPolicy; + worldInputDirectory?: string; + worldBuildId?: string; + acquiredLock?: AcquiredWorldLock; }; type ReleaseWorldLockOptions = { @@ -176,6 +184,9 @@ export type PushWorldResult = { context: ResolvedWorldS3Context; versioning: WorldVersioningStatus; versionId?: string; + channel: WorldPushPolicy; + inputDirectory: string; + worldBuildId?: string; }; export type PullWorldResult = { @@ -267,6 +278,8 @@ const WORLD_OBJECT_METADATA_KEYS = { cliVersion: "blr-cli-version", projectName: "blr-project-name", packageName: "blr-package-name", + channel: "blr-world-channel", + worldBuildId: "blr-world-build-id", } as const; function toObjectKeySegment(value: string, fallback: string): string { @@ -591,6 +604,8 @@ function createWorldObjectMetadata(input: { packageName: string; cliVersion: string; reason?: string; + channel?: WorldPushPolicy; + worldBuildId?: string; }): Record { const metadata: Record = { [WORLD_OBJECT_METADATA_KEYS.actor]: `${input.actor.userName}@${input.actor.hostName}`, @@ -603,6 +618,12 @@ function createWorldObjectMetadata(input: { if (trimmedReason) { metadata[WORLD_OBJECT_METADATA_KEYS.reason] = trimmedReason; } + if (input.channel) { + metadata[WORLD_OBJECT_METADATA_KEYS.channel] = input.channel; + } + if (input.worldBuildId) { + metadata[WORLD_OBJECT_METADATA_KEYS.worldBuildId] = input.worldBuildId; + } return metadata; } @@ -644,6 +665,26 @@ function resolveTrackedWorldBinding( }; } +async function readProcessedWorldBinding( + projectRoot: string, + context: ResolvedWorldS3Context, +): Promise { + const published = await readProcessedWorldPublicationState( + projectRoot, + context.worldName, + ); + return resolveTrackedWorldBinding( + context, + published + ? { + name: context.worldName, + remoteFingerprint: published.remoteFingerprint, + versionId: published.versionId, + } + : undefined, + ); +} + function createS3ClientForWorld(config: BlurProject): S3Client { const region = config.world.s3.region.trim() || @@ -661,6 +702,7 @@ function resolveWorldS3Context( projectRoot: string, config: BlurProject, worldName: string, + channel: WorldPushPolicy = "authored", ): ResolvedWorldS3Context { requireS3WorldBackend(config); @@ -690,6 +732,7 @@ function resolveWorldS3Context( "worlds", toCacheSegment(config.world.s3.bucket.trim(), "bucket"), toCacheSegment(worldSegment, "world"), + channel, ); const legacyCacheDirectory = path.resolve( projectRoot, @@ -706,6 +749,7 @@ function resolveWorldS3Context( ); return { + channel, worldName, worldSourcePath, worldSourceDirectory, @@ -718,7 +762,11 @@ function resolveWorldS3Context( keyNamespace, projectPrefix: config.world.s3.projectPrefix, forcePathStyle: config.world.s3.forcePathStyle, - objectKey: joinObjectKey(keyNamespace, objectFileName), + objectKey: joinObjectKey( + keyNamespace, + channel === "processed" ? "processed" : undefined, + objectFileName, + ), lockKey: joinObjectKey(keyNamespace, lockFileName), }; } @@ -1379,6 +1427,7 @@ export async function acquireRemoteWorldLock( expiresAt: lock.expiresAt, }); return { + worldName, lock, releaseOnFailure, }; @@ -1588,27 +1637,55 @@ export async function pushWorldToS3( worldName: string, options: PushWorldOptions = {}, ): Promise { - const context = resolveWorldS3Context(projectRoot, config, worldName); + const channel = options.channel ?? "authored"; + const context = resolveWorldS3Context( + projectRoot, + config, + worldName, + channel, + ); const client = createS3ClientForWorld(config); const versioning = await resolveWorldVersioningStatus(client, context); assertWorldVersioningAvailable(versioning); - await assertValidProjectWorldSource( - projectRoot, - context.worldSourcePath, - `push world "${worldName}"`, - ); + let inputDirectory: string; + if (channel === "processed") { + if (!options.worldInputDirectory || !options.worldBuildId) { + throw new Error( + `Processed world push for "${worldName}" requires an exact verified world build input and build id.`, + ); + } + inputDirectory = path.resolve(options.worldInputDirectory); + if (!(await exists(path.join(inputDirectory, "db")))) { + throw new Error( + `Processed world build ${options.worldBuildId} is missing its db directory at ${inputDirectory}.`, + ); + } + if (inputDirectory === path.resolve(context.worldSourceDirectory)) { + throw new Error( + "A processed world push cannot alias the authored source directory.", + ); + } + } else { + inputDirectory = await assertValidProjectWorldSource( + projectRoot, + context.worldSourcePath, + `push world "${worldName}"`, + ); + } - const lockHandle = await acquireRemoteWorldLock( - projectRoot, - config, - worldName, - { - command: "push", + const lockHandle = + options.acquiredLock ?? + (await acquireRemoteWorldLock(projectRoot, config, worldName, { + command: channel === "processed" ? "push-processed" : "push", force: options.forceLock, reason: options.reason, debug: options.debug, - }, - ); + })); + if (lockHandle.worldName !== worldName) { + throw new Error( + `Remote lock for ${lockHandle.worldName} cannot publish ${worldName}.`, + ); + } let shouldReleaseLock = true; const archivePath = resolveTemporaryWorldArchivePath(context); @@ -1616,7 +1693,9 @@ export async function pushWorldToS3( try { const [latestRemote, tracked] = await Promise.all([ readRemoteWorldObjectMetadata(client, context), - readTrackedWorldBinding(projectRoot, context), + channel === "processed" + ? readProcessedWorldBinding(projectRoot, context) + : readTrackedWorldBinding(projectRoot, context), ]); if (!options.allowRemoteConflict) { @@ -1627,7 +1706,7 @@ export async function pushWorldToS3( }); } - await createWorldArchive(context.worldSourceDirectory, archivePath); + await createWorldArchive(inputDirectory, archivePath); const payload = await readFile(archivePath); const response = await client.send( new PutObjectCommand({ @@ -1641,6 +1720,8 @@ export async function pushWorldToS3( packageName: config.project.packageName, cliVersion: lockHandle.lock.cliVersion, reason: options.reason ?? lockHandle.lock.reason, + channel, + worldBuildId: options.worldBuildId, }), }), ); @@ -1650,26 +1731,38 @@ export async function pushWorldToS3( ); } - await persistTrackedProjectWorldState(projectRoot, context, { - versionId: response.VersionId, - }); - await markProjectWorldMaterializedFromRemote(projectRoot, { - worldName, - remoteFingerprint: buildTrackedProjectWorldFingerprint({ - backend: "s3", - bucket: context.bucket, - endpoint: context.endpoint, - objectKey: context.objectKey, - }), - versionId: response.VersionId, + const remoteFingerprint = buildTrackedProjectWorldFingerprint({ + backend: "s3", + bucket: context.bucket, + endpoint: context.endpoint, + objectKey: context.objectKey, }); + if (channel === "processed") { + await markProcessedWorldPublished(projectRoot, { + worldName, + remoteFingerprint, + versionId: response.VersionId, + worldBuildId: options.worldBuildId!, + }); + } else { + await persistTrackedProjectWorldState(projectRoot, context, { + versionId: response.VersionId, + }); + await markProjectWorldMaterializedFromRemote(projectRoot, { + worldName, + remoteFingerprint, + versionId: response.VersionId, + }); + } options.debug?.log("world", "pushed world to s3", { worldName, bucket: context.bucket, objectKey: context.objectKey, versionId: response.VersionId, archivePath, - worldSourceDirectory: context.worldSourceDirectory, + channel, + inputDirectory, + worldBuildId: options.worldBuildId, }); await removeDirectory(archivePath); await pruneWorldCacheDirectory(context); @@ -1686,6 +1779,11 @@ export async function pushWorldToS3( context, versioning, versionId: response.VersionId, + channel, + inputDirectory, + ...(options.worldBuildId + ? { worldBuildId: options.worldBuildId } + : {}), }; } catch (error) { if (shouldReleaseLock && lockHandle.releaseOnFailure) { diff --git a/packages/blr/src/world-input.ts b/packages/blr/src/world-input.ts new file mode 100644 index 0000000..b4b2de5 --- /dev/null +++ b/packages/blr/src/world-input.ts @@ -0,0 +1,8 @@ +export type ResolvedWorldInput = { + readonly kind: "authored" | "processed"; + readonly worldName: string; + /** Absolute directory containing a complete Bedrock world. */ + readonly directory: string; + /** Stable lineage identity recorded when this input seeds a runtime. */ + readonly seedIdentity: string; +}; diff --git a/packages/blr/src/world-internal-state.ts b/packages/blr/src/world-internal-state.ts index 5c6f081..226d409 100644 --- a/packages/blr/src/world-internal-state.ts +++ b/packages/blr/src/world-internal-state.ts @@ -12,6 +12,13 @@ export type RuntimeWorldSeedState = { seededAt: string; }; +export type ProcessedWorldPublicationState = { + remoteFingerprint: string; + versionId: string; + worldBuildId: string; + publishedAt: string; +}; + export type LocalServerSessionState = { processId: number; worldName: string; @@ -22,6 +29,7 @@ export type LocalServerSessionState = { type InternalWorldStateEntry = { name: string; materializedRemote?: MaterializedProjectWorldRemoteState; + processedPublication?: ProcessedWorldPublicationState; runtimeSeed?: RuntimeWorldSeedState; }; @@ -94,6 +102,23 @@ function normalizeRuntimeSeedState( }; } +function normalizeProcessedWorldPublicationState( + value: unknown, +): ProcessedWorldPublicationState | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const record = value as Record; + const remoteFingerprint = normalizeString(record.remoteFingerprint); + const versionId = normalizeString(record.versionId); + const worldBuildId = normalizeString(record.worldBuildId); + const publishedAt = normalizeString(record.publishedAt); + if (!remoteFingerprint || !versionId || !worldBuildId || !publishedAt) { + return undefined; + } + return { remoteFingerprint, versionId, worldBuildId, publishedAt }; +} + function normalizeInternalWorldStateEntry( value: unknown, ): InternalWorldStateEntry | undefined { @@ -112,6 +137,9 @@ function normalizeInternalWorldStateEntry( materializedRemote: normalizeMaterializedRemoteState( record.materializedRemote, ), + processedPublication: normalizeProcessedWorldPublicationState( + record.processedPublication, + ), runtimeSeed: normalizeRuntimeSeedState(record.runtimeSeed), }; } @@ -247,6 +275,39 @@ export async function markProjectWorldMaterializedFromRemote( materializedAt: input.materializedAt ?? new Date().toISOString(), }, runtimeSeed: current?.runtimeSeed, + processedPublication: current?.processedPublication, + })); +} + +export async function readProcessedWorldPublicationState( + projectRoot: string, + worldName: string, +): Promise { + const state = await readInternalWorldState(projectRoot); + return state.worlds.find((entry) => entry.name === worldName) + ?.processedPublication; +} + +export async function markProcessedWorldPublished( + projectRoot: string, + input: { + worldName: string; + remoteFingerprint: string; + versionId: string; + worldBuildId: string; + publishedAt?: string; + }, +): Promise { + await updateInternalWorldEntry(projectRoot, input.worldName, (current) => ({ + name: input.worldName, + materializedRemote: current?.materializedRemote, + runtimeSeed: current?.runtimeSeed, + processedPublication: { + remoteFingerprint: input.remoteFingerprint, + versionId: input.versionId, + worldBuildId: input.worldBuildId, + publishedAt: input.publishedAt ?? new Date().toISOString(), + }, })); } @@ -269,6 +330,7 @@ export async function writeRuntimeWorldSeedState( await updateInternalWorldEntry(projectRoot, input.worldName, (current) => ({ name: input.worldName, materializedRemote: current?.materializedRemote, + processedPublication: current?.processedPublication, runtimeSeed: { sourceIdentity: input.sourceIdentity, seededAt: input.seededAt ?? new Date().toISOString(), @@ -281,12 +343,13 @@ export async function clearRuntimeWorldSeedState( worldName: string, ): Promise { await updateInternalWorldEntry(projectRoot, worldName, (current) => { - if (!current?.materializedRemote) { + if (!current?.materializedRemote && !current?.processedPublication) { return undefined; } return { name: current.name, materializedRemote: current.materializedRemote, + processedPublication: current.processedPublication, }; }); } diff --git a/packages/blr/src/world-processing.ts b/packages/blr/src/world-processing.ts new file mode 100644 index 0000000..ab16bd4 --- /dev/null +++ b/packages/blr/src/world-processing.ts @@ -0,0 +1,356 @@ +import type { + ResolvedWorldProcessorConfig, + WorldProcessorApplyOn, + WorldProcessorCapability, +} from "./types.js"; +import { + canonicalJson, + hashCanonicalJson, +} from "./world-processing/canonical-json.js"; + +export type { + BlurConfigWorldProcessorFile, + ResolvedWorldProcessorConfig, + WorldProcessorApplyOn, + WorldProcessorCapability, +} from "./types.js"; + +export type WorldProcessorPipelineIntent = + | "dev" + | "build" + | "package" + | "check" + | "world-build" + | "world-push"; + +/** Canonical JSON bytes used for immutable world-processor artifacts. */ +export function canonicalizeWorldDerivedJson(input: unknown): string { + return canonicalJson(input); +} + +/** Lowercase SHA-256 of {@link canonicalizeWorldDerivedJson}. */ +export function hashWorldDerivedJson(input: unknown): string { + return hashCanonicalJson(input); +} + +export type WorldProcessorMode = "check" | "bake"; +export type WorldDimensionId = "overworld" | "nether" | "the_end"; +export type WorldBlockStateValue = boolean | number | string; +export type WorldDerivedJsonPrimitive = boolean | number | string | null; +export type WorldDerivedJsonValue = + | WorldDerivedJsonPrimitive + | readonly WorldDerivedJsonValue[] + | { readonly [key: string]: WorldDerivedJsonValue }; + +export type WorldBlockLocation = { + readonly x: number; + readonly y: number; + readonly z: number; +}; + +/** Inclusive integer world bounds. */ +export type WorldBlockBounds = { + readonly min: WorldBlockLocation; + readonly max: WorldBlockLocation; +}; + +export type WorldBlockPaletteEntry = { + readonly typeId: string; + readonly states: Readonly>; + readonly version: number; +}; + +export type WorldStandingSignOrientation = { + readonly kind: "standing"; + readonly groundSignDirection: + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15; + readonly yawDegrees: number; +}; + +export type WorldWallSignOrientation = { + readonly kind: "wall"; + readonly facingDirection: 2 | 3 | 4 | 5; + readonly yawDegrees: number; +}; + +export type WorldHangingSignOrientation = { + readonly kind: "hanging"; + readonly states: Readonly>; +}; + +export type WorldSignOrientation = + | WorldStandingSignOrientation + | WorldWallSignOrientation + | WorldHangingSignOrientation; + +export type WorldSignFaceObservation = { + readonly face: "front" | "back"; + readonly rawText: string; + readonly normalizedText: string; + readonly lines: readonly string[]; +}; + +export type WorldBlockEntityObservation = { + readonly id: string; + readonly location: WorldBlockLocation; + readonly value: WorldDerivedJsonValue; + readonly signFaces?: readonly WorldSignFaceObservation[]; +}; + +export type WorldBlockObservation = { + readonly dimension: WorldDimensionId; + readonly location: WorldBlockLocation; + readonly layer: number; + readonly palette: WorldBlockPaletteEntry; + readonly blockEntity?: WorldBlockEntityObservation; + readonly signOrientation?: WorldSignOrientation; +}; + +export type WorldSignBlockEntityObservation = Omit< + WorldBlockEntityObservation, + "id" | "signFaces" +> & { + readonly id: "Sign"; + readonly signFaces: readonly WorldSignFaceObservation[]; +}; + +export type WorldSignObservation = Omit< + WorldBlockObservation, + "blockEntity" | "layer" | "signOrientation" +> & { + readonly layer: 0; + readonly blockEntity: WorldSignBlockEntityObservation; + readonly signOrientation: WorldSignOrientation; +}; + +export type WorldObservationQuery = { + readonly dimension: WorldDimensionId; + readonly bounds: WorldBlockBounds; + readonly typeIds?: readonly string[]; + readonly includeAir?: boolean; +}; + +export type WorldSignObservationQuery = { + readonly dimension?: WorldDimensionId; + readonly bounds?: WorldBlockBounds; +}; + +export interface WorldObservationFacade { + blocks(query: WorldObservationQuery): AsyncIterable; + /** Streams coherent sign blocks by their block entities without scanning a world volume. */ + signs( + query?: WorldSignObservationQuery, + ): AsyncIterable; +} + +export type WorldSourceIdentity = { + readonly worldName: string; + readonly contentHash: string; + readonly sourceRevision?: string; + readonly sourceVersion?: string; +}; + +export type WorldProcessorDeclaredLogicalInput = + | { + readonly id: string; + readonly kind: "value"; + readonly value: string; + } + | { + readonly id: string; + readonly kind: "file"; + readonly path: string; + }; + +export type WorldProcessorMaterializedLogicalInput = { + readonly id: string; + readonly kind: "value" | "file" | "world" | "provider" | "schema"; + readonly contentHash: string; +}; + +export interface WorldProcessorInputFile { + readonly id: string; + readonly relativePath: string; + readonly contentHash: string; + readonly size: number; + read(): Promise; +} + +export type WorldProcessorDiagnostic = { + readonly code: string; + readonly severity: "info" | "warning" | "error"; + readonly message: string; + readonly location?: WorldBlockLocation; +}; + +export type WorldDerivedArtifactHashReference = { + readonly artifactId: string; + readonly jsonPointer: string; +}; + +export type WorldProcessorArtifact = { + readonly id: string; + readonly value: WorldDerivedJsonValue; + readonly hashReferences?: readonly WorldDerivedArtifactHashReference[]; +}; + +export type WorldDerivedArtifactSetMemberV1 = { + readonly id: string; + readonly fileName: string; + readonly contentHash: string; +}; + +export type WorldDerivedArtifactSetManifestV1 = { + readonly schemaVersion: 1; + readonly artifactSetId: string; + readonly logicalInputHash: string; + readonly providerId: string; + readonly providerRevision: string; + /** Payload members only. The detached manifest never hashes itself. */ + readonly members: readonly WorldDerivedArtifactSetMemberV1[]; +}; + +export type WorldDerivedArtifactSetPointerMember = + WorldDerivedArtifactSetMemberV1 & { + readonly value: T; + }; + +export type WorldDerivedArtifactSetPointer< + TPayloads extends Readonly>, +> = { + readonly schemaVersion: 1; + readonly artifactSetId: string; + readonly logicalInputHash: string; + readonly providerId: string; + readonly providerRevision: string; + readonly manifestContentHash: string; + readonly members: { + readonly [K in keyof TPayloads]: WorldDerivedArtifactSetPointerMember< + TPayloads[K] + >; + }; +}; + +export type WorldPaletteReplacement = + | { + readonly kind: "literal"; + readonly palette: WorldBlockPaletteEntry; + } + | { + readonly kind: "observed"; + readonly dimension: WorldDimensionId; + readonly location: WorldBlockLocation; + readonly layer: number; + }; + +export type WorldBlockEntityPolicy = + | "require-absent" + | "preserve-compatible" + | "remove"; + +export type WorldSetBlockMutation = { + readonly kind: "set-block"; + readonly opId: string; + readonly dimension: WorldDimensionId; + readonly location: WorldBlockLocation; + readonly layer: number; + readonly expectedPalette: WorldBlockPaletteEntry; + readonly replacement: WorldPaletteReplacement; + readonly blockEntityPolicy: WorldBlockEntityPolicy; +}; + +export type WorldRemoveBlockEntityMutation = { + readonly kind: "remove-block-entity"; + readonly opId: string; + readonly dimension: WorldDimensionId; + readonly location: WorldBlockLocation; + readonly expectedId: string; +}; + +export type WorldReplaceBlocksMutation = { + readonly kind: "replace-blocks"; + readonly opId: string; + readonly dimension: WorldDimensionId; + readonly bounds: WorldBlockBounds; + readonly layer: number; + readonly matchPalette: WorldBlockPaletteEntry; + readonly replacement: WorldPaletteReplacement; + readonly blockEntityPolicy: WorldBlockEntityPolicy; +}; + +export type WorldConsumeSignMutation = { + readonly kind: "consume-sign"; + readonly opId: string; + readonly dimension: WorldDimensionId; + readonly location: WorldBlockLocation; + readonly expectedPalette: WorldBlockPaletteEntry; + readonly expectedBlockEntityId: "Sign"; + readonly replacement: WorldPaletteReplacement; + readonly otherLayerPolicy: "preserve"; +}; + +export type WorldMutation = + | WorldSetBlockMutation + | WorldRemoveBlockEntityMutation + | WorldReplaceBlocksMutation + | WorldConsumeSignMutation; + +export type WorldMutationPlan = { + readonly version: 1; + readonly mutations: readonly WorldMutation[]; +}; + +export type WorldProcessorInput = { + readonly config: ResolvedWorldProcessorConfig; + readonly sourceIdentity: WorldSourceIdentity; + readonly observations: WorldObservationFacade; + readonly inputFiles: readonly WorldProcessorInputFile[]; + readonly logicalInputs: readonly WorldProcessorMaterializedLogicalInput[]; + readonly dependencies: Readonly>; + readonly signal: AbortSignal; +}; + +export type WorldProcessorResult = { + readonly logicalInputs: readonly WorldProcessorMaterializedLogicalInput[]; + readonly artifacts: readonly WorldProcessorArtifact[]; + /** Optional rich, canonical JSON written only when the caller requests an audit. */ + readonly audit?: WorldDerivedJsonValue; + readonly diagnostics: readonly WorldProcessorDiagnostic[]; + readonly mutations: readonly WorldMutation[]; +}; + +export interface WorldProcessor { + readonly implementationRevision: string; + readonly logicalInputs: readonly WorldProcessorDeclaredLogicalInput[]; + run(input: WorldProcessorInput): Promise; +} + +export type WorldProcessorFactory = () => WorldProcessor; + +export function defineWorldProcessor( + factory: WorldProcessorFactory, +): WorldProcessorFactory { + return factory; +} + +// Keep config vocabulary reachable from the SDK without creating a runtime +// dependency on the config loader. +export type WorldProcessorConfigVocabulary = { + readonly capability: WorldProcessorCapability; + readonly applyOn: WorldProcessorApplyOn; +}; diff --git a/packages/blr/src/world-processing/artifact-set.ts b/packages/blr/src/world-processing/artifact-set.ts new file mode 100644 index 0000000..6a5520e --- /dev/null +++ b/packages/blr/src/world-processing/artifact-set.ts @@ -0,0 +1,436 @@ +import { randomUUID } from "node:crypto"; +import { + mkdir, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import type { + ResolvedWorldProcessorConfig, + WorldDerivedArtifactSetManifestV1, + WorldDerivedArtifactSetMemberV1, + WorldDerivedJsonValue, + WorldProcessorArtifact, +} from "../world-processing.js"; +import type { WorldProcessorExecutionResult } from "./runner.js"; +import { canonicalJson, hashCanonicalJson } from "./canonical-json.js"; + +export type PublishWorldProcessorArtifactSetOptions = { + readonly projectRoot: string; + readonly config: ResolvedWorldProcessorConfig; + readonly execution: WorldProcessorExecutionResult; + readonly mode: "check" | "bake"; + readonly isCurrent?: () => boolean; + readonly hooks?: { + readonly beforePointerReplace?: () => void | Promise; + }; +}; + +export type PublishedWorldProcessorArtifactSet = { + readonly status: "current" | "published" | "stale"; + readonly artifactSetId: string; + readonly manifestContentHash: string; + readonly setDirectory: string; + readonly pointerPath?: string; +}; + +type PreparedPayload = WorldDerivedArtifactSetMemberV1 & { + readonly bytes: Buffer; + readonly value: WorldDerivedJsonValue; +}; + +export async function publishWorldProcessorArtifactSet( + options: PublishWorldProcessorArtifactSetOptions, +): Promise { + const prepared = prepareArtifactSet(options); + const outputRoot = path.resolve( + options.projectRoot, + options.config.outputRoot!, + ); + const setDirectory = path.join( + outputRoot, + "sets", + prepared.manifest.artifactSetId, + ); + const pointerPath = options.config.runtimePointerPath + ? path.resolve(options.projectRoot, options.config.runtimePointerPath) + : undefined; + const pointerBytes = pointerPath + ? Buffer.from( + renderPointer( + pointerPath, + setDirectory, + prepared.manifest, + prepared.manifestContentHash, + prepared.payloads, + ), + ) + : undefined; + const setCurrent = await artifactSetMatches( + setDirectory, + prepared.payloads, + prepared.manifestBytes, + ); + const pointerCurrent = pointerPath + ? await fileMatches(pointerPath, pointerBytes!) + : true; + + if (options.mode === "check") { + return Object.freeze({ + status: setCurrent && pointerCurrent ? "current" : "stale", + artifactSetId: prepared.manifest.artifactSetId, + manifestContentHash: prepared.manifestContentHash, + setDirectory, + ...(pointerPath ? { pointerPath } : {}), + }); + } + + if (await pathExists(setDirectory)) { + if (!setCurrent) { + throw new Error( + `Existing immutable artifact set ${prepared.manifest.artifactSetId} does not match its canonical payloads.`, + ); + } + } else { + await publishImmutableSet( + setDirectory, + prepared.payloads, + prepared.manifestBytes, + ); + } + if (pointerCurrent) { + return Object.freeze({ + status: "current", + artifactSetId: prepared.manifest.artifactSetId, + manifestContentHash: prepared.manifestContentHash, + setDirectory, + ...(pointerPath ? { pointerPath } : {}), + }); + } + if (pointerPath && pointerBytes) { + if (options.isCurrent && !options.isCurrent()) { + return staleResult(); + } + await options.hooks?.beforePointerReplace?.(); + if (options.isCurrent && !options.isCurrent()) { + return staleResult(); + } + await replacePointer(pointerPath, pointerBytes); + } + return Object.freeze({ + status: "published", + artifactSetId: prepared.manifest.artifactSetId, + manifestContentHash: prepared.manifestContentHash, + setDirectory, + ...(pointerPath ? { pointerPath } : {}), + }); + + function staleResult(): PublishedWorldProcessorArtifactSet { + return Object.freeze({ + status: "stale", + artifactSetId: prepared.manifest.artifactSetId, + manifestContentHash: prepared.manifestContentHash, + setDirectory, + ...(pointerPath ? { pointerPath } : {}), + }); + } +} + +function prepareArtifactSet(options: PublishWorldProcessorArtifactSetOptions): { + readonly payloads: readonly PreparedPayload[]; + readonly manifest: WorldDerivedArtifactSetManifestV1; + readonly manifestBytes: Buffer; + readonly manifestContentHash: string; +} { + if (!options.config.outputRoot) { + throw new Error( + `World processor ${options.config.id} has no artifact outputRoot.`, + ); + } + if (options.execution.processorId !== options.config.id) { + throw new Error( + `Artifact execution ${options.execution.processorId} does not match config ${options.config.id}.`, + ); + } + const expectedIds = Object.keys(options.config.payloadFileNames).sort(); + const artifacts = new Map(); + for (const artifact of options.execution.result.artifacts) { + if (artifacts.has(artifact.id)) { + throw new Error( + `World processor returned duplicate artifact ${artifact.id}.`, + ); + } + artifacts.set(artifact.id, artifact); + } + const actualIds = [...artifacts.keys()].sort(); + if (canonicalJson(actualIds) !== canonicalJson(expectedIds)) { + throw new Error( + `World processor ${options.config.id} must return exactly configured payloads ${expectedIds.join( + ", ", + )}; received ${actualIds.join(", ") || "none"}.`, + ); + } + const payloads = expectedIds.map((id) => { + const artifact = artifacts.get(id)!; + const bytes = Buffer.from(canonicalJson(artifact.value)); + return Object.freeze({ + id, + fileName: options.config.payloadFileNames[id]!, + contentHash: hashCanonicalJson(artifact.value), + bytes, + value: artifact.value, + }); + }); + const hashes = new Map( + payloads.map((payload) => [payload.id, payload.contentHash]), + ); + for (const artifact of artifacts.values()) { + for (const reference of artifact.hashReferences ?? []) { + const expectedHash = hashes.get(reference.artifactId); + if (!expectedHash) { + throw new Error( + `Artifact ${artifact.id} hash reference names unknown artifact ${reference.artifactId}.`, + ); + } + const actual = resolveJsonPointer( + artifact.value, + reference.jsonPointer, + ); + if (actual !== expectedHash) { + throw new Error( + `Artifact ${artifact.id} hash reference ${reference.jsonPointer} does not match ${reference.artifactId}.`, + ); + } + } + } + const members: readonly WorldDerivedArtifactSetMemberV1[] = Object.freeze( + payloads.map(({ id, fileName, contentHash }) => + Object.freeze({ id, fileName, contentHash }), + ), + ); + const descriptor = { + schemaVersion: 1 as const, + logicalInputHash: options.execution.logicalInputHash, + providerId: options.config.id, + providerRevision: options.execution.providerRevision, + members, + }; + const artifactSetId = hashCanonicalJson(descriptor); + const manifest: WorldDerivedArtifactSetManifestV1 = Object.freeze({ + ...descriptor, + artifactSetId, + }); + const manifestBytes = Buffer.from(canonicalJson(manifest)); + return Object.freeze({ + payloads: Object.freeze(payloads), + manifest, + manifestBytes, + manifestContentHash: hashCanonicalJson(manifest), + }); +} + +async function publishImmutableSet( + setDirectory: string, + payloads: readonly PreparedPayload[], + manifestBytes: Buffer, +): Promise { + const setsDirectory = path.dirname(setDirectory); + await mkdir(setsDirectory, { recursive: true }); + const stagingDirectory = path.join( + setsDirectory, + `.staging-${path.basename(setDirectory)}-${randomUUID()}`, + ); + try { + await mkdir(stagingDirectory, { recursive: false }); + for (const payload of payloads) { + const outputPath = path.join(stagingDirectory, payload.fileName); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, payload.bytes, { flag: "wx" }); + } + await writeFile( + path.join(stagingDirectory, "manifest.json"), + manifestBytes, + { flag: "wx" }, + ); + await rename(stagingDirectory, setDirectory); + } finally { + await rm(stagingDirectory, { recursive: true, force: true }); + } +} + +async function replacePointer( + pointerPath: string, + pointerBytes: Buffer, +): Promise { + await mkdir(path.dirname(pointerPath), { recursive: true }); + const temporaryPath = path.join( + path.dirname(pointerPath), + `.${path.basename(pointerPath)}.${randomUUID()}.tmp`, + ); + try { + await writeFile(temporaryPath, pointerBytes, { flag: "wx" }); + await rename(temporaryPath, pointerPath); + } finally { + await rm(temporaryPath, { force: true }); + } +} + +function renderPointer( + pointerPath: string, + setDirectory: string, + manifest: WorldDerivedArtifactSetManifestV1, + manifestContentHash: string, + payloads: readonly PreparedPayload[], +): string { + if (path.extname(pointerPath).toLowerCase() === ".json") { + return canonicalJson({ + schemaVersion: manifest.schemaVersion, + artifactSetId: manifest.artifactSetId, + logicalInputHash: manifest.logicalInputHash, + providerId: manifest.providerId, + providerRevision: manifest.providerRevision, + manifestContentHash, + members: Object.fromEntries( + payloads.map((payload) => [ + payload.id, + { + id: payload.id, + fileName: payload.fileName, + contentHash: payload.contentHash, + value: payload.value, + }, + ]), + ), + }); + } + + const imports = payloads + .map((payload, index) => { + const target = path.join(setDirectory, payload.fileName); + let specifier = path + .relative(path.dirname(pointerPath), target) + .replace(/\\/g, "/"); + if (!specifier.startsWith(".")) specifier = `./${specifier}`; + return `import payload${index} from ${JSON.stringify( + specifier, + )} with { type: "json" };`; + }) + .join("\n"); + const members = payloads + .map( + (payload, index) => ` ${JSON.stringify(payload.id)}: { + id: ${JSON.stringify(payload.id)}, + fileName: ${JSON.stringify(payload.fileName)}, + contentHash: ${JSON.stringify(payload.contentHash)}, + value: payload${index}, + },`, + ) + .join("\n"); + return `${imports} + +export const worldDerivedArtifactSet = { + schemaVersion: 1, + artifactSetId: ${JSON.stringify(manifest.artifactSetId)}, + logicalInputHash: ${JSON.stringify(manifest.logicalInputHash)}, + providerId: ${JSON.stringify(manifest.providerId)}, + providerRevision: ${JSON.stringify(manifest.providerRevision)}, + manifestContentHash: ${JSON.stringify(manifestContentHash)}, + members: { +${members} + }, +} as const; + +export default worldDerivedArtifactSet; +`; +} + +function resolveJsonPointer( + value: WorldDerivedJsonValue, + pointer: string, +): WorldDerivedJsonValue | undefined { + if (pointer === "") return value; + if (!pointer.startsWith("/")) { + throw new Error(`JSON pointer ${pointer} must start with /.`); + } + let current: WorldDerivedJsonValue | undefined = value; + for (const encoded of pointer.slice(1).split("/")) { + const key = encoded.replace(/~1/g, "/").replace(/~0/g, "~"); + if (Array.isArray(current)) { + if (!/^(?:0|[1-9][0-9]*)$/.test(key)) return undefined; + current = current[Number(key)]; + } else if (current && typeof current === "object") { + current = ( + current as Readonly> + )[key]; + } else { + return undefined; + } + } + return current; +} + +async function artifactSetMatches( + setDirectory: string, + payloads: readonly PreparedPayload[], + manifestBytes: Buffer, +): Promise { + if (!(await pathExists(setDirectory))) return false; + const expected = new Map([ + ...payloads.map( + (payload) => [payload.fileName, payload.bytes] as const, + ), + ["manifest.json", manifestBytes], + ]); + const actualFiles = await listFiles(setDirectory); + if ( + canonicalJson(actualFiles) !== + canonicalJson([...expected.keys()].sort()) + ) { + return false; + } + for (const [fileName, bytes] of expected) { + if (!(await fileMatches(path.join(setDirectory, fileName), bytes))) { + return false; + } + } + return true; +} + +async function listFiles(root: string): Promise { + const result: string[] = []; + await visit(""); + return result.sort(); + + async function visit(relativeDirectory: string): Promise { + const children = await readdir(path.join(root, relativeDirectory), { + withFileTypes: true, + }); + for (const child of children) { + const relativePath = path.join(relativeDirectory, child.name); + if (child.isDirectory()) await visit(relativePath); + else if (child.isFile()) + result.push(relativePath.replace(/\\/g, "/")); + else result.push(`${relativePath.replace(/\\/g, "/")}:unsupported`); + } + } +} + +async function fileMatches( + filePath: string, + expected: Buffer, +): Promise { + try { + return (await readFile(filePath)).equals(expected); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function pathExists(targetPath: string): Promise { + return Boolean(await stat(targetPath).catch(() => undefined)); +} diff --git a/packages/blr/src/world-processing/bedrock-block-entities.ts b/packages/blr/src/world-processing/bedrock-block-entities.ts new file mode 100644 index 0000000..104ffc2 --- /dev/null +++ b/packages/blr/src/world-processing/bedrock-block-entities.ts @@ -0,0 +1,259 @@ +import type { + WorldBlockEntityObservation, + WorldBlockPaletteEntry, + WorldDerivedJsonValue, + WorldSignFaceObservation, + WorldSignOrientation, +} from "../world-processing.js"; +import { loadMcbeLeveldbHelpers } from "../mcbe-leveldb-adapter.js"; + +export type NormalizedSignText = Omit; + +export function normalizeSignText(rawText: string): NormalizedSignText { + const normalizedText = rawText.replace(/\r\n?/g, "\n"); + const rowsText = normalizedText.endsWith("\n") + ? normalizedText.slice(0, -1) + : normalizedText; + return Object.freeze({ + rawText, + normalizedText, + lines: Object.freeze(rowsText.split("\n")), + }); +} + +export async function decodeBedrockBlockEntities( + rawValue: Uint8Array, +): Promise { + const helpers = await loadMcbeLeveldbHelpers(); + const parsed = await helpers.entryContentTypeToFormatMap.BlockEntity.parse( + Buffer.from(rawValue.buffer, rawValue.byteOffset, rawValue.byteLength), + ); + const values = parsed.value.blockEntities.value.value; + return Object.freeze( + values.map((value, index) => + normalizeBlockEntity(value, `blockEntities[${index}]`), + ), + ); +} + +export function decodeSignOrientation( + palette: WorldBlockPaletteEntry, +): WorldSignOrientation | undefined { + if (palette.typeId.includes("hanging_sign")) { + return Object.freeze({ + kind: "hanging", + states: palette.states, + }); + } + if (palette.typeId.endsWith("standing_sign")) { + const state = palette.states.ground_sign_direction; + if ( + typeof state !== "number" || + !Number.isInteger(state) || + state < 0 || + state > 15 + ) { + throw new Error( + `${palette.typeId} ground_sign_direction must be an integer in 0..15.`, + ); + } + const yaw = state * 22.5; + return Object.freeze({ + kind: "standing", + groundSignDirection: state as 0, + yawDegrees: yaw > 180 ? yaw - 360 : yaw, + }); + } + if (palette.typeId.endsWith("wall_sign")) { + const state = palette.states.facing_direction; + if ( + !Number.isInteger(state) || + (state !== 2 && state !== 3 && state !== 4 && state !== 5) + ) { + throw new Error( + `${palette.typeId} facing_direction must be 2, 3, 4, or 5.`, + ); + } + const yawByState: Readonly> = { + 2: 180, + 3: 0, + 4: 90, + 5: -90, + }; + return Object.freeze({ + kind: "wall", + facingDirection: state, + yawDegrees: yawByState[state], + }); + } + return undefined; +} + +function normalizeBlockEntity( + value: Record, + source: string, +): WorldBlockEntityObservation { + const id = readNbtString(value.id, `${source}.id`); + const location = Object.freeze({ + x: readNbtNumber(value.x, `${source}.x`), + y: readNbtNumber(value.y, `${source}.y`), + z: readNbtNumber(value.z, `${source}.z`), + }); + if ( + !Number.isInteger(location.x) || + !Number.isInteger(location.y) || + !Number.isInteger(location.z) + ) { + throw new Error(`${source} coordinates must be integers.`); + } + const normalizedValue = normalizeNbtCompound(value, source); + const signFaces = id === "Sign" ? readSignFaces(value, source) : undefined; + return Object.freeze({ + id, + location, + value: normalizedValue, + ...(signFaces ? { signFaces } : {}), + }); +} + +function readSignFaces( + value: Record, + source: string, +): readonly WorldSignFaceObservation[] { + const faces: WorldSignFaceObservation[] = []; + for (const [field, face] of [ + ["FrontText", "front"], + ["BackText", "back"], + ] as const) { + const textCompound = readOptionalNbtCompound( + value[field], + `${source}.${field}`, + ); + if (!textCompound) continue; + const text = readNbtString( + textCompound.Text, + `${source}.${field}.Text`, + ); + faces.push(Object.freeze({ face, ...normalizeSignText(text) })); + } + return Object.freeze(faces); +} + +function normalizeNbtCompound( + input: Record, + source: string, +): WorldDerivedJsonValue { + const result: Record = {}; + for (const key of Object.keys(input).sort()) { + result[key] = normalizeNbtTag(input[key], `${source}.${key}`); + } + return Object.freeze(result); +} + +function normalizeNbtTag( + input: unknown, + source: string, +): WorldDerivedJsonValue { + const tag = readNbtTag(input, source); + if (tag.type === "compound") { + if ( + !tag.value || + typeof tag.value !== "object" || + Array.isArray(tag.value) + ) { + throw new Error(`${source} is not an NBT compound.`); + } + return normalizeNbtCompound( + tag.value as Record, + source, + ); + } + if (tag.type === "list") { + const list = tag.value as + | { type?: unknown; value?: unknown } + | undefined; + if ( + !list || + typeof list.type !== "string" || + !Array.isArray(list.value) + ) { + throw new Error(`${source} is not an NBT list.`); + } + return Object.freeze( + list.value.map((entry, index) => + normalizeNbtTag( + { type: list.type, value: entry }, + `${source}[${index}]`, + ), + ), + ); + } + if (/Array$/i.test(tag.type)) { + const arrayValue = + tag.value && typeof tag.value === "object" && "value" in tag.value + ? (tag.value as { value: unknown }).value + : tag.value; + if (!Array.isArray(arrayValue) && !ArrayBuffer.isView(arrayValue)) { + throw new Error(`${source} is not an NBT array.`); + } + return Object.freeze(Array.from(arrayValue as ArrayLike)); + } + if (typeof tag.value === "bigint") return tag.value.toString(); + if ( + typeof tag.value === "string" || + typeof tag.value === "boolean" || + (typeof tag.value === "number" && Number.isFinite(tag.value)) + ) { + return tag.value; + } + throw new Error(`${source} has unsupported NBT type ${tag.type}.`); +} + +function readOptionalNbtCompound( + input: unknown, + source: string, +): Record | undefined { + if (input === undefined) return undefined; + const tag = readNbtTag(input, source); + if ( + tag.type !== "compound" || + !tag.value || + typeof tag.value !== "object" || + Array.isArray(tag.value) + ) { + throw new Error(`${source} is not an NBT compound.`); + } + return tag.value as Record; +} + +function readNbtString(input: unknown, source: string): string { + const tag = readNbtTag(input, source); + if (tag.type !== "string" || typeof tag.value !== "string") { + throw new Error(`${source} is not an NBT string.`); + } + return tag.value; +} + +function readNbtNumber(input: unknown, source: string): number { + const tag = readNbtTag(input, source); + if (typeof tag.value !== "number" || !Number.isFinite(tag.value)) { + throw new Error(`${source} is not a finite NBT number.`); + } + return tag.value; +} + +function readNbtTag( + input: unknown, + source: string, +): { readonly type: string; readonly value: unknown } { + if ( + !input || + typeof input !== "object" || + !("type" in input) || + !("value" in input) || + typeof (input as { type?: unknown }).type !== "string" + ) { + throw new Error(`${source} is not a valid NBT tag.`); + } + return input as { readonly type: string; readonly value: unknown }; +} diff --git a/packages/blr/src/world-processing/bedrock-subchunk.ts b/packages/blr/src/world-processing/bedrock-subchunk.ts new file mode 100644 index 0000000..e88961d --- /dev/null +++ b/packages/blr/src/world-processing/bedrock-subchunk.ts @@ -0,0 +1,296 @@ +import NBT from "prismarine-nbt"; +import type { WorldBlockPaletteEntry } from "../world-processing.js"; + +const SUPPORTED_STORAGE_HEADERS = new Set([2, 4, 6, 8, 10, 12, 16]); + +export type DecodedBedrockSubchunkLayer = { + readonly storageHeader: number; + readonly bitsPerBlock: number; + readonly blocksPerWord: number; + readonly palette: readonly WorldBlockPaletteEntry[]; + /** Exact little-endian NBT bytes for each palette entry. */ + readonly paletteEntryBytes: readonly Buffer[]; + /** Exact source bytes for this entire storage layer. */ + readonly rawBytes: Buffer; + readonly indices: Uint16Array; +}; + +export type DecodedBedrockSubchunk = { + readonly formatVersion: 1 | 8 | 9; + readonly subChunkY: number | undefined; + readonly layers: readonly DecodedBedrockSubchunkLayer[]; +}; + +export type DecodeBedrockSubchunkOptions = { + readonly allowedFormatVersions?: readonly (1 | 8 | 9)[]; + /** Required for formats before v9 because Y is stored only in the DB key. */ + readonly subChunkY?: number; +}; + +/** + * Strictly decodes the packed Bedrock SubChunkPrefix representation used by + * world processors. The default observation contract accepts v9 only; + * compatibility callers must opt into older formats explicitly. + */ +export async function decodeBedrockSubchunk( + input: Uint8Array, + options: DecodeBedrockSubchunkOptions = {}, +): Promise { + const data = Buffer.from(input.buffer, input.byteOffset, input.byteLength); + let offset = 0; + const formatVersion = readByte(data, offset, "subchunk format"); + offset += 1; + const allowed = options.allowedFormatVersions ?? [9]; + if ( + (formatVersion !== 1 && formatVersion !== 8 && formatVersion !== 9) || + !allowed.includes(formatVersion) + ) { + throw new Error(`Unsupported subchunk format ${formatVersion}.`); + } + + let layerCount = 1; + if (formatVersion >= 8) { + layerCount = readByte(data, offset, "storage layer count"); + offset += 1; + if (layerCount < 1) { + throw new Error("Subchunk storage layer count must be positive."); + } + } + + let subChunkY = options.subChunkY; + if (formatVersion >= 9) { + ensureAvailable(data, offset, 1, "embedded subchunk Y"); + subChunkY = data.readInt8(offset); + offset += 1; + } else if (!Number.isInteger(subChunkY)) { + throw new Error( + `Subchunk format ${formatVersion} requires subChunkY from its DB key.`, + ); + } + + const layers: DecodedBedrockSubchunkLayer[] = []; + for (let layerIndex = 0; layerIndex < layerCount; layerIndex += 1) { + const result = await decodeLayer(data, offset, layerIndex); + offset = result.offset; + layers.push(result.layer); + } + if (offset !== data.length) { + throw new Error( + `Subchunk has ${data.length - offset} unexpected trailing byte(s).`, + ); + } + + return Object.freeze({ + formatVersion, + subChunkY, + layers: Object.freeze(layers), + }); +} + +async function decodeLayer( + data: Buffer, + initialOffset: number, + layerIndex: number, +): Promise<{ + readonly layer: DecodedBedrockSubchunkLayer; + readonly offset: number; +}> { + let offset = initialOffset; + const storageHeader = readByte( + data, + offset, + `storage header for layer ${layerIndex}`, + ); + offset += 1; + if (!SUPPORTED_STORAGE_HEADERS.has(storageHeader)) { + throw new Error( + `Unsupported packed storage header ${storageHeader} in layer ${layerIndex}.`, + ); + } + + const bitsPerBlock = storageHeader >> 1; + const blocksPerWord = Math.floor(32 / bitsPerBlock); + const wordCount = Math.ceil(4096 / blocksPerWord); + const blockDataOffset = offset; + const blockDataLength = wordCount * 4; + ensureAvailable( + data, + blockDataOffset, + blockDataLength, + `block indices for layer ${layerIndex}`, + ); + offset += blockDataLength; + + ensureAvailable(data, offset, 4, `palette size for layer ${layerIndex}`); + const paletteSize = data.readInt32LE(offset); + offset += 4; + if (paletteSize < 1 || paletteSize > 1 << bitsPerBlock) { + throw new Error( + `Palette size ${paletteSize} is invalid for ${bitsPerBlock}-bit layer ${layerIndex}.`, + ); + } + + const palette: WorldBlockPaletteEntry[] = []; + const paletteEntryBytes: Buffer[] = []; + for (let paletteIndex = 0; paletteIndex < paletteSize; paletteIndex += 1) { + const entryOffset = offset; + const result = (await NBT.parse( + data.subarray(offset), + "little", + )) as NbtParseResult; + const size = result.metadata?.size; + if (typeof size !== "number" || !Number.isInteger(size) || size <= 0) { + throw new Error( + `Palette entry ${paletteIndex} in layer ${layerIndex} is invalid.`, + ); + } + offset += size; + paletteEntryBytes.push(Buffer.from(data.subarray(entryOffset, offset))); + palette.push( + normalizePaletteEntry( + result.parsed, + `layer ${layerIndex} palette ${paletteIndex}`, + ), + ); + } + + const indices = new Uint16Array(4096); + const paletteMask = (1 << bitsPerBlock) - 1; + for (let blockIndex = 0; blockIndex < 4096; blockIndex += 1) { + const wordOffset = + blockDataOffset + Math.floor(blockIndex / blocksPerWord) * 4; + const shift = (blockIndex % blocksPerWord) * bitsPerBlock; + const paletteIndex = + (data.readUInt32LE(wordOffset) >>> shift) & paletteMask; + if (paletteIndex >= palette.length) { + throw new Error( + `Block ${blockIndex} references palette index ${paletteIndex}, but layer ${layerIndex} palette size is ${palette.length}.`, + ); + } + indices[blockIndex] = paletteIndex; + } + + return { + layer: Object.freeze({ + storageHeader, + bitsPerBlock, + blocksPerWord, + palette: Object.freeze(palette), + paletteEntryBytes: Object.freeze(paletteEntryBytes), + rawBytes: Buffer.from(data.subarray(initialOffset, offset)), + indices, + }), + offset, + }; +} + +type NbtParseResult = { + readonly parsed?: unknown; + readonly metadata?: { readonly size?: number }; +}; + +function normalizePaletteEntry( + parsed: unknown, + source: string, +): WorldBlockPaletteEntry { + const root = getNbtCompoundValue(parsed, source); + const typeId = getNbtString(root.name, `${source}.name`); + const version = getNbtNumber(root.version, `${source}.version`); + const stateTags = getNbtCompoundValue(root.states, `${source}.states`); + const states: Record = {}; + for (const key of Object.keys(stateTags).sort()) { + const tag = getNbtTag(stateTags[key], `${source}.states.${key}`); + if (tag.type === "string") { + if (typeof tag.value !== "string") { + throw new Error(`${source}.states.${key} is not a string.`); + } + states[key] = tag.value; + } else if ( + tag.type === "byte" && + (tag.value === 0 || tag.value === 1) + ) { + states[key] = tag.value === 1; + } else if ( + (tag.type === "byte" || + tag.type === "short" || + tag.type === "int") && + typeof tag.value === "number" && + Number.isFinite(tag.value) + ) { + states[key] = tag.value; + } else { + throw new Error( + `${source}.states.${key} has unsupported NBT type ${tag.type}.`, + ); + } + } + return Object.freeze({ + typeId, + states: Object.freeze(states), + version, + }); +} + +function getNbtCompoundValue( + input: unknown, + source: string, +): Record { + const tag = getNbtTag(input, source); + if ( + tag.type !== "compound" || + !tag.value || + typeof tag.value !== "object" || + Array.isArray(tag.value) + ) { + throw new Error(`${source} is not an NBT compound.`); + } + return tag.value as Record; +} + +function getNbtString(input: unknown, source: string): string { + const tag = getNbtTag(input, source); + if (tag.type !== "string" || typeof tag.value !== "string") { + throw new Error(`${source} is not an NBT string.`); + } + return tag.value; +} + +function getNbtNumber(input: unknown, source: string): number { + const tag = getNbtTag(input, source); + if (typeof tag.value !== "number" || !Number.isFinite(tag.value)) { + throw new Error(`${source} is not a finite NBT number.`); + } + return tag.value; +} + +function getNbtTag( + input: unknown, + source: string, +): { readonly type: string; readonly value: unknown } { + if ( + !input || + typeof input !== "object" || + !("type" in input) || + !("value" in input) || + typeof (input as { type?: unknown }).type !== "string" + ) { + throw new Error(`${source} is not a valid NBT tag.`); + } + return input as { readonly type: string; readonly value: unknown }; +} + +function readByte(data: Buffer, offset: number, source: string): number { + ensureAvailable(data, offset, 1, source); + return data.readUInt8(offset); +} + +function ensureAvailable( + data: Buffer, + offset: number, + count: number, + source: string, +): void { + if (offset < 0 || count < 0 || offset + count > data.length) { + throw new Error(`Subchunk ${source} is truncated.`); + } +} diff --git a/packages/blr/src/world-processing/canonical-json.ts b/packages/blr/src/world-processing/canonical-json.ts new file mode 100644 index 0000000..f8c8be2 --- /dev/null +++ b/packages/blr/src/world-processing/canonical-json.ts @@ -0,0 +1,54 @@ +import { createHash } from "node:crypto"; +import type { WorldDerivedJsonValue } from "../world-processing.js"; + +export function canonicalJson(input: unknown): string { + return `${JSON.stringify(normalizeJson(input), null, 2)}\n`; +} + +export function hashCanonicalJson(input: unknown): string { + return createHash("sha256").update(canonicalJson(input)).digest("hex"); +} + +export function normalizeJson( + input: unknown, + source = "value", +): WorldDerivedJsonValue { + if ( + input === null || + typeof input === "string" || + typeof input === "boolean" + ) { + return input; + } + if (typeof input === "number") { + if (!Number.isFinite(input)) { + throw new Error(`${source} contains a non-finite number.`); + } + return Object.is(input, -0) ? 0 : input; + } + if (Array.isArray(input)) { + return Object.freeze( + input.map((value, index) => + normalizeJson(value, `${source}[${index}]`), + ), + ); + } + if (input && typeof input === "object") { + const prototype = Object.getPrototypeOf(input); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${source} must contain only plain JSON objects.`); + } + const result: Record = {}; + for (const key of Object.keys( + input as Record, + ).sort()) { + const value = (input as Record)[key]; + if (value === undefined) { + throw new Error(`${source}.${key} must not be undefined.`); + } + result[key] = normalizeJson(value, `${source}.${key}`); + } + return Object.freeze(result); + } + throw new Error(`${source} contains unsupported ${typeof input} data.`); +} diff --git a/packages/blr/src/world-processing/config.ts b/packages/blr/src/world-processing/config.ts new file mode 100644 index 0000000..3288e8b --- /dev/null +++ b/packages/blr/src/world-processing/config.ts @@ -0,0 +1,355 @@ +import path from "node:path"; +import { assertProjectRelativePath } from "../project-paths.js"; +import type { + BlurConfigWorldProcessorFile, + ResolvedWorldProcessorConfig, + WorldProcessorApplyOn, + WorldProcessorCapability, +} from "../types.js"; +import { assertValidWorldName } from "../world.js"; + +const PORTABLE_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; +const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; +const CAPABILITIES = new Set([ + "observer", + "artifact", + "transform", +]); + +export function normalizeWorldProcessorConfigs( + input: unknown, + projectRoot: string, + source = "worldProcessors", +): readonly ResolvedWorldProcessorConfig[] { + if (input === undefined) return Object.freeze([]); + if (!Array.isArray(input)) { + throw new Error(`${source} must be an array.`); + } + + const ids = new Set(); + const outputOwners = new Map(); + const configs = input.map((value, index) => { + const itemSource = `${source}[${index}]`; + const record = expectRecord(value, itemSource); + const id = expectPortableId(record.id, `${itemSource}.id`); + if (ids.has(id)) { + throw new Error(`Duplicate world processor id "${id}".`); + } + ids.add(id); + + const capabilities = normalizeCapabilities( + record.capabilities, + `${itemSource}.capabilities`, + ); + const outputRoot = normalizeOptionalProjectPath( + record.outputRoot, + projectRoot, + `${itemSource}.outputRoot`, + ); + const payloadFileNames = normalizePayloadFileNames( + record.payloadFileNames, + projectRoot, + itemSource, + ); + if (capabilities.includes("artifact") && !outputRoot) { + throw new Error( + `${itemSource}.outputRoot is required for artifact processors.`, + ); + } + if ( + capabilities.includes("artifact") && + Object.keys(payloadFileNames).length === 0 + ) { + throw new Error( + `${itemSource}.payloadFileNames must declare at least one artifact payload.`, + ); + } + + const runtimePointerPath = normalizeOptionalProjectPath( + record.runtimePointerPath, + projectRoot, + `${itemSource}.runtimePointerPath`, + ); + if ( + runtimePointerPath !== undefined && + !runtimePointerPath.endsWith(".ts") && + !runtimePointerPath.endsWith(".json") + ) { + throw new Error( + `${itemSource}.runtimePointerPath must end in .ts or .json.`, + ); + } + const auditOutputPath = normalizeOptionalProjectPath( + record.auditOutputPath, + projectRoot, + `${itemSource}.auditOutputPath`, + ); + const config: ResolvedWorldProcessorConfig = Object.freeze({ + id, + module: normalizeModule(record.module, projectRoot, itemSource), + export: + expectOptionalNonEmptyString( + record.export, + `${itemSource}.export`, + ) ?? "default", + sourceWorld: assertValidWorldName( + expectNonEmptyString( + record.sourceWorld, + `${itemSource}.sourceWorld`, + ), + `${itemSource}.sourceWorld`, + ), + capabilities: Object.freeze(capabilities), + dependsOn: Object.freeze( + normalizeStringArray( + record.dependsOn, + `${itemSource}.dependsOn`, + ).map((dependency, dependencyIndex) => + expectPortableId( + dependency, + `${itemSource}.dependsOn[${dependencyIndex}]`, + ), + ), + ), + inputPaths: Object.freeze( + normalizeStringArray( + record.inputPaths, + `${itemSource}.inputPaths`, + ).map((inputPath, inputIndex) => + assertProjectRelativePath( + projectRoot, + inputPath, + `${itemSource}.inputPaths[${inputIndex}]`, + ), + ), + ), + ...(outputRoot ? { outputRoot } : {}), + payloadFileNames: Object.freeze(payloadFileNames), + ...(runtimePointerPath ? { runtimePointerPath } : {}), + ...(auditOutputPath ? { auditOutputPath } : {}), + applyOn: Object.freeze( + normalizeApplyOn(record.applyOn, `${itemSource}.applyOn`), + ), + }); + + for (const payloadPath of Object.values(payloadFileNames)) { + claimOutput( + outputOwners, + path.posix.join(outputRoot ?? "", payloadPath), + id, + ); + } + if (runtimePointerPath) { + claimOutput(outputOwners, runtimePointerPath, id); + } + if (auditOutputPath) { + claimOutput(outputOwners, auditOutputPath, id); + } + return config; + }); + + for (const config of configs) { + for (const dependency of config.dependsOn) { + if (!ids.has(dependency)) { + throw new Error( + `World processor "${config.id}" depends on unknown processor "${dependency}".`, + ); + } + if (dependency === config.id) { + throw new Error( + `World processor "${config.id}" cannot depend on itself.`, + ); + } + } + } + return Object.freeze(configs); +} + +function normalizeModule( + input: unknown, + projectRoot: string, + source: string, +): string { + const value = expectNonEmptyString(input, `${source}.module`); + if (value.startsWith("./")) { + return `./${assertProjectRelativePath(projectRoot, value.slice(2), `${source}.module`)}`; + } + if ( + value.startsWith(".") || + value.startsWith("/") || + value.startsWith("file:") || + path.isAbsolute(value) + ) { + throw new Error( + `${source}.module must be an explicit ./ project module or a bare package specifier.`, + ); + } + return value; +} + +function normalizeCapabilities( + input: unknown, + source: string, +): WorldProcessorCapability[] { + if (input === undefined) return ["artifact"]; + if (!Array.isArray(input) || input.length === 0) { + throw new Error(`${source} must be a non-empty array.`); + } + const values: WorldProcessorCapability[] = []; + for (const [index, value] of input.entries()) { + if (!CAPABILITIES.has(value as WorldProcessorCapability)) { + throw new Error( + `${source}[${index}] must be observer, artifact, or transform.`, + ); + } + if (!values.includes(value as WorldProcessorCapability)) { + values.push(value as WorldProcessorCapability); + } + } + return values; +} + +function normalizePayloadFileNames( + input: unknown, + projectRoot: string, + source: string, +): Record { + if (input === undefined) return {}; + const record = expectRecord(input, `${source}.payloadFileNames`); + const result: Record = {}; + const paths = new Set(); + for (const [artifactId, fileNameInput] of Object.entries(record)) { + expectPortableId(artifactId, `${source}.payloadFileNames key`); + const fileName = assertProjectRelativePath( + projectRoot, + expectNonEmptyString( + fileNameInput, + `${source}.payloadFileNames.${artifactId}`, + ), + `${source}.payloadFileNames.${artifactId}`, + ); + if (fileName === "manifest.json" || fileName.startsWith("sets/")) { + throw new Error( + `${source}.payloadFileNames.${artifactId} uses a reserved artifact path.`, + ); + } + if (paths.has(fileName)) { + throw new Error( + `${source}.payloadFileNames contains duplicate path "${fileName}".`, + ); + } + paths.add(fileName); + result[artifactId] = fileName; + } + return result; +} + +function normalizeApplyOn( + input: unknown, + source: string, +): WorldProcessorApplyOn { + const defaults: WorldProcessorApplyOn = { + dev: true, + build: true, + package: true, + check: true, + worldBuild: true, + worldPush: true, + }; + if (input === undefined) return defaults; + const record = expectRecord(input, source); + return { + dev: expectOptionalBoolean(record.dev, `${source}.dev`) ?? defaults.dev, + build: + expectOptionalBoolean(record.build, `${source}.build`) ?? + defaults.build, + package: + expectOptionalBoolean(record.package, `${source}.package`) ?? + defaults.package, + check: + expectOptionalBoolean(record.check, `${source}.check`) ?? + defaults.check, + worldBuild: + expectOptionalBoolean(record.worldBuild, `${source}.worldBuild`) ?? + defaults.worldBuild, + worldPush: + expectOptionalBoolean(record.worldPush, `${source}.worldPush`) ?? + defaults.worldPush, + }; +} + +function claimOutput( + owners: Map, + outputPath: string, + id: string, +): void { + const owner = owners.get(outputPath); + if (owner && owner !== id) { + throw new Error( + `world processor output path "${outputPath}" is claimed by both "${owner}" and "${id}".`, + ); + } + owners.set(outputPath, id); +} + +function normalizeOptionalProjectPath( + input: unknown, + projectRoot: string, + source: string, +): string | undefined { + const value = expectOptionalNonEmptyString(input, source); + return value === undefined + ? undefined + : assertProjectRelativePath(projectRoot, value, source); +} + +function normalizeStringArray(input: unknown, source: string): string[] { + if (input === undefined) return []; + if (!Array.isArray(input)) throw new Error(`${source} must be an array.`); + return input.map((value, index) => + expectNonEmptyString(value, `${source}[${index}]`), + ); +} + +function expectPortableId(input: unknown, source: string): string { + const value = expectNonEmptyString(input, source); + if (!PORTABLE_ID.test(value) || WINDOWS_RESERVED.test(value)) { + throw new Error( + `${source} must be one portable lowercase segment using letters, digits, underscores, or hyphens.`, + ); + } + return value; +} + +function expectRecord(input: unknown, source: string): Record { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error(`${source} must be an object.`); + } + return input as Record; +} + +function expectNonEmptyString(input: unknown, source: string): string { + if (typeof input !== "string" || input.trim().length === 0) { + throw new Error(`${source} must be a non-empty string.`); + } + return input.trim(); +} + +function expectOptionalNonEmptyString( + input: unknown, + source: string, +): string | undefined { + return input === undefined + ? undefined + : expectNonEmptyString(input, source); +} + +function expectOptionalBoolean( + input: unknown, + source: string, +): boolean | undefined { + if (input === undefined) return undefined; + if (typeof input !== "boolean") + throw new Error(`${source} must be a boolean.`); + return input; +} diff --git a/packages/blr/src/world-processing/mutations.ts b/packages/blr/src/world-processing/mutations.ts new file mode 100644 index 0000000..b60eb35 --- /dev/null +++ b/packages/blr/src/world-processing/mutations.ts @@ -0,0 +1,506 @@ +import type { + WorldBlockEntityPolicy, + WorldBlockLocation, + WorldBlockObservation, + WorldBlockPaletteEntry, + WorldDimensionId, + WorldMutation, + WorldObservationFacade, + WorldPaletteReplacement, +} from "../world-processing.js"; +import { canonicalJson } from "./canonical-json.js"; + +export type WorldMutationClaim = { + readonly processorId: string; + readonly opId: string; +}; + +export type PrimitiveWorldBlockWrite = { + readonly kind: "block-write"; + readonly dimension: WorldDimensionId; + readonly location: WorldBlockLocation; + readonly layer: number; + readonly expectedPalette: WorldBlockPaletteEntry; + readonly replacementPalette: WorldBlockPaletteEntry; + readonly blockEntityPolicy: WorldBlockEntityPolicy; + readonly claims: readonly WorldMutationClaim[]; +}; + +export type PrimitiveWorldBlockEntityRemoval = { + readonly kind: "block-entity-remove"; + readonly dimension: WorldDimensionId; + readonly location: WorldBlockLocation; + readonly layer?: undefined; + readonly expectedId: string; + readonly claims: readonly WorldMutationClaim[]; +}; + +export type PrimitiveWorldMutation = + | PrimitiveWorldBlockWrite + | PrimitiveWorldBlockEntityRemoval; + +export type WorldProcessorMutationPlan = { + readonly processorId: string; + readonly mutations: readonly WorldMutation[]; +}; + +export async function normalizeWorldMutationPlans( + plans: readonly WorldProcessorMutationPlan[], + observations: WorldObservationFacade, +): Promise { + const claims = new Map(); + const opIds = new Set(); + + for (const plan of [...plans].sort((left, right) => + left.processorId.localeCompare(right.processorId), + )) { + assertNonEmpty(plan.processorId, "processorId"); + for (const mutation of plan.mutations) { + const claim = freezeClaim(plan.processorId, mutation.opId); + const opKey = `${plan.processorId}:${mutation.opId}`; + if (opIds.has(opKey)) { + throw new Error( + `World processor ${plan.processorId} has duplicate opId ${mutation.opId}.`, + ); + } + opIds.add(opKey); + switch (mutation.kind) { + case "set-block": { + const current = await requireObservation( + observations, + mutation.dimension, + mutation.location, + mutation.layer, + ); + const expectedPalette = normalizePalette( + mutation.expectedPalette, + ); + assertPaletteMatches( + current.palette, + expectedPalette, + mutation.location, + ); + assertBlockEntityPolicy( + current, + mutation.blockEntityPolicy, + mutation.location, + ); + const replacementPalette = await resolveReplacement( + observations, + mutation.replacement, + ); + addClaim( + claims, + freezeBlockWrite({ + dimension: mutation.dimension, + location: mutation.location, + layer: mutation.layer, + expectedPalette, + replacementPalette, + blockEntityPolicy: mutation.blockEntityPolicy, + claim, + }), + ); + break; + } + case "remove-block-entity": { + const current = await requireObservation( + observations, + mutation.dimension, + mutation.location, + 0, + ); + if (current.blockEntity?.id !== mutation.expectedId) { + throw new Error( + `Expected block entity ${mutation.expectedId} at ${formatLocation( + mutation.location, + )}, found ${current.blockEntity?.id ?? "none"}.`, + ); + } + addClaim( + claims, + freezeBlockEntityRemoval({ + dimension: mutation.dimension, + location: mutation.location, + expectedId: mutation.expectedId, + claim, + }), + ); + break; + } + case "consume-sign": { + const current = await requireObservation( + observations, + mutation.dimension, + mutation.location, + 0, + ); + const expectedPalette = normalizePalette( + mutation.expectedPalette, + ); + assertPaletteMatches( + current.palette, + expectedPalette, + mutation.location, + ); + if ( + current.blockEntity?.id !== + mutation.expectedBlockEntityId + ) { + throw new Error( + `Expected block entity ${mutation.expectedBlockEntityId} at ${formatLocation( + mutation.location, + )}, found ${current.blockEntity?.id ?? "none"}.`, + ); + } + if (mutation.otherLayerPolicy !== "preserve") { + throw new Error( + `consume-sign ${mutation.opId} must preserve other storage layers.`, + ); + } + const replacementPalette = await resolveReplacement( + observations, + mutation.replacement, + ); + addClaim( + claims, + freezeBlockWrite({ + dimension: mutation.dimension, + location: mutation.location, + layer: 0, + expectedPalette, + replacementPalette, + blockEntityPolicy: "remove", + claim, + }), + ); + addClaim( + claims, + freezeBlockEntityRemoval({ + dimension: mutation.dimension, + location: mutation.location, + expectedId: mutation.expectedBlockEntityId, + claim, + }), + ); + break; + } + case "replace-blocks": { + validateBounds(mutation.bounds); + const expectedPalette = normalizePalette( + mutation.matchPalette, + ); + const replacementPalette = await resolveReplacement( + observations, + mutation.replacement, + ); + for await (const current of observations.blocks({ + dimension: mutation.dimension, + bounds: mutation.bounds, + includeAir: true, + })) { + if ( + current.layer !== mutation.layer || + !palettesEqual(current.palette, expectedPalette) + ) { + continue; + } + assertBlockEntityPolicy( + current, + mutation.blockEntityPolicy, + current.location, + ); + addClaim( + claims, + freezeBlockWrite({ + dimension: mutation.dimension, + location: current.location, + layer: mutation.layer, + expectedPalette, + replacementPalette, + blockEntityPolicy: mutation.blockEntityPolicy, + claim, + }), + ); + } + break; + } + } + } + } + return Object.freeze([...claims.values()].sort(comparePrimitiveMutations)); +} + +async function resolveReplacement( + observations: WorldObservationFacade, + replacement: WorldPaletteReplacement, +): Promise { + if (replacement.kind === "literal") { + return normalizePalette(replacement.palette); + } + return normalizePalette( + ( + await requireObservation( + observations, + replacement.dimension, + replacement.location, + replacement.layer, + ) + ).palette, + ); +} + +async function requireObservation( + observations: WorldObservationFacade, + dimension: WorldDimensionId, + location: WorldBlockLocation, + layer: number, +): Promise { + validateLocation(location); + if (!Number.isSafeInteger(layer) || layer < 0) { + throw new Error(`World storage layer must be a non-negative integer.`); + } + const matches: WorldBlockObservation[] = []; + for await (const observation of observations.blocks({ + dimension, + bounds: { min: location, max: location }, + includeAir: true, + })) { + if (observation.layer === layer) matches.push(observation); + } + if (matches.length === 0) { + throw new Error( + `World mutation requires an existing subchunk at ${formatLocation( + location, + )} layer ${layer}.`, + ); + } + if (matches.length > 1) { + throw new Error( + `World observation returned duplicate blocks at ${formatLocation( + location, + )} layer ${layer}.`, + ); + } + return matches[0]!; +} + +function addClaim( + claims: Map, + candidate: PrimitiveWorldMutation, +): void { + const key = mutationKey(candidate); + const existing = claims.get(key); + if (!existing) { + claims.set(key, candidate); + return; + } + const existingShape = withoutClaims(existing); + const candidateShape = withoutClaims(candidate); + if (canonicalJson(existingShape) !== canonicalJson(candidateShape)) { + const noun = + candidate.kind === "block-write" ? "block" : "block-entity"; + throw new Error( + `Conflicting ${noun} claims at ${candidate.dimension} ${formatLocation( + candidate.location, + )}${candidate.kind === "block-write" ? ` layer ${candidate.layer}` : ""}.`, + ); + } + const mergedClaims = [...existing.claims, ...candidate.claims].sort( + compareClaims, + ); + claims.set( + key, + Object.freeze({ ...existing, claims: Object.freeze(mergedClaims) }), + ); +} + +function freezeBlockWrite(input: { + dimension: WorldDimensionId; + location: WorldBlockLocation; + layer: number; + expectedPalette: WorldBlockPaletteEntry; + replacementPalette: WorldBlockPaletteEntry; + blockEntityPolicy: WorldBlockEntityPolicy; + claim: WorldMutationClaim; +}): PrimitiveWorldBlockWrite { + validateLocation(input.location); + return Object.freeze({ + kind: "block-write", + dimension: input.dimension, + location: freezeLocation(input.location), + layer: input.layer, + expectedPalette: input.expectedPalette, + replacementPalette: input.replacementPalette, + blockEntityPolicy: input.blockEntityPolicy, + claims: Object.freeze([input.claim]), + }); +} + +function freezeBlockEntityRemoval(input: { + dimension: WorldDimensionId; + location: WorldBlockLocation; + expectedId: string; + claim: WorldMutationClaim; +}): PrimitiveWorldBlockEntityRemoval { + validateLocation(input.location); + assertNonEmpty(input.expectedId, "expectedId"); + return Object.freeze({ + kind: "block-entity-remove", + dimension: input.dimension, + location: freezeLocation(input.location), + expectedId: input.expectedId, + claims: Object.freeze([input.claim]), + }); +} + +function normalizePalette( + input: WorldBlockPaletteEntry, +): WorldBlockPaletteEntry { + assertNonEmpty(input.typeId, "palette.typeId"); + if (!Number.isSafeInteger(input.version) || input.version < 0) { + throw new Error(`palette.version must be a non-negative integer.`); + } + const states: Record = {}; + for (const key of Object.keys(input.states).sort()) { + const value = input.states[key]; + if ( + typeof value !== "string" && + typeof value !== "boolean" && + (typeof value !== "number" || !Number.isFinite(value)) + ) { + throw new Error(`palette state ${key} is not a finite primitive.`); + } + states[key] = Object.is(value, -0) ? 0 : value; + } + return Object.freeze({ + typeId: input.typeId, + states: Object.freeze(states), + version: input.version, + }); +} + +function assertPaletteMatches( + actual: WorldBlockPaletteEntry, + expected: WorldBlockPaletteEntry, + location: WorldBlockLocation, +): void { + if (!palettesEqual(actual, expected)) { + throw new Error( + `World mutation expected palette ${expected.typeId} at ${formatLocation( + location, + )}, but the observation does not match.`, + ); + } +} + +function palettesEqual( + left: WorldBlockPaletteEntry, + right: WorldBlockPaletteEntry, +): boolean { + return canonicalJson(left) === canonicalJson(right); +} + +function assertBlockEntityPolicy( + observation: WorldBlockObservation, + policy: WorldBlockEntityPolicy, + location: WorldBlockLocation, +): void { + if (policy === "require-absent" && observation.blockEntity) { + throw new Error( + `World mutation requires no block entity at ${formatLocation( + location, + )}, found ${observation.blockEntity.id}.`, + ); + } +} + +function mutationKey(mutation: PrimitiveWorldMutation): string { + return `${mutation.kind}:${mutation.dimension}:${mutation.location.x}:${ + mutation.location.y + }:${mutation.location.z}:${mutation.kind === "block-write" ? mutation.layer : "entity"}`; +} + +function withoutClaims( + mutation: PrimitiveWorldMutation, +): Omit { + const { claims: _claims, ...rest } = mutation; + return rest; +} + +function comparePrimitiveMutations( + left: PrimitiveWorldMutation, + right: PrimitiveWorldMutation, +): number { + const dimensionOrder = { overworld: 0, nether: 1, the_end: 2 } as const; + return ( + dimensionOrder[left.dimension] - dimensionOrder[right.dimension] || + Math.floor(left.location.x / 16) - Math.floor(right.location.x / 16) || + Math.floor(left.location.z / 16) - Math.floor(right.location.z / 16) || + Math.floor(left.location.y / 16) - Math.floor(right.location.y / 16) || + (left.kind === "block-write" ? 0 : 1) - + (right.kind === "block-write" ? 0 : 1) || + (left.kind === "block-write" ? left.layer : -1) - + (right.kind === "block-write" ? right.layer : -1) || + (((left.location.x % 16) + 16) % 16) - + (((right.location.x % 16) + 16) % 16) || + (((left.location.z % 16) + 16) % 16) - + (((right.location.z % 16) + 16) % 16) || + (((left.location.y % 16) + 16) % 16) - + (((right.location.y % 16) + 16) % 16) || + left.kind.localeCompare(right.kind) + ); +} + +function compareClaims( + left: WorldMutationClaim, + right: WorldMutationClaim, +): number { + return ( + left.processorId.localeCompare(right.processorId) || + left.opId.localeCompare(right.opId) + ); +} + +function freezeClaim(processorId: string, opId: string): WorldMutationClaim { + assertNonEmpty(processorId, "processorId"); + assertNonEmpty(opId, "opId"); + return Object.freeze({ processorId, opId }); +} + +function freezeLocation(location: WorldBlockLocation): WorldBlockLocation { + return Object.freeze({ x: location.x, y: location.y, z: location.z }); +} + +function validateLocation(location: WorldBlockLocation): void { + for (const axis of ["x", "y", "z"] as const) { + if (!Number.isSafeInteger(location[axis])) { + throw new Error( + `World mutation ${axis} coordinate must be an integer.`, + ); + } + } +} + +function validateBounds(bounds: { + min: WorldBlockLocation; + max: WorldBlockLocation; +}): void { + validateLocation(bounds.min); + validateLocation(bounds.max); + for (const axis of ["x", "y", "z"] as const) { + if (bounds.min[axis] > bounds.max[axis]) { + throw new Error(`World mutation bounds are inverted on ${axis}.`); + } + } +} + +function formatLocation(location: WorldBlockLocation): string { + return `${location.x},${location.y},${location.z}`; +} + +function assertNonEmpty(value: string, source: string): void { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${source} must be a non-empty string.`); + } +} diff --git a/packages/blr/src/world-processing/observation-facade.ts b/packages/blr/src/world-processing/observation-facade.ts new file mode 100644 index 0000000..4098554 --- /dev/null +++ b/packages/blr/src/world-processing/observation-facade.ts @@ -0,0 +1,502 @@ +import { LevelDB } from "@8crafter/leveldb-zlib"; +import { + loadMcbeLeveldbHelpers, + type McbeLeveldbHelpers, +} from "../mcbe-leveldb-adapter.js"; +import { normalizeChunkDimension } from "../world-image-dimension.js"; +import type { + WorldBlockBounds, + WorldBlockEntityObservation, + WorldBlockObservation, + WorldDimensionId, + WorldObservationFacade, + WorldObservationQuery, + WorldSignBlockEntityObservation, + WorldSignObservation, + WorldSignObservationQuery, +} from "../world-processing.js"; +import { decodeBedrockBlockEntities } from "./bedrock-block-entities.js"; +import { decodeSignOrientation } from "./bedrock-block-entities.js"; +import { + decodeBedrockSubchunk, + type DecodedBedrockSubchunk, +} from "./bedrock-subchunk.js"; + +export const DEFAULT_WORLD_OBSERVATION_LIMITS = Object.freeze({ + maxPositionsPerQuery: 1_048_576, + maxPositionsPerRun: 16_777_216, + maxSignsPerQuery: 100_000, + maxSignsPerRun: 1_000_000, +}); + +export type WorldObservationLimits = { + readonly maxPositionsPerQuery: number; + readonly maxPositionsPerRun: number; + readonly maxSignsPerQuery: number; + readonly maxSignsPerRun: number; +}; + +export type BedrockWorldObservationOptions = { + readonly dbPath: string; + readonly limits?: Partial; + readonly signal?: AbortSignal; +}; + +export async function withBedrockWorldObservations( + options: BedrockWorldObservationOptions, + operation: (observations: WorldObservationFacade) => Promise, +): Promise { + const helpers = await loadMcbeLeveldbHelpers(); + const db = new LevelDB(options.dbPath, { + createIfMissing: false, + bufferKeys: true, + }); + await db.open(); + try { + const observations = new BedrockWorldObservationFacade( + db, + helpers, + normalizeLimits(options.limits), + options.signal, + ); + return await operation(observations); + } finally { + await db.close(); + } +} + +class BedrockWorldObservationFacade implements WorldObservationFacade { + readonly #subchunks = new Map< + string, + Promise + >(); + readonly #blockEntities = new Map< + string, + Promise> + >(); + #allSigns: Promise | undefined; + #positionsRead = 0; + #signsRead = 0; + + constructor( + private readonly db: LevelDB, + private readonly helpers: McbeLeveldbHelpers, + private readonly limits: WorldObservationLimits, + private readonly signal?: AbortSignal, + ) {} + + async *blocks( + query: WorldObservationQuery, + ): AsyncIterable { + throwIfAborted(this.signal); + validateBounds(query.bounds); + const positionCount = countPositions(query.bounds); + if (positionCount > this.limits.maxPositionsPerQuery) { + throw new Error( + `World observation query covers ${positionCount} positions, but the per-query limit is ${this.limits.maxPositionsPerQuery}.`, + ); + } + const nextTotal = this.#positionsRead + positionCount; + if (nextTotal > this.limits.maxPositionsPerRun) { + throw new Error( + `World observation run would cover ${nextTotal} positions, but the run limit is ${this.limits.maxPositionsPerRun}.`, + ); + } + this.#positionsRead = nextTotal; + + const typeIds = query.typeIds ? new Set(query.typeIds) : undefined; + for ( + let worldX = query.bounds.min.x; + worldX <= query.bounds.max.x; + worldX += 1 + ) { + const chunkX = floorDiv(worldX, 16); + const localX = floorMod(worldX, 16); + for ( + let worldY = query.bounds.min.y; + worldY <= query.bounds.max.y; + worldY += 1 + ) { + const subChunkY = floorDiv(worldY, 16); + const localY = floorMod(worldY, 16); + for ( + let worldZ = query.bounds.min.z; + worldZ <= query.bounds.max.z; + worldZ += 1 + ) { + throwIfAborted(this.signal); + const chunkZ = floorDiv(worldZ, 16); + const localZ = floorMod(worldZ, 16); + const subchunk = await this.#readSubchunk( + query.dimension, + chunkX, + chunkZ, + subChunkY, + ); + if (!subchunk) continue; + const blockIndex = (localX << 8) | (localZ << 4) | localY; + const blockEntity = await this.#readBlockEntity( + query.dimension, + chunkX, + chunkZ, + worldX, + worldY, + worldZ, + ); + for ( + let layerIndex = 0; + layerIndex < subchunk.layers.length; + layerIndex += 1 + ) { + const layer = subchunk.layers[layerIndex]!; + const paletteIndex = layer.indices[blockIndex] ?? 0; + const palette = layer.palette[paletteIndex]; + if (!palette) continue; + if ( + !query.includeAir && + palette.typeId === "minecraft:air" + ) { + continue; + } + if (typeIds && !typeIds.has(palette.typeId)) continue; + const location = Object.freeze({ + x: worldX, + y: worldY, + z: worldZ, + }); + const signOrientation = decodeSignOrientation(palette); + yield Object.freeze({ + dimension: query.dimension, + location, + layer: layerIndex, + palette, + ...(layerIndex === 0 && blockEntity + ? { blockEntity } + : {}), + ...(signOrientation ? { signOrientation } : {}), + }); + } + } + } + } + } + + async *signs( + query: WorldSignObservationQuery = {}, + ): AsyncIterable { + throwIfAborted(this.signal); + if (query.bounds) validateBounds(query.bounds); + this.#allSigns ??= this.#loadAllSigns(); + const signs = (await this.#allSigns).filter( + (sign) => + (query.dimension === undefined || + sign.dimension === query.dimension) && + (query.bounds === undefined || + containsLocation(query.bounds, sign.location)), + ); + if (signs.length > this.limits.maxSignsPerQuery) { + throw new Error( + `World sign query found ${signs.length} signs, but the per-query limit is ${this.limits.maxSignsPerQuery}.`, + ); + } + const nextTotal = this.#signsRead + signs.length; + if (nextTotal > this.limits.maxSignsPerRun) { + throw new Error( + `World sign run would return ${nextTotal} signs, but the run limit is ${this.limits.maxSignsPerRun}.`, + ); + } + this.#signsRead = nextTotal; + for (const sign of signs) { + throwIfAborted(this.signal); + yield sign; + } + } + + async #loadAllSigns(): Promise { + const pending: Array<{ + readonly dimension: WorldDimensionId; + readonly entity: WorldSignBlockEntityObservation; + }> = []; + const seen = new Set(); + for await (const [rawKey, rawValue] of this.db.getIterator({ + keys: true, + values: true, + keyAsBuffer: true, + valueAsBuffer: true, + })) { + throwIfAborted(this.signal); + if (!Buffer.isBuffer(rawKey) || !rawValue) continue; + if ( + this.helpers.getContentTypeFromDBKey(rawKey) !== "BlockEntity" + ) { + continue; + } + const indices = this.helpers.getChunkKeyIndices(rawKey); + const dimension = normalizeChunkDimension(indices.dimension); + if (!dimension) continue; + const entities = await decodeBedrockBlockEntities( + Buffer.isBuffer(rawValue) ? rawValue : Buffer.from(rawValue), + ); + for (const entity of entities) { + if (entity.id !== "Sign") continue; + if ( + floorDiv(entity.location.x, 16) !== indices.x || + floorDiv(entity.location.z, 16) !== indices.z + ) { + throw new Error( + `Sign block entity at ${formatLocation(entity.location)} is stored under the wrong ${dimension} chunk key ${indices.x},${indices.z}.`, + ); + } + const identity = `${dimension}:${formatLocation(entity.location)}`; + if (seen.has(identity)) { + throw new Error( + `World contains duplicate sign block entities at ${identity}.`, + ); + } + seen.add(identity); + pending.push({ + dimension, + entity: Object.freeze({ + ...entity, + id: "Sign", + signFaces: entity.signFaces ?? Object.freeze([]), + }), + }); + } + } + + const signs: WorldSignObservation[] = []; + for (const entry of pending) { + throwIfAborted(this.signal); + signs.push(await this.#resolveSign(entry.dimension, entry.entity)); + } + signs.sort(compareSigns); + return Object.freeze(signs); + } + + async #resolveSign( + dimension: WorldDimensionId, + blockEntity: WorldSignBlockEntityObservation, + ): Promise { + const { x, y, z } = blockEntity.location; + const subchunk = await this.#readSubchunk( + dimension, + floorDiv(x, 16), + floorDiv(z, 16), + floorDiv(y, 16), + ); + if (!subchunk) { + throw new Error( + `Sign block entity at ${dimension} ${formatLocation(blockEntity.location)} has no containing subchunk.`, + ); + } + const blockIndex = + (floorMod(x, 16) << 8) | (floorMod(z, 16) << 4) | floorMod(y, 16); + const layer = subchunk.layers[0]; + const palette = layer?.palette[layer.indices[blockIndex] ?? 0]; + if (!palette) { + throw new Error( + `Sign block entity at ${dimension} ${formatLocation(blockEntity.location)} has no layer-zero block.`, + ); + } + const signOrientation = decodeSignOrientation(palette); + if (!signOrientation) { + throw new Error( + `Sign block entity at ${dimension} ${formatLocation(blockEntity.location)} belongs to non-sign block ${palette.typeId}.`, + ); + } + return Object.freeze({ + dimension, + location: blockEntity.location, + layer: 0, + palette, + blockEntity, + signOrientation, + }); + } + + async #readSubchunk( + dimension: WorldDimensionId, + chunkX: number, + chunkZ: number, + subChunkY: number, + ): Promise { + const cacheKey = `${dimension}:${chunkX}:${chunkZ}:${subChunkY}`; + let pending = this.#subchunks.get(cacheKey); + if (!pending) { + pending = this.#loadSubchunk(dimension, chunkX, chunkZ, subChunkY); + this.#subchunks.set(cacheKey, pending); + } + return pending; + } + + async #loadSubchunk( + dimension: WorldDimensionId, + chunkX: number, + chunkZ: number, + subChunkY: number, + ): Promise { + const raw = await this.db.get( + this.helpers.generateChunkKeyFromIndices( + { + x: chunkX, + z: chunkZ, + dimension, + subChunkIndex: subChunkY, + }, + "SubChunkPrefix", + ), + ); + if (!raw) return undefined; + return decodeBedrockSubchunk(raw); + } + + async #readBlockEntity( + dimension: WorldDimensionId, + chunkX: number, + chunkZ: number, + worldX: number, + worldY: number, + worldZ: number, + ): Promise { + const cacheKey = `${dimension}:${chunkX}:${chunkZ}`; + let pending = this.#blockEntities.get(cacheKey); + if (!pending) { + pending = this.#loadBlockEntities(dimension, chunkX, chunkZ); + this.#blockEntities.set(cacheKey, pending); + } + const entities = await pending; + return entities.get(`${worldX}:${worldY}:${worldZ}`); + } + + async #loadBlockEntities( + dimension: WorldDimensionId, + chunkX: number, + chunkZ: number, + ): Promise> { + const raw = await this.db.get( + this.helpers.generateChunkKeyFromIndices( + { x: chunkX, z: chunkZ, dimension }, + "BlockEntity", + ), + ); + if (!raw) return new Map(); + const result = new Map(); + for (const entity of await decodeBedrockBlockEntities(raw)) { + const key = `${entity.location.x}:${entity.location.y}:${entity.location.z}`; + if (result.has(key)) { + throw new Error( + `Chunk ${dimension} ${chunkX},${chunkZ} contains duplicate block entities at ${key}.`, + ); + } + result.set(key, entity); + } + return result; + } +} + +function normalizeLimits( + input?: Partial, +): WorldObservationLimits { + const limits = { + maxPositionsPerQuery: + input?.maxPositionsPerQuery ?? + DEFAULT_WORLD_OBSERVATION_LIMITS.maxPositionsPerQuery, + maxPositionsPerRun: + input?.maxPositionsPerRun ?? + DEFAULT_WORLD_OBSERVATION_LIMITS.maxPositionsPerRun, + maxSignsPerQuery: + input?.maxSignsPerQuery ?? + DEFAULT_WORLD_OBSERVATION_LIMITS.maxSignsPerQuery, + maxSignsPerRun: + input?.maxSignsPerRun ?? + DEFAULT_WORLD_OBSERVATION_LIMITS.maxSignsPerRun, + }; + for (const [name, value] of Object.entries(limits)) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error( + `World observation ${name} must be a positive integer.`, + ); + } + } + return Object.freeze(limits); +} + +function containsLocation( + bounds: WorldBlockBounds, + location: { readonly x: number; readonly y: number; readonly z: number }, +): boolean { + return ( + location.x >= bounds.min.x && + location.x <= bounds.max.x && + location.y >= bounds.min.y && + location.y <= bounds.max.y && + location.z >= bounds.min.z && + location.z <= bounds.max.z + ); +} + +function compareSigns( + left: WorldSignObservation, + right: WorldSignObservation, +): number { + const dimensionOrder = { overworld: 0, nether: 1, the_end: 2 } as const; + return ( + dimensionOrder[left.dimension] - dimensionOrder[right.dimension] || + left.location.x - right.location.x || + left.location.y - right.location.y || + left.location.z - right.location.z + ); +} + +function formatLocation(location: { + readonly x: number; + readonly y: number; + readonly z: number; +}): string { + return `${location.x},${location.y},${location.z}`; +} + +function validateBounds(bounds: WorldBlockBounds): void { + for (const axis of ["x", "y", "z"] as const) { + const min = bounds.min[axis]; + const max = bounds.max[axis]; + if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) { + throw new Error( + `World observation ${axis} bounds must be integers.`, + ); + } + if (min > max) { + throw new Error( + `World observation ${axis} minimum must not exceed its maximum.`, + ); + } + } +} + +function countPositions(bounds: WorldBlockBounds): number { + const count = + (bounds.max.x - bounds.min.x + 1) * + (bounds.max.y - bounds.min.y + 1) * + (bounds.max.z - bounds.min.z + 1); + if (!Number.isSafeInteger(count)) { + throw new Error("World observation bounds cover too many positions."); + } + return count; +} + +export function floorDiv(value: number, divisor: number): number { + return Math.floor(value / divisor); +} + +function floorMod(value: number, divisor: number): number { + return ((value % divisor) + divisor) % divisor; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException("World observation was aborted.", "AbortError"); + } +} diff --git a/packages/blr/src/world-processing/processor-build.ts b/packages/blr/src/world-processing/processor-build.ts new file mode 100644 index 0000000..6a5a196 --- /dev/null +++ b/packages/blr/src/world-processing/processor-build.ts @@ -0,0 +1,831 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { + cp, + mkdir, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { LevelDB } from "@8crafter/leveldb-zlib"; +import type { + ResolvedWorldProcessorConfig, + WorldProcessorDiagnostic, + WorldProcessorPipelineIntent, + WorldSourceIdentity, +} from "../world-processing.js"; +import { + publishWorldProcessorArtifactSet, + type PublishedWorldProcessorArtifactSet, +} from "./artifact-set.js"; +import { canonicalJson, hashCanonicalJson } from "./canonical-json.js"; +import { + normalizeWorldMutationPlans, + type PrimitiveWorldMutation, +} from "./mutations.js"; +import { withBedrockWorldObservations } from "./observation-facade.js"; +import { + runWorldProcessorGraph, + type WorldProcessorExecutionResult, +} from "./runner.js"; +import { + withVerifiedWorldSnapshot, + type VerifiedWorldSnapshot, +} from "./world-snapshot.js"; +import { applyBedrockWorldMutations } from "./writer.js"; + +export type BuildProcessedWorldOptions = { + readonly projectRoot: string; + readonly worldName: string; + readonly sourceWorldDirectory: string; + readonly configs: readonly ResolvedWorldProcessorConfig[]; + readonly pipeline?: WorldProcessorPipelineIntent; + readonly processorIds?: readonly string[]; + readonly outputDirectory?: string; + readonly audit?: boolean; + readonly mode: "check" | "bake"; + readonly signal: AbortSignal; + readonly isCurrent?: () => boolean; + readonly verifyMutations?: boolean; +}; + +export type ProcessedWorldBuildStatus = "current" | "stale" | "built"; + +export type ProcessedWorldBuildResult = { + readonly status: ProcessedWorldBuildStatus; + readonly worldName: string; + readonly worldBuildId: string; + readonly sourceIdentity: WorldSourceIdentity; + readonly worldContentHash: string; + readonly mutationPlanHash: string; + readonly processorIds: readonly string[]; + readonly setDirectory: string; + readonly worldDirectory: string; + readonly pointerPath: string; + readonly diagnostics: readonly WorldProcessorDiagnostic[]; +}; + +export type RunWorldProcessorArtifactPipelineOptions = { + readonly projectRoot: string; + readonly worldName: string; + readonly sourceWorldDirectory: string; + readonly configs: readonly ResolvedWorldProcessorConfig[]; + readonly pipeline: WorldProcessorPipelineIntent; + readonly mode: "check" | "bake"; + readonly audit?: boolean; + readonly signal: AbortSignal; + readonly isCurrent?: () => boolean; + /** Apply the normalized mutation plan to the disposable snapshot for verification. */ + readonly verifyMutations?: boolean; +}; + +export type WorldProcessorArtifactPipelineResult = { + readonly status: "current" | "published" | "stale"; + readonly processorIds: readonly string[]; + readonly diagnostics: readonly WorldProcessorDiagnostic[]; +}; + +type ArtifactBinding = { + readonly processorId: string; + readonly artifactSetId: string; + readonly manifestContentHash: string; +}; + +type ProcessedWorldLogicalManifest = { + readonly schemaVersion: 1; + readonly worldName: string; + readonly pipeline: WorldProcessorPipelineIntent; + readonly sourceIdentity: WorldSourceIdentity; + readonly worldContentHash: string; + readonly mutationPlanHash: string; + readonly processors: readonly { + readonly id: string; + readonly providerRevision: string; + readonly logicalInputHash: string; + }[]; + readonly artifacts: readonly ArtifactBinding[]; +}; + +type WorldFileInventoryEntry = { + readonly path: string; + readonly size: number; + readonly contentHash: string; +}; + +type ProcessedWorldManifestV1 = ProcessedWorldLogicalManifest & { + readonly worldBuildId: string; + readonly worldFiles: readonly WorldFileInventoryEntry[]; +}; + +type PreparedBuild = { + readonly snapshot: VerifiedWorldSnapshot; + readonly executions: readonly WorldProcessorExecutionResult[]; + readonly mutations: readonly PrimitiveWorldMutation[]; + readonly mutationPlanHash: string; + readonly worldContentHash: string; + readonly logicalManifest: ProcessedWorldLogicalManifest; + readonly worldBuildId: string; + readonly artifactChecks: readonly PublishedWorldProcessorArtifactSet[]; + readonly diagnostics: readonly WorldProcessorDiagnostic[]; +}; + +/** + * Builds or verifies a processed-world lineage from one immutable source + * snapshot. The authored world is never opened and can never be an output. + */ +export async function buildProcessedWorld( + options: BuildProcessedWorldOptions, +): Promise { + throwIfAborted(options.signal); + assertOutputDoesNotOverlapSource( + options.sourceWorldDirectory, + options.outputDirectory, + ); + const pipeline = options.pipeline ?? "world-build"; + const configs = selectProcessorConfigs( + options.configs, + options.worldName, + options.processorIds, + pipeline, + ); + if (configs.length === 0) { + throw new Error( + `No world processors apply to ${options.worldName} for world build.`, + ); + } + const configById = new Map(configs.map((config) => [config.id, config])); + const lineageRoot = path.join( + options.projectRoot, + ".blr", + "world-processing", + "worlds", + options.worldName, + pipeline, + ); + + return withVerifiedWorldSnapshot( + { + worldName: options.worldName, + sourceWorldDirectory: options.sourceWorldDirectory, + signal: options.signal, + }, + async (snapshot) => { + const prepared = await prepareProcessedWorld( + options, + snapshot, + configs, + configById, + pipeline, + ); + const setDirectory = path.join( + lineageRoot, + "sets", + prepared.worldBuildId, + ); + const worldDirectory = path.join(setDirectory, "world"); + const pointerPath = path.join(lineageRoot, "current.json"); + const setCurrent = await processedSetIsCurrent( + setDirectory, + prepared.logicalManifest, + prepared.worldBuildId, + ); + const expectedPointer = canonicalJson({ + schemaVersion: 1, + worldName: options.worldName, + worldBuildId: prepared.worldBuildId, + manifest: `sets/${prepared.worldBuildId}/manifest.json`, + world: `sets/${prepared.worldBuildId}/world`, + }); + const pointerCurrent = await fileEquals( + pointerPath, + expectedPointer, + ); + const artifactsCurrent = prepared.artifactChecks.every( + (artifact) => artifact.status === "current", + ); + + if (options.mode === "check") { + return result( + setCurrent && pointerCurrent && artifactsCurrent + ? "current" + : "stale", + ); + } + throwIfAborted(options.signal); + let changed = false; + if (!setCurrent) { + if (await exists(setDirectory)) { + throw new Error( + `Processed world set ${prepared.worldBuildId} exists but does not match its logical manifest or file inventory.`, + ); + } + await publishProcessedSet( + snapshot.worldDirectory, + setDirectory, + prepared.logicalManifest, + prepared.worldBuildId, + options, + ); + changed = true; + } + for (const execution of prepared.executions) { + const config = configById.get(execution.processorId)!; + if ( + config.capabilities.includes("artifact") && + execution.result.artifacts.length > 0 + ) { + const published = await publishWorldProcessorArtifactSet({ + projectRoot: options.projectRoot, + config, + execution, + mode: "bake", + isCurrent: options.isCurrent, + }); + if (published.status === "stale") { + return result("stale"); + } + if (published.status === "published") changed = true; + } + if (options.isCurrent && !options.isCurrent()) { + return result("stale"); + } + if (options.audit && config.auditOutputPath) { + await writeAudit(options.projectRoot, config, execution); + changed = true; + } + } + if (options.isCurrent && !options.isCurrent()) { + return result("stale"); + } + if (!pointerCurrent) { + await replaceFileAtomically(pointerPath, expectedPointer); + changed = true; + } + if (options.outputDirectory) { + await copyExplicitOutput( + worldDirectory, + options.outputDirectory, + ); + changed = true; + } + return result(changed ? "built" : "current"); + + function result( + status: ProcessedWorldBuildStatus, + ): ProcessedWorldBuildResult { + return Object.freeze({ + status, + worldName: options.worldName, + worldBuildId: prepared.worldBuildId, + sourceIdentity: prepared.snapshot.sourceIdentity, + worldContentHash: prepared.worldContentHash, + mutationPlanHash: prepared.mutationPlanHash, + processorIds: Object.freeze( + prepared.executions.map( + (execution) => execution.processorId, + ), + ), + setDirectory, + worldDirectory, + pointerPath, + diagnostics: prepared.diagnostics, + }); + } + }, + ); +} + +/** + * Runs the same processor graph and mutation verification used by world build, + * but publishes only runtime artifacts. This is the build-stage hook: world + * mutations are proven on a disposable snapshot and never materialised. + */ +export async function runWorldProcessorArtifactPipeline( + options: RunWorldProcessorArtifactPipelineOptions, +): Promise { + const configs = selectProcessorConfigs( + options.configs, + options.worldName, + undefined, + options.pipeline, + ); + if (configs.length === 0) { + return Object.freeze({ + status: "current", + processorIds: Object.freeze([]), + diagnostics: Object.freeze([]), + }); + } + const configById = new Map(configs.map((config) => [config.id, config])); + return withVerifiedWorldSnapshot( + { + worldName: options.worldName, + sourceWorldDirectory: options.sourceWorldDirectory, + signal: options.signal, + }, + async (snapshot) => { + const prepared = await prepareProcessedWorld( + options, + snapshot, + configs, + configById, + options.pipeline, + ); + let published = false; + for (const execution of prepared.executions) { + const config = configById.get(execution.processorId)!; + if ( + !config.capabilities.includes("artifact") || + execution.result.artifacts.length === 0 + ) { + continue; + } + const artifact = await publishWorldProcessorArtifactSet({ + projectRoot: options.projectRoot, + config, + execution, + mode: options.mode, + isCurrent: options.isCurrent, + }); + if (artifact.status === "stale") { + return output("stale"); + } + if (artifact.status === "published") published = true; + if ( + options.mode === "bake" && + options.audit && + config.auditOutputPath + ) { + await writeAudit(options.projectRoot, config, execution); + published = true; + } + } + return output(published ? "published" : "current"); + + function output( + status: WorldProcessorArtifactPipelineResult["status"], + ): WorldProcessorArtifactPipelineResult { + return Object.freeze({ + status, + processorIds: Object.freeze( + prepared.executions.map( + (execution) => execution.processorId, + ), + ), + diagnostics: prepared.diagnostics, + }); + } + }, + ); +} + +async function prepareProcessedWorld( + options: BuildProcessedWorldOptions, + snapshot: VerifiedWorldSnapshot, + configs: readonly ResolvedWorldProcessorConfig[], + configById: ReadonlyMap, + pipeline: WorldProcessorPipelineIntent, +): Promise { + let executions: readonly WorldProcessorExecutionResult[] = []; + let mutations: readonly PrimitiveWorldMutation[] = []; + await withBedrockWorldObservations( + { dbPath: snapshot.dbPath, signal: options.signal }, + async (observations) => { + executions = await runWorldProcessorGraph({ + projectRoot: options.projectRoot, + configs, + sourceIdentity: snapshot.sourceIdentity, + observations, + pipeline, + mode: options.mode, + signal: options.signal, + cacheRoot: path.join(options.projectRoot, ".blr", "cache"), + }); + validateCapabilities(executions, configById); + mutations = await normalizeWorldMutationPlans( + executions + .filter((execution) => + configById + .get(execution.processorId)! + .capabilities.includes("transform"), + ) + .map((execution) => ({ + processorId: execution.processorId, + mutations: execution.result.mutations, + })), + observations, + ); + }, + ); + const diagnostics = Object.freeze( + executions.flatMap((execution) => execution.result.diagnostics), + ); + const errors = diagnostics.filter( + (diagnostic) => diagnostic.severity === "error", + ); + if (errors.length > 0) { + throw new Error( + `World processors reported ${errors.length} error diagnostic(s): ${errors + .map((diagnostic) => diagnostic.message) + .join("; ")}`, + ); + } + const mutationPlanHash = hashCanonicalJson({ + schemaVersion: 1, + mutations, + }); + if (mutations.length > 0 && options.verifyMutations !== false) { + await applyBedrockWorldMutations({ + dbPath: snapshot.dbPath, + mutations, + }); + } + const worldContentHash = await hashLogicalWorld(snapshot.worldDirectory); + const artifactChecks: PublishedWorldProcessorArtifactSet[] = []; + const artifactBindings: ArtifactBinding[] = []; + for (const execution of executions) { + const config = configById.get(execution.processorId)!; + if ( + !config.capabilities.includes("artifact") || + execution.result.artifacts.length === 0 + ) { + continue; + } + const checked = await publishWorldProcessorArtifactSet({ + projectRoot: options.projectRoot, + config, + execution, + mode: "check", + }); + artifactChecks.push(checked); + artifactBindings.push( + Object.freeze({ + processorId: execution.processorId, + artifactSetId: checked.artifactSetId, + manifestContentHash: checked.manifestContentHash, + }), + ); + } + const logicalManifest: ProcessedWorldLogicalManifest = Object.freeze({ + schemaVersion: 1, + worldName: options.worldName, + pipeline, + sourceIdentity: snapshot.sourceIdentity, + worldContentHash, + mutationPlanHash, + processors: Object.freeze( + executions.map((execution) => + Object.freeze({ + id: execution.processorId, + providerRevision: execution.providerRevision, + logicalInputHash: execution.logicalInputHash, + }), + ), + ), + artifacts: Object.freeze(artifactBindings), + }); + return Object.freeze({ + snapshot, + executions, + mutations, + mutationPlanHash, + worldContentHash, + logicalManifest, + worldBuildId: hashCanonicalJson(logicalManifest), + artifactChecks: Object.freeze(artifactChecks), + diagnostics, + }); +} + +function validateCapabilities( + executions: readonly WorldProcessorExecutionResult[], + configById: ReadonlyMap, +): void { + for (const execution of executions) { + const config = configById.get(execution.processorId)!; + if ( + execution.result.artifacts.length > 0 && + !config.capabilities.includes("artifact") + ) { + throw new Error( + `World processor ${config.id} emitted artifacts without the artifact capability.`, + ); + } + if ( + execution.result.mutations.length > 0 && + !config.capabilities.includes("transform") + ) { + throw new Error( + `World processor ${config.id} emitted mutations without the transform capability.`, + ); + } + } +} + +function selectProcessorConfigs( + allConfigs: readonly ResolvedWorldProcessorConfig[], + worldName: string, + processorIds: readonly string[] | undefined, + pipeline: WorldProcessorPipelineIntent, +): readonly ResolvedWorldProcessorConfig[] { + const eligible = allConfigs.filter( + (config) => + config.sourceWorld === worldName && appliesOn(config, pipeline), + ); + if (!processorIds || processorIds.length === 0) + return Object.freeze(eligible); + const byId = new Map(eligible.map((config) => [config.id, config])); + const selected = new Set(); + const visit = (id: string): void => { + const config = byId.get(id); + if (!config) throw new Error(`Unknown world processor ${id}.`); + if (selected.has(id)) return; + selected.add(id); + for (const dependency of config.dependsOn) visit(dependency); + }; + for (const id of processorIds) visit(id); + return Object.freeze(eligible.filter((config) => selected.has(config.id))); +} + +function appliesOn( + config: ResolvedWorldProcessorConfig, + pipeline: WorldProcessorPipelineIntent, +): boolean { + switch (pipeline) { + case "dev": + return config.applyOn.dev; + case "build": + return config.applyOn.build; + case "package": + return config.applyOn.package; + case "check": + return config.applyOn.check; + case "world-build": + return config.applyOn.worldBuild; + case "world-push": + return config.applyOn.worldPush; + } +} + +async function publishProcessedSet( + sourceWorldDirectory: string, + setDirectory: string, + logicalManifest: ProcessedWorldLogicalManifest, + worldBuildId: string, + options: BuildProcessedWorldOptions, +): Promise { + const parent = path.dirname(setDirectory); + await mkdir(parent, { recursive: true }); + const staging = path.join(parent, `.${worldBuildId}.${randomUUID()}.tmp`); + try { + await cp(sourceWorldDirectory, path.join(staging, "world"), { + recursive: true, + errorOnExist: true, + force: false, + }); + const worldFiles = await inventoryFiles(path.join(staging, "world")); + const manifest: ProcessedWorldManifestV1 = Object.freeze({ + ...logicalManifest, + worldBuildId, + worldFiles, + }); + await writeFile( + path.join(staging, "manifest.json"), + canonicalJson(manifest), + { flag: "wx" }, + ); + if (options.isCurrent && !options.isCurrent()) { + throw new Error( + "Processed world build was superseded before publication.", + ); + } + await rename(staging, setDirectory); + } finally { + await rm(staging, { recursive: true, force: true }); + } +} + +async function processedSetIsCurrent( + setDirectory: string, + logicalManifest: ProcessedWorldLogicalManifest, + worldBuildId: string, +): Promise { + let parsed: ProcessedWorldManifestV1; + try { + parsed = JSON.parse( + await readFile(path.join(setDirectory, "manifest.json"), "utf8"), + ) as ProcessedWorldManifestV1; + } catch { + return false; + } + const { + worldFiles, + worldBuildId: parsedBuildId, + ...parsedLogical + } = parsed; + if ( + parsedBuildId !== worldBuildId || + canonicalJson(parsedLogical) !== canonicalJson(logicalManifest) || + !Array.isArray(worldFiles) + ) { + return false; + } + const actual = await inventoryFiles(path.join(setDirectory, "world")).catch( + () => undefined, + ); + return !!actual && canonicalJson(actual) === canonicalJson(worldFiles); +} + +async function hashLogicalWorld(worldDirectory: string): Promise { + const hash = createHash("sha256"); + for (const entry of await inventoryFiles(worldDirectory, "db")) { + hash.update(entry.path).update("\0"); + hash.update(String(entry.size)).update("\0"); + hash.update(entry.contentHash).update("\n"); + } + const db = new LevelDB(path.join(worldDirectory, "db"), { + createIfMissing: false, + bufferKeys: true, + }); + await db.open(); + try { + for await (const [rawKey, rawValue] of db.getIterator({ + keys: true, + values: true, + keyAsBuffer: true, + valueAsBuffer: true, + })) { + const key = Buffer.from(rawKey as Uint8Array); + const value = Buffer.from(rawValue as Uint8Array); + const sizes = Buffer.alloc(8); + sizes.writeUInt32LE(key.length, 0); + sizes.writeUInt32LE(value.length, 4); + hash.update(sizes).update(key).update(value); + } + } finally { + await db.close(); + } + return hash.digest("hex"); +} + +async function inventoryFiles( + root: string, + excludedTopLevel?: string, +): Promise { + const result: WorldFileInventoryEntry[] = []; + await visit(""); + return Object.freeze( + result.sort((left, right) => left.path.localeCompare(right.path)), + ); + + async function visit(relativeDirectory: string): Promise { + const entries = await readdir(path.join(root, relativeDirectory), { + withFileTypes: true, + }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const relativePath = path + .join(relativeDirectory, entry.name) + .replace(/\\/g, "/"); + if (!relativeDirectory && entry.name === excludedTopLevel) continue; + if (entry.isDirectory()) { + await visit(relativePath); + } else if (entry.isFile()) { + const absolutePath = path.join(root, relativePath); + const details = await stat(absolutePath); + result.push( + Object.freeze({ + path: relativePath, + size: details.size, + contentHash: await hashFile(absolutePath), + }), + ); + } else { + throw new Error( + `Processed world contains unsupported entry ${relativePath}.`, + ); + } + } + } +} + +async function hashFile(filePath: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(filePath)) hash.update(chunk); + return hash.digest("hex"); +} + +async function writeAudit( + projectRoot: string, + config: ResolvedWorldProcessorConfig, + execution: WorldProcessorExecutionResult, +): Promise { + const target = path.resolve(projectRoot, config.auditOutputPath!); + await mkdir(path.dirname(target), { recursive: true }); + await replaceFileAtomically( + target, + canonicalJson({ + schemaVersion: 1, + processorId: execution.processorId, + providerRevision: execution.providerRevision, + logicalInputHash: execution.logicalInputHash, + diagnostics: execution.result.diagnostics, + ...(execution.result.audit === undefined + ? {} + : { audit: execution.result.audit }), + }), + ); +} + +async function replaceFileAtomically( + target: string, + contents: string, +): Promise { + await mkdir(path.dirname(target), { recursive: true }); + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${randomUUID()}.tmp`, + ); + try { + await writeFile(temporary, contents, { flag: "wx" }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }); + } +} + +async function copyExplicitOutput( + source: string, + outputDirectory: string, +): Promise { + if (await exists(outputDirectory)) { + throw new Error( + `Explicit processed-world output already exists: ${outputDirectory}.`, + ); + } + const parent = path.dirname(outputDirectory); + await mkdir(parent, { recursive: true }); + const staging = path.join( + parent, + `.${path.basename(outputDirectory)}.${randomUUID()}.tmp`, + ); + try { + await cp(source, staging, { + recursive: true, + errorOnExist: true, + force: false, + }); + await rename(staging, outputDirectory); + } finally { + await rm(staging, { recursive: true, force: true }); + } +} + +function assertOutputDoesNotOverlapSource( + sourceWorldDirectory: string, + outputDirectory: string | undefined, +): void { + if (!outputDirectory) return; + const source = path.resolve(sourceWorldDirectory); + const output = path.resolve(outputDirectory); + const sourceToOutput = path.relative(source, output); + const outputToSource = path.relative(output, source); + if ( + source === output || + (!sourceToOutput.startsWith("..") && + !path.isAbsolute(sourceToOutput)) || + (!outputToSource.startsWith("..") && !path.isAbsolute(outputToSource)) + ) { + throw new Error( + "Processed-world output must not overlap the authored source world.", + ); + } +} + +async function fileEquals(target: string, contents: string): Promise { + try { + return (await readFile(target, "utf8")) === contents; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function exists(target: string): Promise { + return !!(await stat(target).catch(() => undefined)); +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException( + "Processed world build was aborted.", + "AbortError", + ); + } +} diff --git a/packages/blr/src/world-processing/provider-loader.ts b/packages/blr/src/world-processing/provider-loader.ts new file mode 100644 index 0000000..a432284 --- /dev/null +++ b/packages/blr/src/world-processing/provider-loader.ts @@ -0,0 +1,201 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; +import type { + WorldProcessor, + WorldProcessorDeclaredLogicalInput, +} from "../world-processing.js"; + +export type LoadedWorldProcessorInput = { + readonly relativePath: string; + readonly contentHash: string; +}; + +export type LoadedWorldProcessorProvider = { + readonly processor: WorldProcessor; + readonly bundleHash: string; + readonly providerContentHash: string; + readonly localInputs: readonly LoadedWorldProcessorInput[]; +}; + +export type LoadWorldProcessorProviderOptions = { + readonly projectRoot: string; + readonly module: string; + readonly exportName: string; +}; + +export async function loadWorldProcessorProvider( + options: LoadWorldProcessorProviderOptions, +): Promise { + const entryPath = resolveProviderModule(options); + const bundle = await build({ + entryPoints: [entryPath], + absWorkingDir: options.projectRoot, + bundle: true, + platform: "node", + format: "esm", + target: "node22", + write: false, + metafile: true, + logLevel: "silent", + sourcemap: false, + }); + const output = bundle.outputFiles?.[0]; + if (!output) { + throw new Error( + `World processor ${options.module} produced no bundle.`, + ); + } + const bundleBytes = Buffer.from(output.contents); + const bundleHash = sha256(bundleBytes); + const localInputs = await collectLocalInputs( + options.projectRoot, + bundle.metafile?.inputs ?? {}, + ); + const providerContentHash = sha256( + Buffer.from(JSON.stringify({ bundleHash, localInputs })), + ); + + const tempFile = path.join( + os.tmpdir(), + `blr-world-processor-${bundleHash}-${randomUUID()}.mjs`, + ); + try { + await writeFile(tempFile, bundleBytes, { flag: "wx" }); + const moduleValue = (await import( + `${pathToFileURL(tempFile).href}?run=${randomUUID()}` + )) as Record; + const factory = moduleValue[options.exportName]; + if (factory === undefined) { + throw new Error( + `World processor module ${options.module} does not export ${options.exportName}.`, + ); + } + if (typeof factory !== "function") { + throw new Error( + `World processor export ${options.exportName} must be a function.`, + ); + } + const processor = (factory as () => unknown)(); + assertProcessor(processor, options); + return Object.freeze({ + processor, + bundleHash, + providerContentHash, + localInputs: Object.freeze(localInputs), + }); + } finally { + await rm(tempFile, { force: true }); + } +} + +function resolveProviderModule( + options: LoadWorldProcessorProviderOptions, +): string { + if (options.module.startsWith("./")) { + return path.resolve(options.projectRoot, options.module.slice(2)); + } + const require = createRequire( + path.join(options.projectRoot, "package.json"), + ); + return require.resolve(options.module); +} + +async function collectLocalInputs( + projectRoot: string, + inputs: Record, +): Promise { + const result: LoadedWorldProcessorInput[] = []; + for (const inputPath of Object.keys(inputs).sort()) { + if (inputPath.startsWith("<")) continue; + const absolutePath = path.isAbsolute(inputPath) + ? inputPath + : path.resolve(projectRoot, inputPath); + const bytes = await readFile(absolutePath); + result.push( + Object.freeze({ + relativePath: path + .relative(projectRoot, absolutePath) + .replace(/\\/g, "/"), + contentHash: sha256(bytes), + }), + ); + } + return result; +} + +function assertProcessor( + input: unknown, + options: LoadWorldProcessorProviderOptions, +): asserts input is WorldProcessor { + if (!input || typeof input !== "object") { + throw new Error( + `World processor factory ${options.exportName} must return an object.`, + ); + } + const record = input as Record; + if ( + typeof record.implementationRevision !== "string" || + record.implementationRevision.trim().length === 0 + ) { + throw new Error( + `World processor implementationRevision must be a non-empty string.`, + ); + } + if (!Array.isArray(record.logicalInputs)) { + throw new Error(`World processor logicalInputs must be an array.`); + } + if (typeof record.run !== "function") { + throw new Error(`World processor run must be a function.`); + } + validateDeclaredInputs(record.logicalInputs); +} + +function validateDeclaredInputs( + inputs: readonly WorldProcessorDeclaredLogicalInput[], +): void { + const ids = new Set(); + for (const [index, input] of inputs.entries()) { + if (!input || typeof input !== "object") { + throw new Error( + `World processor logicalInputs[${index}] is invalid.`, + ); + } + if (typeof input.id !== "string" || input.id.length === 0) { + throw new Error( + `World processor logicalInputs[${index}].id must be non-empty.`, + ); + } + if (ids.has(input.id)) { + throw new Error( + `Duplicate world processor logical input ${input.id}.`, + ); + } + ids.add(input.id); + if (input.kind === "value") { + if (typeof input.value !== "string") { + throw new Error( + `Logical value input ${input.id} must be a string.`, + ); + } + } else if (input.kind === "file") { + if (typeof input.path !== "string" || input.path.length === 0) { + throw new Error( + `Logical file input ${input.id} must name a path.`, + ); + } + } else { + throw new Error( + `World processor logicalInputs[${index}] has an unsupported kind.`, + ); + } + } +} + +function sha256(input: Uint8Array): string { + return createHash("sha256").update(input).digest("hex"); +} diff --git a/packages/blr/src/world-processing/runner.ts b/packages/blr/src/world-processing/runner.ts new file mode 100644 index 0000000..ca763d4 --- /dev/null +++ b/packages/blr/src/world-processing/runner.ts @@ -0,0 +1,393 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { + ResolvedWorldProcessorConfig, + WorldObservationFacade, + WorldProcessorInputFile, + WorldProcessorMaterializedLogicalInput, + WorldProcessorMode, + WorldProcessorPipelineIntent, + WorldProcessorResult, + WorldSourceIdentity, +} from "../world-processing.js"; +import { + canonicalJson, + hashCanonicalJson, + normalizeJson, +} from "./canonical-json.js"; +import { loadWorldProcessorProvider } from "./provider-loader.js"; + +const WORLD_PROCESSOR_SCHEMA_REVISION = "world-processing-runner-v1"; + +export type RunWorldProcessorGraphOptions = { + readonly projectRoot: string; + readonly configs: readonly ResolvedWorldProcessorConfig[]; + readonly sourceIdentity: WorldSourceIdentity; + readonly observations: WorldObservationFacade; + readonly pipeline: WorldProcessorPipelineIntent; + readonly mode: WorldProcessorMode; + readonly signal: AbortSignal; + readonly cacheRoot: string; +}; + +export type WorldProcessorExecutionResult = { + readonly processorId: string; + readonly providerRevision: string; + readonly logicalInputHash: string; + readonly cacheHit: boolean; + readonly result: WorldProcessorResult; +}; + +export async function runWorldProcessorGraph( + options: RunWorldProcessorGraphOptions, +): Promise { + throwIfAborted(options.signal); + const ordered = orderProcessorConfigs(options.configs); + const completed = new Map(); + const results: WorldProcessorExecutionResult[] = []; + + for (const config of ordered) { + throwIfAborted(options.signal); + if (!appliesTo(config, options.pipeline)) continue; + if (config.sourceWorld !== options.sourceIdentity.worldName) { + throw new Error( + `World processor ${config.id} selects ${config.sourceWorld}, but the graph snapshot is ${options.sourceIdentity.worldName}.`, + ); + } + const loaded = await loadWorldProcessorProvider({ + projectRoot: options.projectRoot, + module: config.module, + exportName: config.export, + }); + const dependencies = Object.freeze( + Object.fromEntries( + [...config.dependsOn] + .sort() + .map((id) => [id, completed.get(id)?.result]), + ), + ) as Readonly>; + for (const dependency of config.dependsOn) { + if (!dependencies[dependency]) { + throw new Error( + `World processor ${config.id} dependency ${dependency} did not execute for ${options.pipeline}.`, + ); + } + } + const prepared = await prepareLogicalInputs({ + projectRoot: options.projectRoot, + config, + sourceIdentity: options.sourceIdentity, + bundleHash: loaded.providerContentHash, + implementationRevision: loaded.processor.implementationRevision, + declarations: loaded.processor.logicalInputs, + dependencies, + }); + const logicalInputHash = hashCanonicalJson(prepared.logicalInputs); + const cachePath = path.join( + options.cacheRoot, + "world-processing", + config.id, + `${logicalInputHash}.json`, + ); + const cached = await readCache(cachePath, logicalInputHash); + let result: WorldProcessorResult; + let cacheHit = false; + if (cached) { + result = cached; + cacheHit = true; + } else { + throwIfAborted(options.signal); + const rawResult = await loaded.processor.run({ + config, + sourceIdentity: options.sourceIdentity, + observations: options.observations, + inputFiles: prepared.inputFiles, + logicalInputs: prepared.logicalInputs, + dependencies, + signal: options.signal, + }); + result = normalizeProcessorResult(rawResult, config.id); + if ( + canonicalJson(result.logicalInputs) !== + canonicalJson(prepared.logicalInputs) + ) { + throw new Error( + `World processor ${config.id} result logicalInputs must exactly echo the materialized input list.`, + ); + } + if (options.mode === "bake") { + await writeCache(cachePath, logicalInputHash, result); + } + } + const execution = Object.freeze({ + processorId: config.id, + providerRevision: loaded.processor.implementationRevision, + logicalInputHash, + cacheHit, + result, + }); + completed.set(config.id, execution); + results.push(execution); + } + return Object.freeze(results); +} + +type PreparedInputs = { + readonly logicalInputs: readonly WorldProcessorMaterializedLogicalInput[]; + readonly inputFiles: readonly WorldProcessorInputFile[]; +}; + +async function prepareLogicalInputs(input: { + readonly projectRoot: string; + readonly config: ResolvedWorldProcessorConfig; + readonly sourceIdentity: WorldSourceIdentity; + readonly bundleHash: string; + readonly implementationRevision: string; + readonly declarations: readonly import("../world-processing.js").WorldProcessorDeclaredLogicalInput[]; + readonly dependencies: Readonly>; +}): Promise { + const logicalInputs: WorldProcessorMaterializedLogicalInput[] = [ + { + id: "blr:world", + kind: "world", + contentHash: input.sourceIdentity.contentHash, + }, + { + id: "blr:provider", + kind: "provider", + contentHash: input.bundleHash, + }, + { + id: "blr:provider-revision", + kind: "provider", + contentHash: hashCanonicalJson(input.implementationRevision), + }, + { + id: "blr:config", + kind: "value", + contentHash: hashCanonicalJson(input.config), + }, + { + id: "blr:schema", + kind: "schema", + contentHash: hashCanonicalJson(WORLD_PROCESSOR_SCHEMA_REVISION), + }, + ]; + for (const dependency of Object.keys(input.dependencies).sort()) { + logicalInputs.push({ + id: `blr:dependency:${dependency}`, + kind: "value", + contentHash: hashCanonicalJson(input.dependencies[dependency]), + }); + } + + const configuredPaths = new Set(input.config.inputPaths); + const declaredPaths = new Set(); + const inputFiles: WorldProcessorInputFile[] = []; + for (const declaration of input.declarations) { + if (declaration.id.startsWith("blr:")) { + throw new Error( + `World processor ${input.config.id} logical input ${declaration.id} uses the reserved blr: prefix.`, + ); + } + if (declaration.kind === "value") { + logicalInputs.push({ + id: declaration.id, + kind: "value", + contentHash: hashCanonicalJson(declaration.value), + }); + continue; + } + if (!configuredPaths.has(declaration.path)) { + throw new Error( + `World processor ${input.config.id} declares file ${declaration.path}, but it is not listed in inputPaths.`, + ); + } + if (declaredPaths.has(declaration.path)) { + throw new Error( + `World processor ${input.config.id} declares file ${declaration.path} more than once.`, + ); + } + declaredPaths.add(declaration.path); + const absolutePath = path.resolve(input.projectRoot, declaration.path); + const bytes = await readFile(absolutePath); + const contentHash = sha256(bytes); + logicalInputs.push({ + id: declaration.id, + kind: "file", + contentHash, + }); + inputFiles.push( + Object.freeze({ + id: declaration.id, + relativePath: declaration.path, + contentHash, + size: bytes.byteLength, + read: async () => new Uint8Array(bytes), + }), + ); + } + for (const configuredPath of configuredPaths) { + if (!declaredPaths.has(configuredPath)) { + throw new Error( + `World processor ${input.config.id} inputPaths includes ${configuredPath}, but the provider does not declare it as a logical input.`, + ); + } + } + logicalInputs.sort((left, right) => left.id.localeCompare(right.id)); + return Object.freeze({ + logicalInputs: Object.freeze( + logicalInputs.map((entry) => Object.freeze(entry)), + ), + inputFiles: Object.freeze(inputFiles), + }); +} + +function normalizeProcessorResult( + input: unknown, + processorId: string, +): WorldProcessorResult { + const normalized = normalizeJson( + input, + `world processor ${processorId} result`, + ); + if ( + !normalized || + Array.isArray(normalized) || + typeof normalized !== "object" + ) { + throw new Error( + `World processor ${processorId} result must be an object.`, + ); + } + const result = normalized as Record; + for (const field of [ + "logicalInputs", + "artifacts", + "diagnostics", + "mutations", + ]) { + if (!Array.isArray(result[field])) { + throw new Error( + `World processor ${processorId} result.${field} must be an array.`, + ); + } + } + return normalized as unknown as WorldProcessorResult; +} + +async function readCache( + cachePath: string, + logicalInputHash: string, +): Promise { + let raw: string; + try { + raw = await readFile(cachePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return undefined; + throw error; + } + try { + const parsed = JSON.parse(raw) as Record; + if ( + parsed.schemaVersion !== 1 || + parsed.logicalInputHash !== logicalInputHash || + typeof parsed.resultHash !== "string" + ) { + return undefined; + } + const result = normalizeProcessorResult(parsed.result, "cached"); + if (hashCanonicalJson(result) !== parsed.resultHash) return undefined; + return result; + } catch { + return undefined; + } +} + +async function writeCache( + cachePath: string, + logicalInputHash: string, + result: WorldProcessorResult, +): Promise { + const directory = path.dirname(cachePath); + await mkdir(directory, { recursive: true }); + const temporaryPath = path.join( + directory, + `.${path.basename(cachePath)}.${randomUUID()}.tmp`, + ); + const contents = canonicalJson({ + schemaVersion: 1, + logicalInputHash, + resultHash: hashCanonicalJson(result), + result, + }); + try { + await writeFile(temporaryPath, contents, { flag: "wx" }); + await rename(temporaryPath, cachePath); + } finally { + await rm(temporaryPath, { force: true }); + } +} + +function orderProcessorConfigs( + configs: readonly ResolvedWorldProcessorConfig[], +): readonly ResolvedWorldProcessorConfig[] { + const byId = new Map(configs.map((config) => [config.id, config])); + const visiting = new Set(); + const visited = new Set(); + const result: ResolvedWorldProcessorConfig[] = []; + const visit = (id: string): void => { + if (visited.has(id)) return; + if (visiting.has(id)) { + throw new Error(`World processor dependency cycle includes ${id}.`); + } + const config = byId.get(id); + if (!config) + throw new Error(`Unknown world processor dependency ${id}.`); + visiting.add(id); + for (const dependency of [...config.dependsOn].sort()) + visit(dependency); + visiting.delete(id); + visited.add(id); + result.push(config); + }; + for (const id of [...byId.keys()].sort()) visit(id); + return Object.freeze(result); +} + +function appliesTo( + config: ResolvedWorldProcessorConfig, + pipeline: WorldProcessorPipelineIntent, +): boolean { + switch (pipeline) { + case "dev": + return config.applyOn.dev; + case "build": + return config.applyOn.build; + case "package": + return config.applyOn.package; + case "check": + return config.applyOn.check; + case "world-build": + return config.applyOn.worldBuild; + case "world-push": + return config.applyOn.worldPush; + } +} + +function sha256(input: Uint8Array): string { + return createHash("sha256").update(input).digest("hex"); +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException( + "World processor run was aborted.", + "AbortError", + ); + } +} diff --git a/packages/blr/src/world-processing/world-snapshot.ts b/packages/blr/src/world-processing/world-snapshot.ts new file mode 100644 index 0000000..bf5f1b3 --- /dev/null +++ b/packages/blr/src/world-processing/world-snapshot.ts @@ -0,0 +1,273 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { + copyFile, + lstat, + mkdir, + mkdtemp, + readdir, + rm, + stat, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { WorldSourceIdentity } from "../world-processing.js"; + +export type WorldSourceInventoryEntry = { + readonly relativePath: string; + readonly size: number; + readonly mtimeMs: number; + readonly contentHash: string; +}; + +export type VerifiedWorldSnapshot = { + readonly worldDirectory: string; + readonly dbPath: string; + readonly sourceIdentity: WorldSourceIdentity; + readonly inventory: readonly WorldSourceInventoryEntry[]; +}; + +export type VerifiedWorldSnapshotHooks = { + /** Test/debug seam run after the copy and before source re-inventory. */ + readonly afterCopy?: () => void | Promise; +}; + +export type VerifiedWorldSnapshotOptions = { + readonly worldName: string; + readonly sourceWorldDirectory: string; + readonly sourceRevision?: string; + readonly sourceVersion?: string; + readonly tempRoot?: string; + readonly signal?: AbortSignal; + readonly hooks?: VerifiedWorldSnapshotHooks; +}; + +export async function withVerifiedWorldSnapshot( + options: VerifiedWorldSnapshotOptions, + operation: (snapshot: VerifiedWorldSnapshot) => Promise, +): Promise { + const sourceWorldDirectory = path.resolve(options.sourceWorldDirectory); + const dbStat = await stat(path.join(sourceWorldDirectory, "db")).catch( + () => undefined, + ); + if (!dbStat?.isDirectory()) { + throw new Error( + `Cannot snapshot ${options.worldName} because its db directory is missing.`, + ); + } + + const tempRoot = path.resolve( + options.tempRoot ?? + path.join(os.tmpdir(), "blurengine-world-snapshots"), + ); + await mkdir(tempRoot, { recursive: true }); + throwIfAborted(options.signal); + const before = await inventoryWorld(sourceWorldDirectory, options.signal); + throwIfAborted(options.signal); + + const runDirectory = await mkdtemp(path.join(tempRoot, "snapshot-")); + const snapshotWorldDirectory = path.join(runDirectory, "world"); + try { + await copyVerifiedDirectory( + sourceWorldDirectory, + snapshotWorldDirectory, + options.signal, + ); + await options.hooks?.afterCopy?.(); + throwIfAborted(options.signal); + + const [snapshotInventory, after] = await Promise.all([ + inventoryWorld(snapshotWorldDirectory, options.signal), + inventoryWorld(sourceWorldDirectory, options.signal), + ]); + if (!inventoriesEqual(before, after, true)) { + throw new Error( + `World source ${options.worldName} changed while the verified snapshot was being created.`, + ); + } + if (!inventoriesEqual(before, snapshotInventory, false)) { + throw new Error( + `Verified snapshot for ${options.worldName} does not match its source inventory.`, + ); + } + + const inventory = Object.freeze( + snapshotInventory.map((entry) => Object.freeze(entry)), + ); + const snapshot = Object.freeze({ + worldDirectory: snapshotWorldDirectory, + dbPath: path.join(snapshotWorldDirectory, "db"), + sourceIdentity: Object.freeze({ + worldName: options.worldName, + contentHash: hashLogicalInventory(snapshotInventory), + ...(options.sourceRevision + ? { sourceRevision: options.sourceRevision } + : {}), + ...(options.sourceVersion + ? { sourceVersion: options.sourceVersion } + : {}), + }), + inventory, + }); + return await operation(snapshot); + } finally { + await removeSnapshotDirectory(runDirectory, tempRoot); + } +} + +async function inventoryWorld( + root: string, + signal?: AbortSignal, +): Promise { + const entries: WorldSourceInventoryEntry[] = []; + await visit(""); + return entries.sort((left, right) => + left.relativePath.localeCompare(right.relativePath), + ); + + async function visit(relativeDirectory: string): Promise { + throwIfAborted(signal); + const absoluteDirectory = path.join(root, relativeDirectory); + const children = await readdir(absoluteDirectory, { + withFileTypes: true, + }); + children.sort((left, right) => left.name.localeCompare(right.name)); + for (const child of children) { + throwIfAborted(signal); + const relativePath = path + .join(relativeDirectory, child.name) + .replace(/\\/g, "/"); + const absolutePath = path.join(root, relativePath); + const details = await lstat(absolutePath); + if (details.isSymbolicLink()) { + throw new Error( + `World source contains symbolic link ${relativePath}; verified snapshots do not follow links.`, + ); + } + if (details.isDirectory()) { + await visit(relativePath); + } else if (details.isFile()) { + entries.push({ + relativePath, + size: details.size, + mtimeMs: details.mtimeMs, + contentHash: await hashFile(absolutePath, signal), + }); + } else { + throw new Error( + `World source contains unsupported filesystem entry ${relativePath}.`, + ); + } + } + } +} + +async function copyVerifiedDirectory( + sourceRoot: string, + destinationRoot: string, + signal?: AbortSignal, +): Promise { + await mkdir(destinationRoot, { recursive: true }); + await copyChildren(""); + + async function copyChildren(relativeDirectory: string): Promise { + throwIfAborted(signal); + const children = await readdir( + path.join(sourceRoot, relativeDirectory), + { + withFileTypes: true, + }, + ); + children.sort((left, right) => left.name.localeCompare(right.name)); + for (const child of children) { + throwIfAborted(signal); + const relativePath = path.join(relativeDirectory, child.name); + const sourcePath = path.join(sourceRoot, relativePath); + const destinationPath = path.join(destinationRoot, relativePath); + const details = await lstat(sourcePath); + if (details.isSymbolicLink()) { + throw new Error( + `World source contains symbolic link ${relativePath.replace(/\\/g, "/")}; verified snapshots do not follow links.`, + ); + } + if (details.isDirectory()) { + await mkdir(destinationPath, { recursive: true }); + await copyChildren(relativePath); + } else if (details.isFile()) { + await copyFile(sourcePath, destinationPath); + } else { + throw new Error( + `World source contains unsupported filesystem entry ${relativePath.replace(/\\/g, "/")}.`, + ); + } + } + } +} + +async function hashFile( + filePath: string, + signal?: AbortSignal, +): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(filePath, { signal })) { + hash.update(chunk); + } + return hash.digest("hex"); +} + +function inventoriesEqual( + left: readonly WorldSourceInventoryEntry[], + right: readonly WorldSourceInventoryEntry[], + includeMtime: boolean, +): boolean { + return ( + left.length === right.length && + left.every((entry, index) => { + const candidate = right[index]; + return ( + candidate?.relativePath === entry.relativePath && + candidate.size === entry.size && + candidate.contentHash === entry.contentHash && + (!includeMtime || candidate.mtimeMs === entry.mtimeMs) + ); + }) + ); +} + +function hashLogicalInventory( + inventory: readonly WorldSourceInventoryEntry[], +): string { + const hash = createHash("sha256"); + for (const entry of inventory) { + hash.update(entry.relativePath); + hash.update("\0"); + hash.update(String(entry.size)); + hash.update("\0"); + hash.update(entry.contentHash); + hash.update("\n"); + } + return hash.digest("hex"); +} + +async function removeSnapshotDirectory( + runDirectory: string, + tempRoot: string, +): Promise { + const relative = path.relative(tempRoot, runDirectory); + if ( + relative.length === 0 || + relative.startsWith("..") || + path.isAbsolute(relative) + ) { + throw new Error("Refusing to remove a snapshot outside its temp root."); + } + await rm(runDirectory, { recursive: true, force: true }); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException("World snapshot was aborted.", "AbortError"); + } +} diff --git a/packages/blr/src/world-processing/writer.ts b/packages/blr/src/world-processing/writer.ts new file mode 100644 index 0000000..0f1667b --- /dev/null +++ b/packages/blr/src/world-processing/writer.ts @@ -0,0 +1,895 @@ +import { createHash } from "node:crypto"; +import { LevelDB } from "@8crafter/leveldb-zlib"; +import NBT from "prismarine-nbt"; +import { + loadMcbeLeveldbHelpers, + type McbeLeveldbHelpers, +} from "../mcbe-leveldb-adapter.js"; +import type { + WorldBlockEntityPolicy, + WorldBlockLocation, + WorldBlockPaletteEntry, + WorldDimensionId, +} from "../world-processing.js"; +import { canonicalJson } from "./canonical-json.js"; +import { + decodeBedrockSubchunk, + type DecodedBedrockSubchunk, + type DecodedBedrockSubchunkLayer, +} from "./bedrock-subchunk.js"; +import type { + PrimitiveWorldBlockWrite, + PrimitiveWorldMutation, +} from "./mutations.js"; +import { withBedrockWorldObservations } from "./observation-facade.js"; + +const DIMENSION_HEIGHTS: Readonly< + Record +> = Object.freeze({ + overworld: { minY: -64, maxY: 319 }, + nether: { minY: 0, maxY: 127 }, + the_end: { minY: 0, maxY: 255 }, +}); +const SUPPORTED_BITS_PER_BLOCK = [1, 2, 3, 4, 5, 6, 8] as const; + +type RawOperation = + | { readonly type: "put"; readonly key: Buffer; readonly value: Buffer } + | { readonly type: "del"; readonly key: Buffer }; + +export type BedrockWorldWriterHooks = { + readonly beforeBatch?: () => void | Promise; + readonly afterBatchBeforeVerify?: () => void | Promise; +}; + +export type ApplyBedrockWorldMutationsOptions = { + /** A disposable staged-world DB. The authored source must never be passed. */ + readonly dbPath: string; + readonly mutations: readonly PrimitiveWorldMutation[]; + readonly hooks?: BedrockWorldWriterHooks; +}; + +export type AppliedBedrockWorldMutations = { + readonly verified: true; + readonly mutatedKeyCount: number; +}; + +type MutableLayer = { + storageHeader: number; + bitsPerBlock: number; + palette: WorldBlockPaletteEntry[]; + paletteEntryBytes: Buffer[]; + indices: Uint16Array; + rawBytes: Buffer; + modified: boolean; +}; + +type MutableSubchunk = { + readonly key: Buffer; + readonly dimension: WorldDimensionId; + readonly chunkX: number; + readonly chunkZ: number; + readonly subChunkY: number; + readonly decoded: DecodedBedrockSubchunk; + readonly layers: MutableLayer[]; +}; + +type ChunkCoordinate = { + readonly dimension: WorldDimensionId; + readonly chunkX: number; + readonly chunkZ: number; +}; + +type BlockEntityCompound = Record; + +/** + * Applies a fully normalised mutation plan to a disposable Bedrock LevelDB. + * All preconditions are checked before one synchronous raw-key batch. The DB + * is then closed, reopened, and independently verified before it can be used. + */ +export async function applyBedrockWorldMutations( + options: ApplyBedrockWorldMutationsOptions, +): Promise { + const helpers = await loadMcbeLeveldbHelpers(); + const db = new LevelDB(options.dbPath, { + createIfMissing: false, + bufferKeys: true, + }); + const operations = new Map(); + const modifiedSubchunks = new Map(); + const changedLayerZeroColumns = new Map>(); + await db.open(); + try { + await planSubchunkWrites( + db, + helpers, + options.mutations, + operations, + modifiedSubchunks, + changedLayerZeroColumns, + ); + await planBlockEntityWrites(db, helpers, options.mutations, operations); + await planHeightmapWrites( + db, + helpers, + changedLayerZeroColumns, + modifiedSubchunks, + operations, + ); + + const touchedKeys = new Set(operations.keys()); + const untouchedBefore = await digestUntouchedEntries(db, touchedKeys); + await options.hooks?.beforeBatch?.(); + if (operations.size > 0) { + await db.batch([...operations.values()], { sync: true }); + } + await db.close(); + await options.hooks?.afterBatchBeforeVerify?.(); + await verifyAppliedMutations({ + dbPath: options.dbPath, + mutations: options.mutations, + operations, + untouchedBefore, + modifiedSubchunks, + changedLayerZeroColumns, + }); + return Object.freeze({ + verified: true, + mutatedKeyCount: operations.size, + }); + } finally { + if (db.isOpen()) await db.close(); + } +} + +async function planSubchunkWrites( + db: LevelDB, + helpers: McbeLeveldbHelpers, + mutations: readonly PrimitiveWorldMutation[], + operations: Map, + subchunks: Map, + changedLayerZeroColumns: Map>, +): Promise { + const writes = mutations.filter( + (mutation): mutation is PrimitiveWorldBlockWrite => + mutation.kind === "block-write", + ); + const bySubchunk = new Map(); + for (const write of writes) { + const coordinates = blockCoordinates(write.location); + const key = subchunkMapKey( + write.dimension, + coordinates.chunkX, + coordinates.chunkZ, + coordinates.subChunkY, + ); + bySubchunk.set(key, [...(bySubchunk.get(key) ?? []), write]); + } + + for (const [mapKey, group] of [...bySubchunk].sort(([left], [right]) => + left.localeCompare(right), + )) { + const first = group[0]!; + const coordinates = blockCoordinates(first.location); + const rawKey = helpers.generateChunkKeyFromIndices( + { + x: coordinates.chunkX, + z: coordinates.chunkZ, + dimension: first.dimension, + subChunkIndex: coordinates.subChunkY, + }, + "SubChunkPrefix", + ); + const raw = await db.get(rawKey); + if (!raw) { + throw new Error( + `World mutation requires an existing subchunk at ${formatLocation(first.location)}.`, + ); + } + const decoded = await decodeBedrockSubchunk(raw); + if (decoded.subChunkY !== coordinates.subChunkY) { + throw new Error( + `Subchunk key Y ${coordinates.subChunkY} does not match embedded Y ${decoded.subChunkY}.`, + ); + } + const mutable: MutableSubchunk = { + key: rawKey, + dimension: first.dimension, + chunkX: coordinates.chunkX, + chunkZ: coordinates.chunkZ, + subChunkY: coordinates.subChunkY, + decoded, + layers: decoded.layers.map(mutableLayer), + }; + + const missingByLayer = new Map< + number, + Map + >(); + for (const write of group) { + const layer = mutable.layers[write.layer]; + if (!layer) { + throw new Error( + `World mutation requires existing storage layer ${write.layer} at ${formatLocation(write.location)}.`, + ); + } + const blockIndex = blockCoordinates(write.location).blockIndex; + const current = layer.palette[layer.indices[blockIndex] ?? 0]; + if (!current || !palettesEqual(current, write.expectedPalette)) { + throw new Error( + `World mutation expected palette ${write.expectedPalette.typeId} at ${formatLocation(write.location)}, but the existing palette does not match.`, + ); + } + if (paletteIndex(layer.palette, write.replacementPalette) < 0) { + const missing = missingByLayer.get(write.layer) ?? new Map(); + missing.set( + canonicalJson(write.replacementPalette), + write.replacementPalette, + ); + missingByLayer.set(write.layer, missing); + } + } + + for (const [layerIndex, missing] of missingByLayer) { + const layer = mutable.layers[layerIndex]!; + for (const [canonical, palette] of [...missing].sort(([a], [b]) => + a.localeCompare(b), + )) { + if (paletteIndex(layer.palette, palette) >= 0) continue; + layer.palette.push(freezePalette(palette)); + layer.paletteEntryBytes.push(writePaletteEntry(palette)); + void canonical; + } + const requiredBits = requiredBitsPerBlock(layer.palette.length); + if (requiredBits > layer.bitsPerBlock) { + layer.bitsPerBlock = requiredBits; + layer.storageHeader = requiredBits << 1; + } + } + + for (const write of group) { + const layer = mutable.layers[write.layer]!; + const coordinatesForWrite = blockCoordinates(write.location); + const replacementIndex = paletteIndex( + layer.palette, + write.replacementPalette, + ); + if (replacementIndex < 0) { + throw new Error( + "Internal error resolving replacement palette.", + ); + } + layer.indices[coordinatesForWrite.blockIndex] = replacementIndex; + layer.modified = true; + if (write.layer === 0) { + const chunkKey = chunkMapKey( + write.dimension, + coordinatesForWrite.chunkX, + coordinatesForWrite.chunkZ, + ); + const columns = + changedLayerZeroColumns.get(chunkKey) ?? new Set(); + columns.add( + (coordinatesForWrite.localX << 4) | + coordinatesForWrite.localZ, + ); + changedLayerZeroColumns.set(chunkKey, columns); + } + } + subchunks.set(mapKey, mutable); + setOperation(operations, { + type: "put", + key: rawKey, + value: serializeSubchunk(mutable), + }); + } +} + +async function planBlockEntityWrites( + db: LevelDB, + helpers: McbeLeveldbHelpers, + mutations: readonly PrimitiveWorldMutation[], + operations: Map, +): Promise { + const byChunk = new Map(); + for (const mutation of mutations) { + const coordinates = blockCoordinates(mutation.location); + const key = chunkMapKey( + mutation.dimension, + coordinates.chunkX, + coordinates.chunkZ, + ); + byChunk.set(key, [...(byChunk.get(key) ?? []), mutation]); + } + + for (const [key, chunkMutations] of byChunk) { + const chunk = parseChunkMapKey(key); + const rawKey = helpers.generateChunkKeyFromIndices( + chunk, + "BlockEntity", + ); + const raw = await db.get(rawKey); + const parsed = raw + ? await helpers.entryContentTypeToFormatMap.BlockEntity.parse(raw) + : undefined; + const entities = parsed + ? [...parsed.value.blockEntities.value.value] + : []; + const entityIndex = indexBlockEntities(entities, chunk); + const removals = new Set(); + + for (const mutation of chunkMutations) { + const locationKey = locationMapKey(mutation.location); + const existingIndex = entityIndex.get(locationKey); + const existing = + existingIndex === undefined + ? undefined + : entities[existingIndex]; + if (mutation.kind === "block-entity-remove") { + if ( + !existing || + blockEntityId(existing) !== mutation.expectedId + ) { + throw new Error( + `Expected block entity ${mutation.expectedId} at ${formatLocation(mutation.location)}, found ${existing ? blockEntityId(existing) : "none"}.`, + ); + } + removals.add(existingIndex!); + continue; + } + validateBlockEntityPolicy( + mutation, + existing, + existingIndex, + removals, + ); + } + + if (removals.size === 0) continue; + const retained = entities.filter( + (_entity, index) => !removals.has(index), + ); + if (!parsed) { + throw new Error("Internal block-entity removal without a record."); + } + if (retained.length === 0) { + setOperation(operations, { type: "del", key: rawKey }); + } else { + const updated = { + ...parsed, + value: { + ...parsed.value, + blockEntities: { + ...parsed.value.blockEntities, + value: { + ...parsed.value.blockEntities.value, + value: retained, + }, + }, + }, + }; + setOperation(operations, { + type: "put", + key: rawKey, + value: helpers.entryContentTypeToFormatMap.BlockEntity.serialize( + updated, + ), + }); + } + } +} + +function validateBlockEntityPolicy( + mutation: PrimitiveWorldBlockWrite, + existing: BlockEntityCompound | undefined, + existingIndex: number | undefined, + removals: Set, +): void { + const policy: WorldBlockEntityPolicy = mutation.blockEntityPolicy; + if (policy === "require-absent") { + if (existing) { + throw new Error( + `World mutation requires no block entity at ${formatLocation(mutation.location)}, found ${blockEntityId(existing)}.`, + ); + } + return; + } + if (policy === "remove") { + if (existingIndex !== undefined) removals.add(existingIndex); + return; + } + if ( + existing && + mutation.expectedPalette.typeId !== mutation.replacementPalette.typeId + ) { + throw new Error( + `Cannot preserve block entity ${blockEntityId(existing)} while changing ${mutation.expectedPalette.typeId} to ${mutation.replacementPalette.typeId} at ${formatLocation(mutation.location)}.`, + ); + } +} + +async function planHeightmapWrites( + db: LevelDB, + helpers: McbeLeveldbHelpers, + changedColumns: Map>, + modifiedSubchunks: Map, + operations: Map, +): Promise { + const readCache = new Map(); + for (const [key, columns] of changedColumns) { + const chunk = parseChunkMapKey(key); + const dataKey = helpers.generateChunkKeyFromIndices(chunk, "Data3D"); + const raw = await db.get(dataKey); + if (!raw) { + throw new Error( + `World mutation requires existing Data3D for ${chunk.dimension} chunk ${chunk.x},${chunk.z}.`, + ); + } + const parsed = helpers.readData3dValue(raw); + if (!parsed) { + throw new Error( + `Could not decode Data3D for ${chunk.dimension} chunk ${chunk.x},${chunk.z}.`, + ); + } + const heightMap = parsed.heightMap.map((row) => [...row]); + for (const column of columns) { + const localX = column >> 4; + const localZ = column & 15; + heightMap[localX]![localZ] = await calculateColumnHeight( + db, + helpers, + chunk.dimension, + chunk.x, + chunk.z, + localX, + localZ, + modifiedSubchunks, + readCache, + ); + } + setOperation(operations, { + type: "put", + key: dataKey, + value: helpers.writeData3DValue(heightMap, parsed.biomes), + }); + } +} + +async function calculateColumnHeight( + db: LevelDB, + helpers: McbeLeveldbHelpers, + dimension: WorldDimensionId, + chunkX: number, + chunkZ: number, + localX: number, + localZ: number, + modifiedSubchunks: Map, + readCache: Map, +): Promise { + const range = DIMENSION_HEIGHTS[dimension]; + const minSubchunk = Math.floor(range.minY / 16); + const maxSubchunk = Math.floor(range.maxY / 16); + for ( + let subChunkY = maxSubchunk; + subChunkY >= minSubchunk; + subChunkY -= 1 + ) { + const key = subchunkMapKey(dimension, chunkX, chunkZ, subChunkY); + const modified = modifiedSubchunks.get(key); + let decoded: DecodedBedrockSubchunk | undefined = modified?.decoded; + let layer: MutableLayer | DecodedBedrockSubchunkLayer | undefined = + modified?.layers[0]; + if (!modified) { + if (!readCache.has(key)) { + const raw = await db.get( + helpers.generateChunkKeyFromIndices( + { + x: chunkX, + z: chunkZ, + dimension, + subChunkIndex: subChunkY, + }, + "SubChunkPrefix", + ), + ); + readCache.set( + key, + raw ? await decodeBedrockSubchunk(raw) : undefined, + ); + } + decoded = readCache.get(key); + layer = decoded?.layers[0]; + } + if (!layer) continue; + for (let localY = 15; localY >= 0; localY -= 1) { + const worldY = subChunkY * 16 + localY; + if (worldY > range.maxY || worldY < range.minY) continue; + const blockIndex = (localX << 8) | (localZ << 4) | localY; + const palette = layer.palette[layer.indices[blockIndex] ?? 0]; + if (palette && palette.typeId !== "minecraft:air") + return worldY + 1; + } + } + return range.minY; +} + +async function verifyAppliedMutations(input: { + readonly dbPath: string; + readonly mutations: readonly PrimitiveWorldMutation[]; + readonly operations: ReadonlyMap; + readonly untouchedBefore: EntryDigest; + readonly modifiedSubchunks: ReadonlyMap; + readonly changedLayerZeroColumns: ReadonlyMap>; +}): Promise { + const db = new LevelDB(input.dbPath, { + createIfMissing: false, + bufferKeys: true, + }); + await db.open(); + try { + const untouchedAfter = await digestUntouchedEntries( + db, + new Set(input.operations.keys()), + ); + if ( + untouchedAfter.count !== input.untouchedBefore.count || + untouchedAfter.hash !== input.untouchedBefore.hash + ) { + throw new Error("World mutation changed an untouched LevelDB key."); + } + for (const subchunk of input.modifiedSubchunks.values()) { + const raw = await db.get(subchunk.key); + if (!raw) throw new Error("A written subchunk is missing."); + await decodeBedrockSubchunk(raw); + } + const helpers = await loadMcbeLeveldbHelpers(); + for (const [key, columns] of input.changedLayerZeroColumns) { + const chunk = parseChunkMapKey(key); + const raw = await db.get( + helpers.generateChunkKeyFromIndices(chunk, "Data3D"), + ); + const parsed = raw ? helpers.readData3dValue(raw) : null; + if (!parsed) throw new Error("A written Data3D record is invalid."); + for (const column of columns) { + const x = column >> 4; + const z = column & 15; + if (!Number.isInteger(parsed.heightMap[x]?.[z])) { + throw new Error("A written heightmap value is invalid."); + } + } + } + } finally { + await db.close(); + } + + await withBedrockWorldObservations( + { dbPath: input.dbPath }, + async (facade) => { + for (const mutation of input.mutations) { + let found = false; + for await (const observation of facade.blocks({ + dimension: mutation.dimension, + bounds: { min: mutation.location, max: mutation.location }, + includeAir: true, + })) { + if (mutation.kind === "block-write") { + if (observation.layer !== mutation.layer) continue; + found = true; + if ( + !palettesEqual( + observation.palette, + mutation.replacementPalette, + ) + ) { + throw new Error( + `Verification failed for block at ${formatLocation(mutation.location)}.`, + ); + } + if ( + mutation.blockEntityPolicy === "remove" && + observation.blockEntity + ) { + throw new Error( + `Verification found a removed block entity at ${formatLocation(mutation.location)}.`, + ); + } + } else if (observation.layer === 0) { + found = true; + if (observation.blockEntity) { + throw new Error( + `Verification found block entity ${observation.blockEntity.id} at ${formatLocation(mutation.location)}.`, + ); + } + } + } + if (!found) { + throw new Error( + `Verification could not observe ${formatLocation(mutation.location)}.`, + ); + } + } + }, + ); +} + +function mutableLayer(layer: DecodedBedrockSubchunkLayer): MutableLayer { + return { + storageHeader: layer.storageHeader, + bitsPerBlock: layer.bitsPerBlock, + palette: [...layer.palette], + paletteEntryBytes: layer.paletteEntryBytes.map((entry) => + Buffer.from(entry), + ), + indices: new Uint16Array(layer.indices), + rawBytes: Buffer.from(layer.rawBytes), + modified: false, + }; +} + +function serializeSubchunk(subchunk: MutableSubchunk): Buffer { + const header = Buffer.alloc(3); + header.writeUInt8(9, 0); + header.writeUInt8(subchunk.layers.length, 1); + header.writeInt8(subchunk.subChunkY, 2); + return Buffer.concat([ + header, + ...subchunk.layers.map((layer) => + layer.modified ? serializeLayer(layer) : layer.rawBytes, + ), + ]); +} + +function serializeLayer(layer: MutableLayer): Buffer { + const blocksPerWord = Math.floor(32 / layer.bitsPerBlock); + const wordCount = Math.ceil(4096 / blocksPerWord); + const words = Buffer.alloc(wordCount * 4); + for (let blockIndex = 0; blockIndex < 4096; blockIndex += 1) { + const wordIndex = Math.floor(blockIndex / blocksPerWord); + const shift = (blockIndex % blocksPerWord) * layer.bitsPerBlock; + const current = words.readUInt32LE(wordIndex * 4); + const paletteIndex = layer.indices[blockIndex] ?? 0; + words.writeUInt32LE( + (current | (paletteIndex << shift)) >>> 0, + wordIndex * 4, + ); + } + const paletteSize = Buffer.alloc(4); + paletteSize.writeInt32LE(layer.palette.length, 0); + return Buffer.concat([ + Buffer.from([layer.storageHeader]), + words, + paletteSize, + ...layer.paletteEntryBytes, + ]); +} + +function writePaletteEntry(entry: WorldBlockPaletteEntry): Buffer { + const states = Object.fromEntries( + Object.entries(entry.states) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [ + key, + typeof value === "boolean" + ? { type: "byte", value: value ? 1 : 0 } + : typeof value === "number" + ? { type: "int", value } + : { type: "string", value }, + ]), + ); + return NBT.writeUncompressed( + { + name: "", + type: "compound", + value: { + name: { type: "string", value: entry.typeId }, + states: { type: "compound", value: states }, + version: { type: "int", value: entry.version }, + }, + } as Parameters[0], + "little", + ); +} + +function requiredBitsPerBlock(paletteSize: number): number { + const minimum = Math.max(1, Math.ceil(Math.log2(paletteSize))); + const supported = SUPPORTED_BITS_PER_BLOCK.find((bits) => bits >= minimum); + if (!supported) { + throw new Error( + `World mutation palette size ${paletteSize} exceeds the supported 8-bit storage.`, + ); + } + return supported; +} + +function indexBlockEntities( + entities: readonly BlockEntityCompound[], + chunk: ChunkCoordinate, +): Map { + const result = new Map(); + entities.forEach((entity, index) => { + const location = blockEntityLocation(entity); + const key = locationMapKey(location); + if (result.has(key)) { + throw new Error( + `Chunk ${chunk.dimension} ${chunk.chunkX},${chunk.chunkZ} contains duplicate block entities at ${key}.`, + ); + } + result.set(key, index); + }); + return result; +} + +function blockEntityId(entity: BlockEntityCompound): string { + return nbtPrimitive(entity.id, "block entity id", "string") as string; +} + +function blockEntityLocation(entity: BlockEntityCompound): WorldBlockLocation { + return { + x: nbtPrimitive(entity.x, "block entity x", "number") as number, + y: nbtPrimitive(entity.y, "block entity y", "number") as number, + z: nbtPrimitive(entity.z, "block entity z", "number") as number, + }; +} + +function nbtPrimitive( + input: unknown, + source: string, + expectedType: "string" | "number", +): string | number { + if ( + !input || + typeof input !== "object" || + !("value" in input) || + typeof (input as { value?: unknown }).value !== expectedType + ) { + throw new Error(`${source} is invalid.`); + } + return (input as { value: string | number }).value; +} + +function freezePalette( + palette: WorldBlockPaletteEntry, +): WorldBlockPaletteEntry { + return Object.freeze({ + typeId: palette.typeId, + states: Object.freeze({ ...palette.states }), + version: palette.version, + }); +} + +function paletteIndex( + palette: readonly WorldBlockPaletteEntry[], + target: WorldBlockPaletteEntry, +): number { + return palette.findIndex((candidate) => palettesEqual(candidate, target)); +} + +function palettesEqual( + left: WorldBlockPaletteEntry, + right: WorldBlockPaletteEntry, +): boolean { + return canonicalJson(left) === canonicalJson(right); +} + +type BlockCoordinates = { + readonly chunkX: number; + readonly chunkZ: number; + readonly subChunkY: number; + readonly localX: number; + readonly localZ: number; + readonly blockIndex: number; +}; + +function blockCoordinates(location: WorldBlockLocation): BlockCoordinates { + const chunkX = Math.floor(location.x / 16); + const chunkZ = Math.floor(location.z / 16); + const subChunkY = Math.floor(location.y / 16); + const localX = floorMod(location.x, 16); + const localY = floorMod(location.y, 16); + const localZ = floorMod(location.z, 16); + return { + chunkX, + chunkZ, + subChunkY, + localX, + localZ, + blockIndex: (localX << 8) | (localZ << 4) | localY, + }; +} + +function floorMod(value: number, divisor: number): number { + return ((value % divisor) + divisor) % divisor; +} + +function subchunkMapKey( + dimension: WorldDimensionId, + chunkX: number, + chunkZ: number, + subChunkY: number, +): string { + return `${dimension}:${chunkX}:${chunkZ}:${subChunkY}`; +} + +function chunkMapKey( + dimension: WorldDimensionId, + chunkX: number, + chunkZ: number, +): string { + return `${dimension}:${chunkX}:${chunkZ}`; +} + +function parseChunkMapKey(key: string): { + readonly dimension: WorldDimensionId; + readonly x: number; + readonly z: number; + readonly chunkX: number; + readonly chunkZ: number; +} { + const [dimension, xText, zText] = key.split(":"); + const x = Number(xText); + const z = Number(zText); + if ( + (dimension !== "overworld" && + dimension !== "nether" && + dimension !== "the_end") || + !Number.isInteger(x) || + !Number.isInteger(z) + ) { + throw new Error(`Invalid chunk key ${key}.`); + } + return { dimension, x, z, chunkX: x, chunkZ: z }; +} + +function locationMapKey(location: WorldBlockLocation): string { + return `${location.x}:${location.y}:${location.z}`; +} + +function formatLocation(location: WorldBlockLocation): string { + return `${location.x},${location.y},${location.z}`; +} + +function rawKeyId(key: Buffer | string): string { + return Buffer.isBuffer(key) + ? key.toString("hex") + : Buffer.from(key).toString("hex"); +} + +function setOperation( + operations: Map, + operation: RawOperation, +): void { + const id = rawKeyId(operation.key); + if (operations.has(id)) { + throw new Error(`World writer produced duplicate raw key ${id}.`); + } + operations.set(id, operation); +} + +type EntryDigest = { + readonly count: number; + readonly hash: string; +}; + +async function digestUntouchedEntries( + db: LevelDB, + touchedKeys: ReadonlySet, +): Promise { + const hash = createHash("sha256"); + let count = 0; + const iterator = db.getIterator({ + keys: true, + values: true, + keyAsBuffer: true, + valueAsBuffer: true, + }); + for await (const pair of iterator) { + const key = Buffer.from(pair[0] as Uint8Array); + if (touchedKeys.has(rawKeyId(key))) continue; + const value = Buffer.from(pair[1] as Uint8Array); + const lengths = Buffer.alloc(8); + lengths.writeUInt32LE(key.length, 0); + lengths.writeUInt32LE(value.length, 4); + hash.update(lengths).update(key).update(value); + count += 1; + } + return Object.freeze({ count, hash: hash.digest("hex") }); +} diff --git a/packages/blr/src/world-terrain-bedrock.ts b/packages/blr/src/world-terrain-bedrock.ts index 4cadade..e445358 100644 --- a/packages/blr/src/world-terrain-bedrock.ts +++ b/packages/blr/src/world-terrain-bedrock.ts @@ -1,5 +1,4 @@ import { LevelDB } from "@8crafter/leveldb-zlib"; -import NBT from "prismarine-nbt"; import { loadMcbeLeveldbHelpers, type BiomePalette, @@ -12,6 +11,10 @@ import { type WorldImageDimension, } from "./world-image-dimension.js"; import { readRawBedrockData3dHeightMap } from "./world-image-bedrock.js"; +import { + decodeBedrockSubchunk, + type DecodedBedrockSubchunkLayer, +} from "./world-processing/bedrock-subchunk.js"; export type WorldTerrainColumn = { x: number; @@ -235,171 +238,18 @@ type TerrainChunkAccumulator = { coveredColumns: number; }; -type SubChunkLayer = { - palette?: { - value?: Record< - string, - { - value?: { - name?: { - value?: unknown; - }; - }; - } - >; - }; - block_indices?: { - value?: { - value?: number[]; - }; - }; - packed?: { - data: Buffer; - blockDataOffset: number; - bitsPerBlock: number; - blocksPerWord: number; - blockNames: string[]; - hasNonAir: boolean; - }; -}; - -type NbtParseResult = { - parsed: unknown; - metadata: { - size: number; - }; -}; +type SubChunkLayer = DecodedBedrockSubchunkLayer; async function parseSubchunkLayers( rawValue: Buffer, - helpers: McbeLeveldbHelpers, + _helpers: McbeLeveldbHelpers, + subChunkY: number, ): Promise { - try { - return await parsePackedSubchunkLayers(rawValue); - } catch (error) { - if (!isUnsupportedPackedSubchunkVersionError(error)) { - throw error; - } - } - - try { - const parsed = - await helpers.entryContentTypeToFormatMap.SubChunkPrefix.parse( - rawValue, - ); - return getLayers(parsed); - } catch (error) { - if (!isUnsupportedPackedStorageVersionError(error)) { - throw error; - } - return parsePackedSubchunkLayers(rawValue); - } -} - -function isUnsupportedPackedStorageVersionError(error: unknown): boolean { - return ( - error instanceof Error && - /^Unknown storage version: (10|12)$/.test(error.message) - ); -} - -function isUnsupportedPackedSubchunkVersionError(error: unknown): boolean { - return ( - error instanceof Error && - /^Unsupported packed SubChunkPrefix version: /.test(error.message) - ); -} - -async function parsePackedSubchunkLayers( - data: Buffer, -): Promise { - let currentOffset = 0; - const version = data[currentOffset++]; - if (version !== 0x01 && version !== 0x08 && version !== 0x09) { - throw new Error( - `Unsupported packed SubChunkPrefix version: ${String(version)}`, - ); - } - - const layerCount = version === 0x01 ? 1 : data[currentOffset++]; - if (version >= 0x09) { - currentOffset += 1; - } - if (typeof layerCount !== "number") { - throw new Error("SubChunkPrefix is missing a storage layer count."); - } - - const layers: SubChunkLayer[] = []; - for (let layerIndex = 0; layerIndex < layerCount; layerIndex += 1) { - const storageVersion = data[currentOffset++]; - if (typeof storageVersion !== "number") { - throw new Error("SubChunkPrefix is missing a storage version."); - } - const bitsPerBlock = storageVersion >> 1; - if (bitsPerBlock <= 0 || bitsPerBlock > 16) { - throw new Error( - `Unsupported packed SubChunkPrefix bits per block: ${bitsPerBlock}.`, - ); - } - - const blocksPerWord = Math.floor(32 / bitsPerBlock); - const wordCount = Math.ceil(4096 / blocksPerWord); - const blockDataOffset = currentOffset; - currentOffset += wordCount * 4; - if (currentOffset + 4 > data.length) { - throw new Error("SubChunkPrefix block data is truncated."); - } - - const paletteSize = data.readInt32LE(currentOffset); - currentOffset += 4; - if (paletteSize < 0) { - throw new Error( - `SubChunkPrefix palette size is invalid: ${paletteSize}.`, - ); - } - - const blockNames: string[] = []; - - for ( - let paletteIndex = 0; - paletteIndex < paletteSize; - paletteIndex += 1 - ) { - const fastEntry = readBedrockPaletteEntryBlockName( - data, - currentOffset, - ); - if (fastEntry) { - currentOffset += fastEntry.size; - blockNames[paletteIndex] = fastEntry.blockName; - } else { - const result = (await NBT.parse( - data.subarray(currentOffset), - "little", - )) as NbtParseResult; - if (result.metadata.size <= 0) { - throw new Error("SubChunkPrefix palette entry is invalid."); - } - currentOffset += result.metadata.size; - blockNames[paletteIndex] = getPaletteBlockName(result.parsed); - } - } - - layers.push({ - packed: { - data, - blockDataOffset, - bitsPerBlock, - blocksPerWord, - blockNames, - hasNonAir: blockNames.some( - (blockName) => blockName !== "minecraft:air", - ), - }, - }); - } - - return layers; + const decoded = await decodeBedrockSubchunk(rawValue, { + allowedFormatVersions: [1, 8, 9], + subChunkY, + }); + return [...decoded.layers]; } type TerrainColumnsBuilder = { @@ -559,6 +409,7 @@ async function finalizeTerrainChunk( const layers = await parseSubchunkLayers( subchunk.rawValue, helpers, + subchunk.subChunkY, ); if (!layers) { parseErrors += 1; @@ -604,17 +455,7 @@ function mergeSubchunkIntoTerrainChunk( } function isAirOnlyLayer(layer: SubChunkLayer): boolean { - if (layer.packed) { - return !layer.packed.hasNonAir; - } - const palette = layer.palette?.value; - if (!palette) { - return false; - } - return Object.values(palette).every((block) => { - const name = block?.value?.name?.value; - return typeof name !== "string" || name === "minecraft:air"; - }); + return layer.palette.every((entry) => entry.typeId === "minecraft:air"); } function createChunkColumnIndex(localX: number, localZ: number): number { @@ -781,219 +622,9 @@ function getBlockNameAt( ): string { const blockIndex = createSubchunkBlockIndex(localX, localY, localZ); for (const layer of layers) { - if (layer.packed) { - const blockName = getPackedLayerBlockName(layer.packed, blockIndex); - if (blockName !== "minecraft:air") { - return blockName; - } - continue; - } - const paletteIndex = layer.block_indices?.value?.value?.[blockIndex]; - if (typeof paletteIndex !== "number") { - continue; - } - const block = layer.palette?.value?.[String(paletteIndex)]; - const name = block?.value?.name?.value; - if (typeof name === "string" && name !== "minecraft:air") { - return name; - } + const paletteIndex = layer.indices[blockIndex]; + const name = layer.palette[paletteIndex ?? 0]?.typeId; + if (name && name !== "minecraft:air") return name; } return "minecraft:air"; } - -function getPackedLayerBlockName( - layer: NonNullable, - blockIndex: number, -): string { - if (!layer.hasNonAir) { - return "minecraft:air"; - } - const word = layer.data.readUInt32LE( - layer.blockDataOffset + - Math.floor(blockIndex / layer.blocksPerWord) * 4, - ); - const shift = (blockIndex % layer.blocksPerWord) * layer.bitsPerBlock; - const paletteIndex = (word >>> shift) & ((1 << layer.bitsPerBlock) - 1); - return layer.blockNames[paletteIndex] ?? "minecraft:air"; -} - -const NBT_TAG_END = 0; -const NBT_TAG_BYTE = 1; -const NBT_TAG_SHORT = 2; -const NBT_TAG_INT = 3; -const NBT_TAG_LONG = 4; -const NBT_TAG_FLOAT = 5; -const NBT_TAG_DOUBLE = 6; -const NBT_TAG_BYTE_ARRAY = 7; -const NBT_TAG_STRING = 8; -const NBT_TAG_LIST = 9; -const NBT_TAG_COMPOUND = 10; -const NBT_TAG_INT_ARRAY = 11; -const NBT_TAG_LONG_ARRAY = 12; - -type NbtReader = { - data: Buffer; - offset: number; -}; - -function readBedrockPaletteEntryBlockName( - data: Buffer, - offset: number, -): { blockName: string; size: number } | undefined { - const reader: NbtReader = { data, offset }; - try { - const rootType = readNbtUnsignedByte(reader); - if (rootType !== NBT_TAG_COMPOUND) { - return undefined; - } - readNbtString(reader); - - let blockName: string | undefined; - while (true) { - const tagType = readNbtUnsignedByte(reader); - if (tagType === NBT_TAG_END) { - break; - } - const tagName = readNbtString(reader); - if (tagType === NBT_TAG_STRING && tagName === "name") { - blockName = readNbtString(reader); - } else { - skipNbtPayload(reader, tagType); - } - } - - const size = reader.offset - offset; - return size > 0 - ? { blockName: blockName ?? "minecraft:air", size } - : undefined; - } catch { - return undefined; - } -} - -function readNbtUnsignedByte(reader: NbtReader): number { - ensureNbtBytes(reader, 1); - const value = reader.data.readUInt8(reader.offset); - reader.offset += 1; - return value; -} - -function readNbtInt(reader: NbtReader): number { - ensureNbtBytes(reader, 4); - const value = reader.data.readInt32LE(reader.offset); - reader.offset += 4; - return value; -} - -function readNbtString(reader: NbtReader): string { - ensureNbtBytes(reader, 2); - const length = reader.data.readUInt16LE(reader.offset); - reader.offset += 2; - ensureNbtBytes(reader, length); - const value = reader.data.toString( - "utf8", - reader.offset, - reader.offset + length, - ); - reader.offset += length; - return value; -} - -function skipNbtBytes(reader: NbtReader, count: number): void { - if (!Number.isInteger(count) || count < 0) { - throw new Error("NBT payload length is invalid."); - } - ensureNbtBytes(reader, count); - reader.offset += count; -} - -function skipNbtPayload(reader: NbtReader, tagType: number): void { - switch (tagType) { - case NBT_TAG_BYTE: - skipNbtBytes(reader, 1); - return; - case NBT_TAG_SHORT: - skipNbtBytes(reader, 2); - return; - case NBT_TAG_INT: - case NBT_TAG_FLOAT: - skipNbtBytes(reader, 4); - return; - case NBT_TAG_LONG: - case NBT_TAG_DOUBLE: - skipNbtBytes(reader, 8); - return; - case NBT_TAG_BYTE_ARRAY: - skipNbtBytes(reader, readNbtInt(reader)); - return; - case NBT_TAG_STRING: - readNbtString(reader); - return; - case NBT_TAG_LIST: { - const childType = readNbtUnsignedByte(reader); - const length = readNbtInt(reader); - for (let index = 0; index < length; index += 1) { - skipNbtPayload(reader, childType); - } - return; - } - case NBT_TAG_COMPOUND: - skipNbtCompoundPayload(reader); - return; - case NBT_TAG_INT_ARRAY: - skipNbtBytes(reader, readNbtInt(reader) * 4); - return; - case NBT_TAG_LONG_ARRAY: - skipNbtBytes(reader, readNbtInt(reader) * 8); - return; - default: - throw new Error(`Unsupported NBT tag type ${String(tagType)}.`); - } -} - -function skipNbtCompoundPayload(reader: NbtReader): void { - while (true) { - const tagType = readNbtUnsignedByte(reader); - if (tagType === NBT_TAG_END) { - return; - } - readNbtString(reader); - skipNbtPayload(reader, tagType); - } -} - -function ensureNbtBytes(reader: NbtReader, count: number): void { - if (reader.offset + count > reader.data.length) { - throw new Error("NBT payload is truncated."); - } -} - -function getPaletteBlockName(parsed: unknown): string { - const name = ( - parsed as { - value?: { - name?: { - value?: unknown; - }; - }; - } - )?.value?.name?.value; - return typeof name === "string" ? name : "minecraft:air"; -} - -function getLayers(parsed: unknown): SubChunkLayer[] | undefined { - const layers = (parsed as { value?: { layers?: unknown } } | undefined) - ?.value?.layers; - if ( - typeof layers !== "object" || - !layers || - !("value" in layers) || - typeof layers.value !== "object" || - !layers.value || - !("value" in layers.value) || - !Array.isArray(layers.value.value) - ) { - return undefined; - } - return layers.value.value as SubChunkLayer[]; -} diff --git a/packages/blr/test/bds.test.ts b/packages/blr/test/bds.test.ts index 8a3f182..cd52978 100644 --- a/packages/blr/test/bds.test.ts +++ b/packages/blr/test/bds.test.ts @@ -19,6 +19,7 @@ import { capturePermissionsFromBds, ensureBds, prefetchBdsArchive, + replaceRuntimeWorldFromProjectSource, replaceBdsServerDirectory, resolveBdsOutputRelayMode, } from "../src/bds.js"; @@ -800,6 +801,69 @@ test("bootstrapProjectWorldSourceFromBds waits when neither a valid project sour ); }); +test("runtime world replacement consumes an explicit processed input without reading the authored source", async (t) => { + const projectRoot = await createTempDirectory( + t, + "blr-bds-processed-input-", + ); + const authored = path.join(projectRoot, "worlds", "Bedrock level"); + const processed = path.join(projectRoot, ".blr", "processed", "world"); + const runtime = path.join( + projectRoot, + ".blr", + "server", + "worlds", + "Bedrock level", + ); + await mkdir(path.join(authored, "db"), { recursive: true }); + await mkdir(path.join(processed, "db"), { recursive: true }); + await mkdir(path.join(runtime, "db"), { recursive: true }); + await writeFile(path.join(authored, "levelname.txt"), "authored"); + await writeFile(path.join(processed, "levelname.txt"), "processed"); + await writeFile(path.join(runtime, "levelname.txt"), "old-runtime"); + + await replaceRuntimeWorldFromProjectSource( + projectRoot, + {} as never, + { + channel: "stable", + version: "1.26.3.1", + platform: "win", + cacheDirectory: path.join(projectRoot, ".blr", "cache"), + serverDirectory: path.join(projectRoot, ".blr", "server"), + worldName: "Bedrock level", + worldSourcePath: "worlds/Bedrock level", + worldDirectory: runtime, + worldSourceDirectory: authored, + executablePath: path.join( + projectRoot, + ".blr", + "server", + "bedrock_server.exe", + ), + zipPath: path.join(projectRoot, ".blr", "cache", "server.zip"), + customExecutableInjected: false, + }, + { + worldInput: { + kind: "processed", + worldName: "Bedrock level", + directory: processed, + seedIdentity: "processed:abc123", + }, + }, + ); + + assert.equal( + await readFile(path.join(runtime, "levelname.txt"), "utf8"), + "processed", + ); + assert.equal( + await readFile(path.join(authored, "levelname.txt"), "utf8"), + "authored", + ); +}); + test("captureAllowlistFromBds copies runtime allowlist state back into the project server directory", async (t) => { const projectRoot = await createTempDirectory( t, diff --git a/packages/blr/test/bedrock-block-entities.test.ts b/packages/blr/test/bedrock-block-entities.test.ts new file mode 100644 index 0000000..eb574d4 --- /dev/null +++ b/packages/blr/test/bedrock-block-entities.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import NBT from "prismarine-nbt"; +import { + decodeBedrockBlockEntities, + decodeSignOrientation, + normalizeSignText, +} from "../src/world-processing/bedrock-block-entities.js"; + +test("normalizeSignText preserves arbitrary rows and empty values while normalising line endings", () => { + assert.deepEqual(normalizeSignText("~\r\n@object\rlamppost\n\n"), { + rawText: "~\r\n@object\rlamppost\n\n", + normalizedText: "~\n@object\nlamppost\n\n", + lines: ["~", "@object", "lamppost", ""], + }); + assert.deepEqual(normalizeSignText("id\n@custom\na\n\nb\n\n"), { + rawText: "id\n@custom\na\n\nb\n\n", + normalizedText: "id\n@custom\na\n\nb\n\n", + lines: ["id", "@custom", "a", "", "b", ""], + }); + assert.deepEqual(normalizeSignText("single"), { + rawText: "single", + normalizedText: "single", + lines: ["single"], + }); +}); + +test("decodeBedrockBlockEntities preserves both sign faces independently", async () => { + const raw = writeBlockEntities([ + { + id: { type: "string", value: "Sign" }, + x: { type: "int", value: -219 }, + y: { type: "int", value: 80 }, + z: { type: "int", value: 1448 }, + FrontText: signText("~\n@object\nlamppost\n\n"), + BackText: signText("back.id\n@other\na\nb"), + IsWaxed: { type: "byte", value: 0 }, + }, + ]); + + const [entity] = await decodeBedrockBlockEntities(raw); + assert.equal(entity?.id, "Sign"); + assert.deepEqual(entity?.location, { x: -219, y: 80, z: 1448 }); + assert.deepEqual(entity?.signFaces, [ + { + face: "front", + rawText: "~\n@object\nlamppost\n\n", + normalizedText: "~\n@object\nlamppost\n\n", + lines: ["~", "@object", "lamppost", ""], + }, + { + face: "back", + rawText: "back.id\n@other\na\nb", + normalizedText: "back.id\n@other\na\nb", + lines: ["back.id", "@other", "a", "b"], + }, + ]); +}); + +test("decodeSignOrientation maps every standing and wall state without rounding", () => { + const expectedStanding = [ + 0, 22.5, 45, 67.5, 90, 112.5, 135, 157.5, 180, -157.5, -135, -112.5, + -90, -67.5, -45, -22.5, + ]; + for (let state = 0; state < 16; state += 1) { + assert.deepEqual( + decodeSignOrientation({ + typeId: "minecraft:spruce_standing_sign", + states: { ground_sign_direction: state }, + version: 18_488_832, + }), + { + kind: "standing", + groundSignDirection: state, + yawDegrees: expectedStanding[state], + }, + ); + } + + const wallYaws = new Map([ + [2, 180], + [3, 0], + [4, 90], + [5, -90], + ]); + for (const [state, yawDegrees] of wallYaws) { + assert.deepEqual( + decodeSignOrientation({ + typeId: "minecraft:spruce_wall_sign", + states: { facing_direction: state }, + version: 18_488_832, + }), + { kind: "wall", facingDirection: state, yawDegrees }, + ); + } +}); + +test("decodeSignOrientation rejects malformed sign states and preserves hanging-sign state", () => { + assert.throws( + () => + decodeSignOrientation({ + typeId: "minecraft:standing_sign", + states: { ground_sign_direction: 16 }, + version: 0, + }), + /ground_sign_direction.*0\.\.15/i, + ); + assert.throws( + () => + decodeSignOrientation({ + typeId: "minecraft:wall_sign", + states: { facing_direction: 1 }, + version: 0, + }), + /facing_direction.*2, 3, 4, or 5/i, + ); + assert.deepEqual( + decodeSignOrientation({ + typeId: "minecraft:oak_hanging_sign", + states: { attached_bit: true, ground_sign_direction: 7 }, + version: 0, + }), + { + kind: "hanging", + states: { attached_bit: true, ground_sign_direction: 7 }, + }, + ); + assert.equal( + decodeSignOrientation({ + typeId: "minecraft:stone", + states: {}, + version: 0, + }), + undefined, + ); +}); + +function signText(text: string): NbtCompound { + return { + type: "compound", + value: { + Text: { type: "string", value: text }, + IgnoreLighting: { type: "byte", value: 0 }, + SignTextColor: { type: "int", value: -16_777_216 }, + }, + }; +} + +type NbtCompound = { + type: "compound"; + value: Record; +}; + +function writeBlockEntities( + values: readonly Record[], +): Buffer { + return Buffer.concat( + values.map((value) => + NBT.writeUncompressed( + { + name: "", + type: "compound", + value, + } as Parameters[0], + "little", + ), + ), + ); +} diff --git a/packages/blr/test/bedrock-subchunk.test.ts b/packages/blr/test/bedrock-subchunk.test.ts new file mode 100644 index 0000000..fa543c4 --- /dev/null +++ b/packages/blr/test/bedrock-subchunk.test.ts @@ -0,0 +1,284 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import NBT from "prismarine-nbt"; +import { + decodeBedrockSubchunk, + type DecodedBedrockSubchunk, +} from "../src/world-processing/bedrock-subchunk.js"; + +const OBSERVED_STORAGE_HEADERS = [2, 4, 6, 8, 10, 12, 16] as const; + +test("decodeBedrockSubchunk decodes every observed v9 packed storage header", async () => { + for (const storageHeader of OBSERVED_STORAGE_HEADERS) { + const raw = createV9Subchunk({ + subChunkY: -2, + layers: [ + { + storageHeader, + palette: [ + palette("minecraft:air", {}, 18_488_832), + palette( + "minecraft:smooth_stone_slab", + { "minecraft:vertical_half": "bottom" }, + 18_488_832, + ), + ], + indices: new Map([ + [blockIndex(0, 0, 0), 1], + [blockIndex(15, 15, 15), 1], + ]), + }, + ], + }); + + const decoded = await decodeBedrockSubchunk(raw); + assert.equal(decoded.formatVersion, 9); + assert.equal(decoded.subChunkY, -2); + assert.equal(decoded.layers.length, 1); + const layer = decoded.layers[0]!; + assert.equal(layer.storageHeader, storageHeader); + assert.equal(layer.bitsPerBlock, storageHeader >> 1); + assert.deepEqual(layer.palette[1], { + typeId: "minecraft:smooth_stone_slab", + states: { "minecraft:vertical_half": "bottom" }, + version: 18_488_832, + }); + assert.equal(layer.indices[blockIndex(0, 0, 0)], 1); + assert.equal(layer.indices[blockIndex(15, 15, 15)], 1); + } +}); + +test("decodeBedrockSubchunk preserves every storage layer and canonical block index order", async () => { + const raw = createV9Subchunk({ + subChunkY: 5, + layers: [ + { + storageHeader: 2, + palette: [ + palette("minecraft:air"), + palette("minecraft:water", { liquid_depth: 0 }), + ], + indices: new Map([[blockIndex(3, 4, 5), 1]]), + }, + { + storageHeader: 4, + palette: [ + palette("minecraft:air"), + palette("bc_test:overlay", { active: true }), + ], + indices: new Map([[blockIndex(3, 4, 5), 1]]), + }, + ], + }); + + const decoded = await decodeBedrockSubchunk(raw); + assert.equal(decoded.layers.length, 2); + assert.equal(decoded.layers[0]!.indices[blockIndex(3, 4, 5)], 1); + assert.equal(decoded.layers[1]!.indices[blockIndex(3, 4, 5)], 1); + assert.equal(decoded.layers[1]!.palette[1]!.states.active, true); +}); + +test("decodeBedrockSubchunk rejects unsupported formats, storage headers, palette references, and trailing bytes", async () => { + const valid = createV9Subchunk({ + subChunkY: 0, + layers: [ + { + storageHeader: 2, + palette: [palette("minecraft:air")], + indices: new Map(), + }, + ], + }); + + const wrongFormat = Buffer.from(valid); + wrongFormat[0] = 10; + await assert.rejects( + () => decodeBedrockSubchunk(wrongFormat), + /unsupported subchunk format 10/i, + ); + + const oddHeader = Buffer.from(valid); + oddHeader[3] = 3; + await assert.rejects( + () => decodeBedrockSubchunk(oddHeader), + /unsupported packed storage header 3/i, + ); + + const invalidReference = createV9Subchunk({ + subChunkY: 0, + layers: [ + { + storageHeader: 2, + palette: [palette("minecraft:air")], + indices: new Map([[blockIndex(1, 2, 3), 1]]), + }, + ], + }); + await assert.rejects( + () => decodeBedrockSubchunk(invalidReference), + /palette index 1.*palette size is 1/i, + ); + + await assert.rejects( + () => decodeBedrockSubchunk(Buffer.concat([valid, Buffer.from([0])])), + /trailing byte/i, + ); +}); + +test("decodeBedrockSubchunk can admit legacy formats only at an explicit compatibility edge", async () => { + const rawV8 = createV8Subchunk({ + layers: [ + { + storageHeader: 2, + palette: [palette("minecraft:air")], + indices: new Map(), + }, + ], + }); + + await assert.rejects( + () => decodeBedrockSubchunk(rawV8), + /unsupported subchunk format 8/i, + ); + const decoded = await decodeBedrockSubchunk(rawV8, { + allowedFormatVersions: [8], + subChunkY: -4, + }); + assert.equal(decoded.formatVersion, 8); + assert.equal(decoded.subChunkY, -4); +}); + +test("the pinned production inventory covers every storage layer in the selected world", async () => { + const fixture = JSON.parse( + await readFile( + new URL( + "./fixtures/world-processing/production-v9-inventory.json", + import.meta.url, + ), + "utf8", + ), + ) as { + source: { fingerprint: string }; + subchunks: { + records: number; + formatVersions: Record; + layerCounts: Record; + firstLayerHeaders: Record; + allLayerHeaders: Record; + }; + blockEntities: { signs: number; distinctFrontBackPairs: number }; + }; + + assert.equal( + fixture.source.fingerprint, + "sha256:b271e1224429cd53e2b746ffd69ccf3dcef3683dab5282184d979a458f2e6d12", + ); + assert.deepEqual(Object.keys(fixture.subchunks.formatVersions), ["9"]); + assert.deepEqual( + Object.keys(fixture.subchunks.allLayerHeaders).map(Number), + [...OBSERVED_STORAGE_HEADERS], + ); + assert.equal(sum(fixture.subchunks.layerCounts), fixture.subchunks.records); + assert.equal( + sum(fixture.subchunks.allLayerHeaders), + fixture.subchunks.records + fixture.subchunks.layerCounts["2"]!, + ); + assert.equal( + fixture.subchunks.allLayerHeaders["2"]! - + fixture.subchunks.firstLayerHeaders["2"]!, + fixture.subchunks.layerCounts["2"], + ); + assert.equal(fixture.blockEntities.signs, 1470); + assert.equal(fixture.blockEntities.distinctFrontBackPairs, 79); +}); + +type PaletteEntry = DecodedBedrockSubchunk["layers"][number]["palette"][number]; + +function palette( + typeId: string, + states: Record = {}, + version = 0, +): PaletteEntry { + return { typeId, states, version }; +} + +type LayerFixture = { + storageHeader: (typeof OBSERVED_STORAGE_HEADERS)[number]; + palette: readonly PaletteEntry[]; + indices: ReadonlyMap; +}; + +function createV9Subchunk(input: { + subChunkY: number; + layers: readonly LayerFixture[]; +}): Buffer { + return Buffer.concat([ + Buffer.from([9, input.layers.length, input.subChunkY & 0xff]), + ...input.layers.map(createPackedLayer), + ]); +} + +function createV8Subchunk(input: { layers: readonly LayerFixture[] }): Buffer { + return Buffer.concat([ + Buffer.from([8, input.layers.length]), + ...input.layers.map(createPackedLayer), + ]); +} + +function createPackedLayer(layer: LayerFixture): Buffer { + const bitsPerBlock = layer.storageHeader >> 1; + const blocksPerWord = Math.floor(32 / bitsPerBlock); + const wordCount = Math.ceil(4096 / blocksPerWord); + const words = Buffer.alloc(wordCount * 4); + for (const [index, paletteIndex] of layer.indices) { + const wordIndex = Math.floor(index / blocksPerWord); + const shift = (index % blocksPerWord) * bitsPerBlock; + const current = words.readUInt32LE(wordIndex * 4); + words.writeUInt32LE( + (current | (paletteIndex << shift)) >>> 0, + wordIndex * 4, + ); + } + const paletteSize = Buffer.alloc(4); + paletteSize.writeInt32LE(layer.palette.length); + return Buffer.concat([ + Buffer.from([layer.storageHeader]), + words, + paletteSize, + ...layer.palette.map(writePaletteEntry), + ]); +} + +function writePaletteEntry(entry: PaletteEntry): Buffer { + const states = Object.fromEntries( + Object.entries(entry.states).map(([key, value]) => [ + key, + typeof value === "boolean" + ? { type: "byte", value: value ? 1 : 0 } + : typeof value === "number" + ? { type: "int", value } + : { type: "string", value }, + ]), + ); + return NBT.writeUncompressed( + { + name: "", + type: "compound", + value: { + name: { type: "string", value: entry.typeId }, + states: { type: "compound", value: states }, + version: { type: "int", value: entry.version }, + }, + } as Parameters[0], + "little", + ); +} + +function blockIndex(localX: number, localY: number, localZ: number): number { + return (localX << 8) | (localZ << 4) | localY; +} + +function sum(values: Record): number { + return Object.values(values).reduce((total, value) => total + value, 0); +} diff --git a/packages/blr/test/config.test.ts b/packages/blr/test/config.test.ts index 5c0e503..3cd3f92 100644 --- a/packages/blr/test/config.test.ts +++ b/packages/blr/test/config.test.ts @@ -89,6 +89,151 @@ test("loadBlurConfig derives the default worldSourcePath from dev.localServer.wo assert.equal(config.features.resourcePack, false); }); +test("loadBlurConfig normalises portable world processor configuration", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-config-"); + await createMinimalProject(projectRoot, { + schemaVersion: 1, + projectVersion: 1, + namespace: "test_pack", + worldProcessors: [ + { + id: "project-world-locations", + module: "./tooling/world-locations/provider.ts", + export: "createWorldLocationsProcessor", + sourceWorld: "Bedrock level", + capabilities: ["artifact", "transform"], + dependsOn: [], + inputPaths: ["tooling/world-locations/source.json"], + outputRoot: + "world-data/Bedrock level/generated/world-locations", + payloadFileNames: { + locations: "locations.json", + }, + runtimePointerPath: + "world-data/Bedrock level/generated/world-locations/current.generated.json", + auditOutputPath: + "generated/world-locations/world-locations.audit.json", + applyOn: { + build: true, + package: true, + worldBuild: true, + }, + }, + ], + }); + + const { config } = await loadBlurConfig(projectRoot); + assert.deepEqual(config.worldProcessors, [ + { + id: "project-world-locations", + module: "./tooling/world-locations/provider.ts", + export: "createWorldLocationsProcessor", + sourceWorld: "Bedrock level", + capabilities: ["artifact", "transform"], + dependsOn: [], + inputPaths: ["tooling/world-locations/source.json"], + outputRoot: "world-data/Bedrock level/generated/world-locations", + payloadFileNames: { + locations: "locations.json", + }, + runtimePointerPath: + "world-data/Bedrock level/generated/world-locations/current.generated.json", + auditOutputPath: + "generated/world-locations/world-locations.audit.json", + applyOn: { + dev: true, + build: true, + package: true, + check: true, + worldBuild: true, + worldPush: true, + }, + }, + ]); +}); + +test("loadBlurConfig rejects unsafe processor ids and output traversal", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-config-"); + await createMinimalProject(projectRoot, { + schemaVersion: 1, + projectVersion: 1, + namespace: "test_pack", + worldProcessors: [ + { + id: "CON", + module: "./provider.ts", + sourceWorld: "Bedrock level", + capabilities: ["artifact"], + outputRoot: "../escape", + payloadFileNames: { locations: "locations.json" }, + }, + ], + }); + + await assert.rejects( + () => loadBlurConfig(projectRoot), + /worldProcessors\[0\]\.id must be one portable lowercase segment/, + ); +}); + +test("loadBlurConfig rejects duplicate processor outputs", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-config-"); + await createMinimalProject(projectRoot, { + schemaVersion: 1, + projectVersion: 1, + namespace: "test_pack", + worldProcessors: [ + { + id: "one", + module: "./one.ts", + sourceWorld: "Bedrock level", + capabilities: ["artifact"], + outputRoot: "src/generated/shared", + payloadFileNames: { output: "output.json" }, + }, + { + id: "two", + module: "./two.ts", + sourceWorld: "Bedrock level", + capabilities: ["artifact"], + outputRoot: "src/generated/shared", + payloadFileNames: { output: "output.json" }, + }, + ], + }); + + await assert.rejects( + () => loadBlurConfig(projectRoot), + /world processor output path "src\/generated\/shared\/output\.json" is claimed by both "one" and "two"/, + ); +}); + +test("loadBlurConfig rejects runtime pointers that are neither TypeScript nor JSON", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-config-"); + await createMinimalProject(projectRoot, { + schemaVersion: 1, + projectVersion: 1, + namespace: "test_pack", + worldProcessors: [ + { + id: "project-locations", + module: "./tooling/world-locations/provider.ts", + sourceWorld: "Bedrock level", + capabilities: ["artifact"], + outputRoot: "world-data/Bedrock level/generated/locations", + payloadFileNames: { locations: "locations.json" }, + runtimePointerPath: + "world-data/Bedrock level/generated/locations/current.generated.txt", + }, + ], + }); + + await assert.rejects( + () => loadBlurConfig(projectRoot), + /runtimePointerPath must end in \.ts or \.json/, + ); +}); + test("loadBlurConfig enables local-server scripting log compaction by default", async (t) => { const projectRoot = await createTempDirectory(t, "blr-config-"); await createMinimalProject(projectRoot, { @@ -665,3 +810,30 @@ test("loadBlurConfig respects environment overrides for local-server worldSync m assert.equal(config.dev.localServer.worldSync.projectWorldMode, "auto"); assert.equal(config.dev.localServer.worldSync.runtimeWorldMode, "backup"); }); + +test("loadBlurConfig defaults authored world pushes and accepts processed publication policy", async (t) => { + const authoredRoot = await createTempDirectory( + t, + "blr-config-world-push-authored-", + ); + await createMinimalProject(authoredRoot, { + schemaVersion: 1, + projectVersion: 1, + namespace: "fixture", + }); + const authored = await loadBlurConfig(authoredRoot); + assert.equal(authored.config.world.pushPolicy, "authored"); + + const processedRoot = await createTempDirectory( + t, + "blr-config-world-push-processed-", + ); + await createMinimalProject(processedRoot, { + schemaVersion: 1, + projectVersion: 1, + namespace: "fixture", + world: { pushPolicy: "processed" }, + }); + const processed = await loadBlurConfig(processedRoot); + assert.equal(processed.config.world.pushPolicy, "processed"); +}); diff --git a/packages/blr/test/create.test.ts b/packages/blr/test/create.test.ts index 9abb862..e815afe 100644 --- a/packages/blr/test/create.test.ts +++ b/packages/blr/test/create.test.ts @@ -18,6 +18,7 @@ import { } from "./helpers.js"; type PackageJsonShape = { + scripts?: Record; dependencies?: Record; devDependencies?: Record; engines?: { @@ -78,6 +79,14 @@ test("built cli create scaffolds a scripting project with bebe enabled by defaul ); assert.ok(packageJson.devDependencies?.["@blurengine/cli"]); assert.equal(packageJson.engines?.node, ">=22.12.0"); + assert.equal( + packageJson.scripts?.["assets:check"], + "blr world build --check", + ); + assert.equal( + packageJson.scripts?.check, + "npm run assets:check && blr build", + ); assert.match(mainFile, /import \{ Context \} from "@blurengine\/bebe";/); assert.match(mainFile, /const ctx = new Context\(\);/); assert.equal( diff --git a/packages/blr/test/dev.test.ts b/packages/blr/test/dev.test.ts index f978635..4176ce4 100644 --- a/packages/blr/test/dev.test.ts +++ b/packages/blr/test/dev.test.ts @@ -5,7 +5,9 @@ import test, { type TestContext } from "node:test"; import { loadBlurConfig } from "../src/config.js"; import { BLR_ENV_BDS_VERSION } from "../src/constants.js"; import { + assertDevWorldProcessingCompatibility, buildRemoteWorldSyncFailureMessage, + createWorldProcessorWatchPlan, mergePipelineModes, resolveRuntimeWorldDecision, resolveProjectWatchChangeAction, @@ -583,6 +585,75 @@ test("resolveProjectWatchChangeAction reloads source changes, syncs pack changes }); }); +test("processed dev worlds reject runtime-to-source capture", () => { + assert.throws( + () => + assertDevWorldProcessingCompatibility({ + watchWorld: true, + worldName: "Bedrock level", + worldProcessors: [ + { + id: "locations", + sourceWorld: "Bedrock level", + capabilities: ["artifact", "transform"], + applyOn: { dev: true }, + }, + ], + }), + /processed play mode.*watch-world.*authored world/i, + ); + + assert.doesNotThrow(() => + assertDevWorldProcessingCompatibility({ + watchWorld: true, + worldName: "Bedrock level", + worldProcessors: [ + { + id: "locations", + sourceWorld: "Bedrock level", + capabilities: ["artifact"], + applyOn: { dev: true }, + }, + ], + }), + ); +}); + +test("world processor watch plans cover config, provider, declared inputs, and authored world changes", () => { + const plan = createWorldProcessorWatchPlan({ + worldSourcePath: "worlds/Bedrock level", + processors: [ + { + module: "./tooling/locations/processor.ts", + inputPaths: ["tooling/locations/catalogue.json"], + }, + ], + }); + + assert.equal(plan.matches("blr.config.json"), true); + assert.equal(plan.matches("worlds/worlds.json"), true); + assert.equal(plan.matches("tooling/locations/processor.ts"), true); + assert.equal(plan.matches("tooling/locations/parser.ts"), true); + assert.equal(plan.matches("tooling/locations/catalogue.json"), true); + assert.equal(plan.matches("worlds/Bedrock level/db/000001.ldb"), true); + assert.equal(plan.matches("src/main.ts"), false); +}); + +test("world processor watch changes request a safe restart and config refresh", () => { + assert.deepEqual( + resolveProjectWatchChangeAction("tooling/locations/processor.ts", { + worldProcessorPaths: ["tooling/locations/**/*"], + }), + { kind: "restart", pipelineMode: "restart", reloadConfig: false }, + ); + assert.deepEqual( + resolveProjectWatchChangeAction("blr.config.json", { + worldProcessorPaths: ["tooling/locations/**/*"], + }), + { kind: "restart", pipelineMode: "restart", reloadConfig: true }, + ); +}); + test("Bebe collection asset sources expand dev watch coverage for nested files", async (t) => { const projectRoot = await createTempDirectory(t, "blr-dev-bebe-watch-"); await createBebeAssetWatchProject(projectRoot); diff --git a/packages/blr/test/fixtures/world-processing/production-v9-inventory.json b/packages/blr/test/fixtures/world-processing/production-v9-inventory.json new file mode 100644 index 0000000..e2c129c --- /dev/null +++ b/packages/blr/test/fixtures/world-processing/production-v9-inventory.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "source": { + "worldName": "Bedrock level", + "fingerprint": "sha256:b271e1224429cd53e2b746ffd69ccf3dcef3683dab5282184d979a458f2e6d12", + "version": "PtY4XXKBQtKWA9pb.P5A_FNX915RVkHE", + "auditDate": "2026-08-11", + "method": "Read-only LevelDB iteration over a verified disposable copy; every SubChunkPrefix layer header and every BlockEntity record was inspected." + }, + "subchunks": { + "records": 1589465, + "formatVersions": { + "9": 1589465 + }, + "layerCounts": { + "1": 1589220, + "2": 245 + }, + "firstLayerHeaders": { + "2": 1579055, + "4": 4751, + "6": 2466, + "8": 1720, + "10": 917, + "12": 301, + "16": 255 + }, + "allLayerHeaders": { + "2": 1579300, + "4": 4751, + "6": 2466, + "8": 1720, + "10": 917, + "12": 301, + "16": 255 + } + }, + "blockEntities": { + "records": 66225, + "entities": 5730, + "signs": 1470, + "distinctFrontBackPairs": 79, + "distinctFaceTexts": 79 + } +} diff --git a/packages/blr/test/mcbe-leveldb-adapter.test.ts b/packages/blr/test/mcbe-leveldb-adapter.test.ts new file mode 100644 index 0000000..1bceef0 --- /dev/null +++ b/packages/blr/test/mcbe-leveldb-adapter.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { loadMcbeLeveldbHelpers } from "../src/mcbe-leveldb-adapter.js"; + +test("the LevelDB helper boundary never mutates caller-owned chunk indices", async () => { + const helpers = await loadMcbeLeveldbHelpers(); + const indices = { dimension: "overworld" as const, x: 12, z: -7 }; + + helpers.generateChunkKeyFromIndices(indices, "Data3D"); + + assert.deepEqual(indices, { dimension: "overworld", x: 12, z: -7 }); +}); diff --git a/packages/blr/test/schema.test.ts b/packages/blr/test/schema.test.ts index e75dc21..0beb69d 100644 --- a/packages/blr/test/schema.test.ts +++ b/packages/blr/test/schema.test.ts @@ -62,4 +62,8 @@ test("generate-config-schema emits the packaged draft-07 schema with config desc rootDefinition?.properties?.minecraft?.description, "Project-level Minecraft targeting defaults.", ); + assert.equal( + rootDefinition?.properties?.worldProcessors?.description, + "Trusted project processors that derive artifacts or staged world mutations from an immutable selected world.", + ); }); diff --git a/packages/blr/test/upgrade.test.ts b/packages/blr/test/upgrade.test.ts index bfae739..1b523a8 100644 --- a/packages/blr/test/upgrade.test.ts +++ b/packages/blr/test/upgrade.test.ts @@ -22,6 +22,22 @@ type BlurConfigShape = { test("upgradeProjectScaffold migrates legacy project state and reconciles managed files", async (t) => { const projectRoot = await copyFixtureProject(t, "legacy-project-v0"); + await writeFile( + path.join(projectRoot, "package.json"), + `${JSON.stringify( + { + name: "legacy-project-v0", + private: true, + scripts: { + build: "old build", + check: "npm run project-specific-check", + }, + }, + null, + 2, + )}\n`, + "utf8", + ); const result = await upgradeProjectScaffold( projectRoot, path.join(projectRoot, "package.json"), @@ -74,7 +90,10 @@ test("upgradeProjectScaffold migrates legacy project state and reconciles manage const packageJson = await readJsonFile( path.join(projectRoot, "package.json"), ); - assert.deepEqual(packageJson.scripts, { ...MANAGED_PACKAGE_SCRIPTS }); + assert.deepEqual(packageJson.scripts, { + ...MANAGED_PACKAGE_SCRIPTS, + check: "npm run project-specific-check", + }); assert.equal(packageJson.engines?.node, ">=22.12.0"); const gitIgnore = await readTextFile(path.join(projectRoot, ".gitignore")); diff --git a/packages/blr/test/world-backend.test.ts b/packages/blr/test/world-backend.test.ts index 9e48c3a..c9a96f1 100644 --- a/packages/blr/test/world-backend.test.ts +++ b/packages/blr/test/world-backend.test.ts @@ -5,6 +5,7 @@ import test from "node:test"; import { S3Client, S3ServiceException } from "@aws-sdk/client-s3"; import AdmZip from "adm-zip"; import { + assertRuntimeWorldCaptureIsAuthored, runWorldListCommand, runWorldPushCommand, runWorldVersionsCommand, @@ -12,6 +13,10 @@ import { import { loadBlurConfig } from "../src/config.js"; import { exists } from "../src/fs.js"; import { buildTrackedProjectWorldFingerprint } from "../src/project-world-state.js"; +import { + readMaterializedProjectWorldRemoteState, + readProcessedWorldPublicationState, +} from "../src/world-internal-state.js"; import { describeWorldStatus, listRemoteWorldVersionsFromS3, @@ -42,6 +47,16 @@ function createBehaviorManifest(projectName: string) { }; } +test("processed runtime worlds cannot be captured into authored source", () => { + assert.throws( + () => assertRuntimeWorldCaptureIsAuthored("processed:build-123"), + /refusing to capture a processed runtime world/i, + ); + assert.doesNotThrow(() => + assertRuntimeWorldCaptureIsAuthored("sha256:authored-source"), + ); +}); + async function createMinimalProject( projectRoot: string, config: Record, @@ -879,6 +894,175 @@ test("pushWorldToS3 returns the pushed version id and writes the minimal tracked ); }); +test("processed world pushes use a separate remote channel and never mark authored source as materialized", async (t) => { + const projectRoot = await createTempDirectory( + t, + "blr-world-push-processed-", + ); + await createMinimalProject(projectRoot, { + schemaVersion: 1, + projectVersion: 1, + namespace: "test_pack", + world: { + backend: "s3", + pushPolicy: "processed", + s3: { + bucket: "example-worlds", + region: "eu-west-2", + keyPrefix: "worlds", + }, + }, + }); + const { config } = await loadBlurConfig(projectRoot); + const authored = path.join(projectRoot, "worlds", "Bedrock level"); + const processed = path.join(projectRoot, ".blr", "processed", "world"); + await mkdir(path.join(authored, "db"), { recursive: true }); + await mkdir(path.join(processed, "db"), { recursive: true }); + + const putKeys: string[] = []; + const originalSend = (S3Client.prototype as any).send; + (S3Client.prototype as any).send = async (command: any) => { + switch (command.constructor.name) { + case "GetBucketVersioningCommand": + return { Status: "Enabled" }; + case "HeadObjectCommand": + throw createS3Error("NotFound", 404); + case "GetObjectCommand": + if (isLockObjectKey(command.input.Key)) { + throw createS3Error("NoSuchKey", 404); + } + throw new Error(`Unexpected object read ${command.input.Key}`); + case "PutObjectCommand": + putKeys.push(command.input.Key); + return isLockObjectKey(command.input.Key) + ? { ETag: '"etag-lock"' } + : { VersionId: "ver-processed", ETag: '"etag-world"' }; + case "DeleteObjectCommand": + return {}; + default: + throw new Error( + `Unexpected command ${command.constructor.name}`, + ); + } + }; + t.after(() => { + (S3Client.prototype as any).send = originalSend; + }); + + const pushed = await pushWorldToS3(projectRoot, config, "Bedrock level", { + channel: "processed", + worldInputDirectory: processed, + worldBuildId: "build-123", + forceLock: true, + }); + + assert.equal( + pushed.context.objectKey, + "worlds/processed/Bedrock level.zip", + ); + assert.equal(putKeys.includes("worlds/processed/Bedrock level.zip"), true); + assert.equal( + await readMaterializedProjectWorldRemoteState( + projectRoot, + "Bedrock level", + ), + undefined, + ); + const publication = await readProcessedWorldPublicationState( + projectRoot, + "Bedrock level", + ); + assert.equal( + publication?.remoteFingerprint, + buildTrackedProjectWorldFingerprint({ + backend: "s3", + bucket: "example-worlds", + endpoint: "", + objectKey: "worlds/processed/Bedrock level.zip", + }), + ); + assert.equal(publication?.versionId, "ver-processed"); + assert.equal(publication?.worldBuildId, "build-123"); + assert.match(publication?.publishedAt ?? "", /^\d{4}-\d{2}-\d{2}T/); + assert.equal( + await exists(path.join(projectRoot, "worlds", "worlds.json")), + false, + ); +}); + +test("processed push acquires the remote lock before rejecting a missing current world build", async (t) => { + const projectRoot = await createTempDirectory( + t, + "blr-world-push-preflight-", + ); + await createMinimalProject(projectRoot, { + schemaVersion: 1, + projectVersion: 1, + namespace: "test_pack", + world: { + backend: "s3", + pushPolicy: "processed", + s3: { + bucket: "example-worlds", + region: "eu-west-2", + keyPrefix: "worlds", + }, + }, + }); + await mkdir(path.join(projectRoot, "worlds", "Bedrock level", "db"), { + recursive: true, + }); + + const events: string[] = []; + const originalSend = (S3Client.prototype as any).send; + (S3Client.prototype as any).send = async (command: any) => { + events.push(`${command.constructor.name}:${command.input.Key ?? ""}`); + switch (command.constructor.name) { + case "GetObjectCommand": + throw createS3Error("NoSuchKey", 404); + case "PutObjectCommand": + return isLockObjectKey(command.input.Key) + ? { ETag: '"etag-lock"' } + : { VersionId: "unexpected-world-version" }; + case "DeleteObjectCommand": + return {}; + case "GetBucketVersioningCommand": + return { Status: "Enabled" }; + case "HeadObjectCommand": + throw createS3Error("NotFound", 404); + default: + throw new Error( + `Unexpected command ${command.constructor.name}`, + ); + } + }; + t.after(() => { + (S3Client.prototype as any).send = originalSend; + }); + + const previous = process.cwd(); + process.chdir(projectRoot); + try { + await assert.rejects( + () => runWorldPushCommand(undefined, {}), + /processed push.*blr world build/i, + ); + } finally { + process.chdir(previous); + } + + const lockPut = events.findIndex((event) => + event.startsWith("PutObjectCommand:worlds/Bedrock level.lock.json"), + ); + assert.notEqual(lockPut, -1); + assert.equal( + events.some((event) => + event.includes("worlds/processed/Bedrock level.zip"), + ), + false, + ); +}); + test("pullWorldFromS3 rejects when bucket versioning is unavailable", async (t) => { const projectRoot = await createTempDirectory( t, diff --git a/packages/blr/test/world-build-command.test.ts b/packages/blr/test/world-build-command.test.ts new file mode 100644 index 0000000..d1e30d9 --- /dev/null +++ b/packages/blr/test/world-build-command.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { + createBedrockHeightmapDb, + createBedrockTerrainDb, +} from "./world-image-helpers.js"; +import { createTempDirectory, runBuiltCli, writeJsonFile } from "./helpers.js"; + +test("built cli world build bakes and checks the selected processed lineage", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-world-build-cli-"); + await createProject(projectRoot); + const sourceLevelName = path.join( + projectRoot, + "worlds", + "Bedrock level", + "levelname.txt", + ); + const sourceBefore = await readFile(sourceLevelName); + + const baked = runBuiltCli(["world", "build", "--json"], projectRoot); + assert.equal(baked.status, 0, baked.stderr); + const result = JSON.parse(baked.stdout) as { + status: string; + worldBuildId: string; + worldDirectory: string; + }; + assert.equal(result.status, "built"); + assert.match(result.worldBuildId, /^[a-f0-9]{64}$/); + assert.deepEqual(await readFile(sourceLevelName), sourceBefore); + + const checked = runBuiltCli( + ["world", "build", "--check", "--json"], + projectRoot, + ); + assert.equal(checked.status, 0, checked.stderr); + assert.equal(JSON.parse(checked.stdout).status, "current"); +}); + +test("built cli world build check is a read-only no-op without applicable processors", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-world-build-empty-"); + await mkdir(path.join(projectRoot, "behavior_packs", "fixture"), { + recursive: true, + }); + await writeJsonFile(path.join(projectRoot, "package.json"), { + name: "world-build-empty", + private: true, + }); + await writeJsonFile( + path.join(projectRoot, "behavior_packs", "fixture", "manifest.json"), + { + format_version: 2, + header: { + name: "Fixture", + description: "Fixture", + uuid: "11111111-1111-1111-1111-111111111111", + version: [0, 1, 0], + min_engine_version: [1, 26, 0], + }, + modules: [ + { + type: "data", + uuid: "22222222-2222-2222-2222-222222222222", + version: [0, 1, 0], + }, + ], + }, + ); + await writeJsonFile(path.join(projectRoot, "blr.config.json"), { + schemaVersion: 1, + projectVersion: 1, + namespace: "fixture", + }); + + const checked = runBuiltCli( + ["world", "build", "--check", "--json"], + projectRoot, + ); + assert.equal(checked.status, 0, checked.stderr); + assert.deepEqual(JSON.parse(checked.stdout), { + status: "current", + worldName: "Bedrock level", + processorIds: [], + diagnostics: [], + }); + await assert.rejects(readFile(path.join(projectRoot, ".blr"))); +}); + +async function createProject(projectRoot: string): Promise { + await mkdir(path.join(projectRoot, "behavior_packs", "fixture"), { + recursive: true, + }); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + const worldDirectory = path.join(projectRoot, "worlds", "Bedrock level"); + await mkdir(worldDirectory, { recursive: true }); + await writeJsonFile(path.join(projectRoot, "package.json"), { + name: "world-build-fixture", + private: true, + }); + await writeJsonFile( + path.join(projectRoot, "behavior_packs", "fixture", "manifest.json"), + { + format_version: 2, + header: { + name: "Fixture", + description: "Fixture", + uuid: "11111111-1111-1111-1111-111111111111", + version: [0, 1, 0], + min_engine_version: [1, 26, 0], + }, + modules: [ + { + type: "data", + uuid: "22222222-2222-2222-2222-222222222222", + version: [0, 1, 0], + }, + ], + }, + ); + await writeJsonFile(path.join(projectRoot, "blr.config.json"), { + schemaVersion: 1, + projectVersion: 1, + namespace: "fixture", + dev: { localServer: { worldName: "Bedrock level" } }, + worldProcessors: [ + { + id: "fixture", + module: "./tooling/processor.mjs", + export: "createProcessor", + sourceWorld: "Bedrock level", + capabilities: ["transform"], + applyOn: { worldBuild: true }, + }, + ], + }); + await writeFile( + path.join(projectRoot, "tooling", "processor.mjs"), + `export function createProcessor() { return { implementationRevision: "v1", logicalInputs: [], async run(input) { let block; for await (const value of input.observations.blocks({ dimension: "overworld", bounds: { min: {x:0,y:0,z:0}, max:{x:0,y:0,z:0} }, includeAir: true })) if (value.layer === 0) block = value; return { logicalInputs: input.logicalInputs, artifacts: [], diagnostics: [], mutations: [{ kind: "set-block", opId: "gold", dimension: "overworld", location: {x:0,y:0,z:0}, layer: 0, expectedPalette: block.palette, replacement: { kind: "literal", palette: { typeId: "minecraft:gold_block", states: {}, version: block.palette.version } }, blockEntityPolicy: "require-absent" }] }; } }; }\n`, + ); + await writeFile(path.join(worldDirectory, "levelname.txt"), "Fixture\n"); + const dbPath = path.join(worldDirectory, "db"); + await createBedrockHeightmapDb(dbPath, [ + { chunkX: 0, chunkZ: 0, baseHeight: 1 }, + ]); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: 0, + chunkZ: 0, + subChunkY: 0, + blockName: "minecraft:stone", + version: 18_488_832, + localX: 0, + localY: 0, + localZ: 0, + }, + ]); +} diff --git a/packages/blr/test/world-image-helpers.ts b/packages/blr/test/world-image-helpers.ts index 650586d..3a27b2d 100644 --- a/packages/blr/test/world-image-helpers.ts +++ b/packages/blr/test/world-image-helpers.ts @@ -120,6 +120,9 @@ export type TestSubchunkBlock = { localY: number; localZ: number; dimension?: BedrockDimension; + states?: Record; + version?: number; + layer?: number; }; export async function createBedrockTerrainDb( @@ -146,60 +149,25 @@ export async function createBedrockTerrainDb( for (const group of grouped.values()) { const first = group[0]!; - const paletteBlocks = [ - ...new Set(group.map((block) => block.blockName)), - ]; - const palette = Object.fromEntries([ - [ - "0", - { - type: "compound", - value: { - name: { - type: "string", - value: "minecraft:air", - }, - states: { type: "compound", value: {} }, - version: { type: "int", value: 0 }, - }, - }, - ], - ...paletteBlocks.map((blockName, index) => [ - String(index + 1), - { - type: "compound", - value: { - name: { type: "string", value: blockName }, - states: { type: "compound", value: {} }, - version: { type: "int", value: 0 }, - }, - }, - ]), - ]); - const paletteIndexByBlock = new Map( - paletteBlocks.map((blockName, index) => [blockName, index + 1]), - ); - const bitsPerBlock = Math.max( - 1, - Math.ceil(Math.log2(paletteBlocks.length + 1)), - ); - const blockIndices = Array.from({ length: 4096 }, () => 0); + const groupedLayers = new Map(); for (const block of group) { - blockIndices[ - createBedrockSubchunkBlockIndex( - block.localX, - block.localY, - block.localZ, - ) - ] = paletteIndexByBlock.get(block.blockName) ?? 0; + const layer = block.layer ?? 0; + groupedLayers.set(layer, [ + ...(groupedLayers.get(layer) ?? []), + block, + ]); } + const layerCount = Math.max(...groupedLayers.keys()) + 1; + const layers = Array.from({ length: layerCount }, (_value, layer) => + createTestSubchunkLayer(groupedLayers.get(layer) ?? []), + ); const raw = helpers.entryContentTypeToFormatMap.SubChunkPrefix.serialize({ type: "compound", value: { version: { type: "byte", value: 9 }, - layerCount: { type: "byte", value: 1 }, + layerCount: { type: "byte", value: layerCount }, subChunkIndex: { type: "byte", value: first.subChunkY, @@ -208,25 +176,7 @@ export async function createBedrockTerrainDb( type: "list", value: { type: "compound", - value: [ - { - storageVersion: { - type: "byte", - value: bitsPerBlock << 1, - }, - palette: { - type: "compound", - value: palette, - }, - block_indices: { - type: "list", - value: { - type: "int", - value: blockIndices, - }, - }, - }, - ], + value: layers, }, }, }, @@ -249,6 +199,84 @@ export async function createBedrockTerrainDb( } } +function createTestSubchunkLayer(blocks: TestSubchunkBlock[]) { + const paletteBlocks = [ + ...new Map( + blocks.map((block) => [paletteBlockKey(block), block]), + ).values(), + ]; + const palette = Object.fromEntries([ + ["0", paletteNbt("minecraft:air", {}, 0)], + ...paletteBlocks.map((block, index) => [ + String(index + 1), + paletteNbt(block.blockName, block.states ?? {}, block.version ?? 0), + ]), + ]); + const paletteIndexByBlock = new Map( + paletteBlocks.map((block, index) => [ + paletteBlockKey(block), + index + 1, + ]), + ); + const bitsPerBlock = Math.max( + 1, + Math.ceil(Math.log2(paletteBlocks.length + 1)), + ); + const blockIndices = Array.from({ length: 4096 }, () => 0); + for (const block of blocks) { + blockIndices[ + createBedrockSubchunkBlockIndex( + block.localX, + block.localY, + block.localZ, + ) + ] = paletteIndexByBlock.get(paletteBlockKey(block)) ?? 0; + } + return { + storageVersion: { type: "byte", value: bitsPerBlock << 1 }, + palette: { type: "compound", value: palette }, + block_indices: { + type: "list", + value: { type: "int", value: blockIndices }, + }, + }; +} + +function paletteBlockKey(block: TestSubchunkBlock): string { + return JSON.stringify([ + block.blockName, + block.states ?? {}, + block.version ?? 0, + ]); +} + +function paletteNbt( + blockName: string, + states: Record, + version: number, +) { + return { + type: "compound", + value: { + name: { type: "string", value: blockName }, + states: { + type: "compound", + value: Object.fromEntries( + Object.entries(states).map(([key, value]) => [ + key, + typeof value === "boolean" + ? { type: "byte", value: value ? 1 : 0 } + : typeof value === "number" + ? { type: "int", value } + : { type: "string", value }, + ]), + ), + }, + version: { type: "int", value: version }, + }, + }; +} + export function createBedrockSubchunkBlockIndex( localX: number, localY: number, diff --git a/packages/blr/test/world-internal-state.test.ts b/packages/blr/test/world-internal-state.test.ts index 186a862..455e45b 100644 --- a/packages/blr/test/world-internal-state.test.ts +++ b/packages/blr/test/world-internal-state.test.ts @@ -6,8 +6,10 @@ import { clearLocalServerSession, clearRuntimeWorldSeedState, markProjectWorldMaterializedFromRemote, + markProcessedWorldPublished, readActiveLocalServerSession, readMaterializedProjectWorldRemoteState, + readProcessedWorldPublicationState, readRuntimeWorldSeedState, writeLocalServerSession, writeRuntimeWorldSeedState, @@ -99,6 +101,37 @@ test("clearRuntimeWorldSeedState keeps the materialized project world pin intact ); }); +test("processed publication lineage is independent from authored materialization state", async (t) => { + const projectRoot = await createTempDirectory( + t, + "blr-world-state-processed-", + ); + await markProcessedWorldPublished(projectRoot, { + worldName: "Bedrock level", + remoteFingerprint: "sha256:processed-remote", + versionId: "ver-processed", + worldBuildId: "build-123", + publishedAt: "2026-08-11T12:00:00.000Z", + }); + + assert.deepEqual( + await readProcessedWorldPublicationState(projectRoot, "Bedrock level"), + { + remoteFingerprint: "sha256:processed-remote", + versionId: "ver-processed", + worldBuildId: "build-123", + publishedAt: "2026-08-11T12:00:00.000Z", + }, + ); + assert.equal( + await readMaterializedProjectWorldRemoteState( + projectRoot, + "Bedrock level", + ), + undefined, + ); +}); + test("local-server session state round-trips and clears cleanly", async (t) => { const projectRoot = await createTempDirectory(t, "blr-world-state-"); diff --git a/packages/blr/test/world-processing-api.test.ts b/packages/blr/test/world-processing-api.test.ts new file mode 100644 index 0000000..57d6d98 --- /dev/null +++ b/packages/blr/test/world-processing-api.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { + canonicalizeWorldDerivedJson, + defineWorldProcessor, + hashWorldDerivedJson, + type WorldProcessor, + type WorldProcessorFactory, + type WorldSignObservationQuery, + type WorldSignOrientation, +} from "../src/world-processing.js"; + +test("world processor SDK canonicalizes and hashes artifact values", () => { + const value = { z: -0, a: [3, { b: true, a: "first" }] }; + + assert.equal( + canonicalizeWorldDerivedJson(value), + '{\n "a": [\n 3,\n {\n "a": "first",\n "b": true\n }\n ],\n "z": 0\n}\n', + ); + assert.match(hashWorldDerivedJson(value), /^[0-9a-f]{64}$/); + assert.equal( + hashWorldDerivedJson(value), + hashWorldDerivedJson({ a: [3, { a: "first", b: true }], z: 0 }), + ); +}); + +test("defineWorldProcessor preserves the supported factory contract", () => { + const processor: WorldProcessor = { + implementationRevision: "locations-v1", + logicalInputs: [ + { + id: "location-schema", + kind: "value", + value: "1", + }, + ], + async run(input) { + assert.equal("pipeline" in input, false); + assert.equal("mode" in input, false); + return { + logicalInputs: input.logicalInputs, + artifacts: [], + diagnostics: [], + mutations: [], + }; + }, + }; + const factory: WorldProcessorFactory = defineWorldProcessor( + () => processor, + ); + + assert.equal(factory(), processor); +}); + +test("world sign orientation preserves the exact standing and wall domains", () => { + const orientations: readonly WorldSignOrientation[] = [ + { kind: "standing", groundSignDirection: 15, yawDegrees: -22.5 }, + { kind: "wall", facingDirection: 4, yawDegrees: 90 }, + { kind: "hanging", states: { attached: true } }, + ]; + + assert.deepEqual(orientations[0], { + kind: "standing", + groundSignDirection: 15, + yawDegrees: -22.5, + }); + assert.deepEqual(orientations[1], { + kind: "wall", + facingDirection: 4, + yawDegrees: 90, + }); +}); + +test("world sign queries can cover a whole dimension without a volume box", () => { + const wholeWorld: WorldSignObservationQuery = { dimension: "overworld" }; + const bounded: WorldSignObservationQuery = { + bounds: { + min: { x: -16, y: -64, z: -16 }, + max: { x: 16, y: 320, z: 16 }, + }, + }; + + assert.deepEqual(wholeWorld, { dimension: "overworld" }); + assert.equal(bounded.bounds?.max.y, 320); +}); + +test("package metadata publishes the world-processing runtime and declarations", async () => { + const packageJson = JSON.parse( + await readFile( + path.resolve(import.meta.dirname, "..", "package.json"), + "utf8", + ), + ) as { + exports?: Record; + }; + + assert.deepEqual(packageJson.exports?.["./world-processing"], { + types: "./dist/world-processing.d.ts", + import: "./dist/world-processing.js", + }); +}); diff --git a/packages/blr/test/world-processing-artifact-set.test.ts b/packages/blr/test/world-processing-artifact-set.test.ts new file mode 100644 index 0000000..e65637b --- /dev/null +++ b/packages/blr/test/world-processing-artifact-set.test.ts @@ -0,0 +1,305 @@ +import assert from "node:assert/strict"; +import { readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { build } from "esbuild"; +import type { ResolvedWorldProcessorConfig } from "../src/world-processing.js"; +import { + publishWorldProcessorArtifactSet, + type PublishWorldProcessorArtifactSetOptions, +} from "../src/world-processing/artifact-set.js"; +import { + canonicalJson, + hashCanonicalJson, +} from "../src/world-processing/canonical-json.js"; +import type { WorldProcessorExecutionResult } from "../src/world-processing/runner.js"; +import { createTempDirectory } from "./helpers.js"; + +test("canonicalJson uses finite lexically ordered LF JSON and exact lowercase SHA-256", () => { + assert.equal( + canonicalJson({ z: -0, a: { b: 2, a: 1 } }), + `{ + "a": { + "a": 1, + "b": 2 + }, + "z": 0 +}\n`, + ); + assert.equal( + hashCanonicalJson({ z: -0, a: { b: 2, a: 1 } }), + "efca8531df3b03b9474059758ded1f4d6bab28508e91390a4772a780f36be073", + ); + assert.throws(() => canonicalJson({ bad: Number.NaN }), /non-finite/i); + assert.throws(() => canonicalJson({ bad: undefined }), /undefined/i); +}); + +test("publishWorldProcessorArtifactSet writes immutable payloads, a detached manifest, and a Node-compatible pointer", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-artifact-set-"); + const options = createPublishOptions(projectRoot, 1); + const missing = await publishWorldProcessorArtifactSet({ + ...options, + mode: "check", + }); + assert.equal(missing.status, "stale"); + await assert.rejects( + () => readdir(path.join(projectRoot, "src/generated/train")), + (error: unknown) => (error as NodeJS.ErrnoException).code === "ENOENT", + ); + const published = await publishWorldProcessorArtifactSet(options); + assert.equal(published.status, "published"); + assert.match(published.artifactSetId, /^[a-f0-9]{64}$/); + assert.match(published.manifestContentHash, /^[a-f0-9]{64}$/); + + const setDirectory = path.join( + projectRoot, + "src/generated/train/sets", + published.artifactSetId, + ); + const manifest = JSON.parse( + await readFile(path.join(setDirectory, "manifest.json"), "utf8"), + ) as { members: Array<{ id: string; fileName: string }> }; + assert.deepEqual( + manifest.members.map((member) => member.id), + ["path-pack", "route-pack"], + ); + assert.equal( + manifest.members.some((member) => member.fileName === "manifest.json"), + false, + ); + const pointerPath = path.join( + projectRoot, + "src/generated/train/current.generated.ts", + ); + const pointer = await readFile(pointerPath, "utf8"); + assert.match(pointer, /with \{ type: "json" \}/); + assert.match(pointer, new RegExp(published.artifactSetId)); + await build({ + entryPoints: [pointerPath], + absWorkingDir: projectRoot, + bundle: true, + platform: "node", + format: "esm", + write: false, + logLevel: "silent", + }); + + const checked = await publishWorldProcessorArtifactSet({ + ...options, + mode: "check", + }); + assert.equal(checked.status, "current"); + assert.deepEqual( + ( + await readdir(path.join(projectRoot, "src/generated/train/sets")) + ).sort(), + [published.artifactSetId], + ); +}); + +test("publishWorldProcessorArtifactSet writes a canonical JSON runtime pointer outside source code", async (t) => { + const projectRoot = await createTempDirectory( + t, + "blr-artifact-json-pointer-", + ); + const base = createPublishOptions(projectRoot, 1); + const pointerPath = + "world-data/Bedrock level/generated/train/current.generated.json"; + const published = await publishWorldProcessorArtifactSet({ + ...base, + config: { + ...base.config, + outputRoot: "world-data/Bedrock level/generated/train", + runtimePointerPath: pointerPath, + }, + }); + + const pointerBytes = await readFile( + path.join(projectRoot, pointerPath), + "utf8", + ); + const pointer = JSON.parse(pointerBytes) as { + artifactSetId: string; + members: Record< + string, + { + contentHash: string; + fileName: string; + id: string; + value: unknown; + } + >; + }; + assert.equal(pointer.artifactSetId, published.artifactSetId); + assert.deepEqual(pointer.members["path-pack"]?.value, { + paths: [{ id: "main", points: [[0, 0, 1]] }], + schemaVersion: 1, + }); + assert.equal( + pointerBytes, + canonicalJson(pointer), + "the JSON pointer must use BLR's canonical JSON contract", + ); + + const checked = await publishWorldProcessorArtifactSet({ + ...base, + config: { + ...base.config, + outputRoot: "world-data/Bedrock level/generated/train", + runtimePointerPath: pointerPath, + }, + mode: "check", + }); + assert.equal(checked.status, "current"); +}); + +test("artifact publication rejects mismatched payloads and generic hash references", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-artifact-errors-"); + const base = createPublishOptions(projectRoot, 1); + await assert.rejects( + () => + publishWorldProcessorArtifactSet({ + ...base, + execution: { + ...base.execution, + result: { + ...base.execution.result, + artifacts: base.execution.result.artifacts.slice(0, 1), + }, + }, + }), + /must return exactly.*path-pack.*route-pack/i, + ); + const route = base.execution.result.artifacts[1]!; + await assert.rejects( + () => + publishWorldProcessorArtifactSet({ + ...base, + execution: { + ...base.execution, + result: { + ...base.execution.result, + artifacts: [ + base.execution.result.artifacts[0]!, + { + ...route, + value: { pathContentHash: "wrong" }, + }, + ], + }, + }, + }), + /hash reference.*pathContentHash.*does not match/i, + ); +}); + +test("failed or stale publication preserves the old coherent pointer and detects immutable-set corruption", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-artifact-atomic-"); + const firstOptions = createPublishOptions(projectRoot, 1); + const first = await publishWorldProcessorArtifactSet(firstOptions); + const pointerPath = path.join( + projectRoot, + "src/generated/train/current.generated.ts", + ); + const oldPointer = await readFile(pointerPath, "utf8"); + + await assert.rejects( + () => + publishWorldProcessorArtifactSet({ + ...createPublishOptions(projectRoot, 2), + hooks: { + beforePointerReplace: () => { + throw new Error("injected pointer failure"); + }, + }, + }), + /injected pointer failure/i, + ); + assert.equal(await readFile(pointerPath, "utf8"), oldPointer); + + const stale = await publishWorldProcessorArtifactSet({ + ...createPublishOptions(projectRoot, 3), + isCurrent: () => false, + }); + assert.equal(stale.status, "stale"); + assert.equal(await readFile(pointerPath, "utf8"), oldPointer); + + const firstPath = path.join( + projectRoot, + "src/generated/train/sets", + first.artifactSetId, + "path-pack.json", + ); + await writeFile(firstPath, "corrupt\n"); + await assert.rejects( + () => publishWorldProcessorArtifactSet(firstOptions), + /immutable artifact set.*does not match/i, + ); +}); + +function createPublishOptions( + projectRoot: string, + revision: number, +): PublishWorldProcessorArtifactSetOptions { + const pathValue = { + schemaVersion: 1, + paths: [{ id: "main", points: [[0, 0, revision]] }], + }; + const pathContentHash = hashCanonicalJson(pathValue); + const execution: WorldProcessorExecutionResult = { + processorId: "project-route", + providerRevision: "train-route-v1", + logicalInputHash: `logical-${revision}`, + cacheHit: false, + result: { + logicalInputs: [], + artifacts: [ + { id: "path-pack", value: pathValue }, + { + id: "route-pack", + value: { schemaVersion: 1, pathContentHash }, + hashReferences: [ + { + artifactId: "path-pack", + jsonPointer: "/pathContentHash", + }, + ], + }, + ], + diagnostics: [], + mutations: [], + }, + }; + return { + projectRoot, + config: processorConfig(), + execution, + mode: "bake", + }; +} + +function processorConfig(): ResolvedWorldProcessorConfig { + return { + id: "project-route", + module: "./tooling/route/provider.ts", + export: "createProvider", + sourceWorld: "Bedrock level", + capabilities: ["artifact"], + dependsOn: [], + inputPaths: [], + outputRoot: "src/generated/train", + payloadFileNames: { + "path-pack": "path-pack.json", + "route-pack": "route-pack.json", + }, + runtimePointerPath: "src/generated/train/current.generated.ts", + applyOn: { + dev: true, + build: true, + package: true, + check: true, + worldBuild: true, + worldPush: true, + }, + }; +} diff --git a/packages/blr/test/world-processing-build.test.ts b/packages/blr/test/world-processing-build.test.ts new file mode 100644 index 0000000..563cb6f --- /dev/null +++ b/packages/blr/test/world-processing-build.test.ts @@ -0,0 +1,382 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import type { ResolvedWorldProcessorConfig } from "../src/world-processing.js"; +import { buildProcessedWorld } from "../src/world-processing/processor-build.js"; +import { withBedrockWorldObservations } from "../src/world-processing/observation-facade.js"; +import { withVerifiedWorldSnapshot } from "../src/world-processing/world-snapshot.js"; +import { + createBedrockHeightmapDb, + createBedrockTerrainDb, +} from "./world-image-helpers.js"; +import { createTempDirectory } from "./helpers.js"; + +test("buildProcessedWorld publishes a content-addressed world and check is project-read-only", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-processed-world-"); + const worldName = "Bedrock level"; + const sourceWorldDirectory = path.join(projectRoot, "worlds", worldName); + await createFixtureWorld(sourceWorldDirectory); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + const providerPath = path.join(projectRoot, "tooling", "processor.ts"); + await writeFile(providerPath, processorSource("minecraft:gold_block")); + const config = processorConfig({ + auditOutputPath: "planning/fixture.audit.json", + }); + const sourceBefore = await inventory(sourceWorldDirectory); + + const built = await buildProcessedWorld({ + projectRoot, + worldName, + sourceWorldDirectory, + configs: [config], + mode: "bake", + audit: true, + signal: new AbortController().signal, + }); + assert.equal(built.status, "built"); + assert.match(built.worldBuildId, /^[a-f0-9]{64}$/); + assert.equal( + await stat(built.worldDirectory).then((item) => item.isDirectory()), + true, + ); + assert.deepEqual(await inventory(sourceWorldDirectory), sourceBefore); + assert.equal(await blockType(built.worldDirectory), "minecraft:gold_block"); + assert.equal( + JSON.parse(await readFile(built.pointerPath, "utf8")).worldBuildId, + built.worldBuildId, + ); + assert.equal( + JSON.parse( + await readFile( + path.join(built.setDirectory, "manifest.json"), + "utf8", + ), + ).mutationPlanHash, + built.mutationPlanHash, + ); + assert.deepEqual( + JSON.parse( + await readFile( + path.join(projectRoot, "planning", "fixture.audit.json"), + "utf8", + ), + ).audit, + { observedTypeId: "minecraft:stone" }, + ); + + const projectBeforeCheck = await inventory(projectRoot); + const checked = await buildProcessedWorld({ + projectRoot, + worldName, + sourceWorldDirectory, + configs: [config], + mode: "check", + signal: new AbortController().signal, + }); + assert.equal(checked.status, "current"); + assert.equal(checked.worldBuildId, built.worldBuildId); + assert.deepEqual(await inventory(projectRoot), projectBeforeCheck); + + await writeFile(providerPath, processorSource("minecraft:diamond_block")); + const stale = await buildProcessedWorld({ + projectRoot, + worldName, + sourceWorldDirectory, + configs: [config], + mode: "check", + signal: new AbortController().signal, + }); + assert.equal(stale.status, "stale"); + assert.deepEqual(await inventory(sourceWorldDirectory), sourceBefore); +}); + +test("buildProcessedWorld writes configured audits for transform-only processors", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-transform-audit-"); + const worldName = "Bedrock level"; + const sourceWorldDirectory = path.join(projectRoot, "worlds", worldName); + await createFixtureWorld(sourceWorldDirectory); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + await writeFile( + path.join(projectRoot, "tooling", "processor.ts"), + transformOnlySource(), + ); + const auditOutputPath = "planning/transform.audit.json"; + + await buildProcessedWorld({ + projectRoot, + worldName, + sourceWorldDirectory, + configs: [ + processorConfig({ + capabilities: ["transform"], + auditOutputPath, + }), + ], + mode: "bake", + audit: true, + signal: new AbortController().signal, + }); + + assert.deepEqual( + JSON.parse( + await readFile(path.join(projectRoot, auditOutputPath), "utf8"), + ).audit, + { kind: "transform-only" }, + ); +}); + +test("buildProcessedWorld narrows processors, copies an explicit output, and rejects unsafe or invalid builds", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-processed-options-"); + const worldName = "Bedrock level"; + const sourceWorldDirectory = path.join(projectRoot, "worlds", worldName); + await createFixtureWorld(sourceWorldDirectory); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + await writeFile( + path.join(projectRoot, "tooling", "processor.ts"), + processorSource("minecraft:gold_block"), + ); + await writeFile( + path.join(projectRoot, "tooling", "unused.ts"), + artifactOnlySource(), + ); + const explicitOutput = path.join(projectRoot, "dist", "processed-world"); + const selected = await buildProcessedWorld({ + projectRoot, + worldName, + sourceWorldDirectory, + configs: [ + processorConfig(), + processorConfig({ + id: "unused", + module: "./tooling/unused.ts", + capabilities: ["artifact"], + outputRoot: "src/generated/unused", + runtimePointerPath: "src/generated/unused/current.generated.ts", + }), + ], + processorIds: ["fixture"], + outputDirectory: explicitOutput, + mode: "bake", + signal: new AbortController().signal, + }); + assert.deepEqual(selected.processorIds, ["fixture"]); + assert.equal(await blockType(explicitOutput), "minecraft:gold_block"); + await assert.rejects( + () => + buildProcessedWorld({ + projectRoot, + worldName, + sourceWorldDirectory, + configs: [], + mode: "check", + signal: new AbortController().signal, + }), + /no world processors/i, + ); + await assert.rejects( + () => + buildProcessedWorld({ + projectRoot, + worldName, + sourceWorldDirectory, + configs: [processorConfig()], + processorIds: ["missing"], + mode: "check", + signal: new AbortController().signal, + }), + /unknown world processor missing/i, + ); + await assert.rejects( + () => + buildProcessedWorld({ + projectRoot, + worldName, + sourceWorldDirectory, + configs: [processorConfig()], + outputDirectory: sourceWorldDirectory, + mode: "bake", + signal: new AbortController().signal, + }), + /authored source/i, + ); +}); + +async function createFixtureWorld(worldDirectory: string): Promise { + await mkdir(worldDirectory, { recursive: true }); + await writeFile(path.join(worldDirectory, "levelname.txt"), "Fixture\n"); + const dbPath = path.join(worldDirectory, "db"); + await createBedrockHeightmapDb(dbPath, [ + { chunkX: 0, chunkZ: 0, baseHeight: 1 }, + ]); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: 0, + chunkZ: 0, + subChunkY: 0, + blockName: "minecraft:stone", + version: 18_488_832, + localX: 0, + localY: 0, + localZ: 0, + }, + ]); +} + +function processorConfig( + overrides: Partial = {}, +): ResolvedWorldProcessorConfig { + return { + id: "fixture", + module: "./tooling/processor.ts", + export: "createProcessor", + sourceWorld: "Bedrock level", + capabilities: ["artifact", "transform"], + dependsOn: [], + inputPaths: [], + outputRoot: "src/generated/fixture", + payloadFileNames: { result: "result.json" }, + runtimePointerPath: "src/generated/fixture/current.generated.ts", + applyOn: { + dev: true, + build: true, + package: true, + check: true, + worldBuild: true, + worldPush: true, + }, + ...overrides, + }; +} + +function processorSource(replacementTypeId: string): string { + return ` +export function createProcessor() { + return { + implementationRevision: "fixture-v1", + logicalInputs: [], + async run(input) { + let observed; + for await (const block of input.observations.blocks({ + dimension: "overworld", + bounds: { min: { x: 0, y: 0, z: 0 }, max: { x: 0, y: 0, z: 0 } }, + includeAir: true + })) { + if (block.layer === 0) observed = block; + } + if (!observed) throw new Error("fixture block missing"); + return { + logicalInputs: input.logicalInputs, + artifacts: [{ id: "result", value: { source: observed.palette.typeId } }], + audit: { observedTypeId: observed.palette.typeId }, + diagnostics: [], + mutations: [{ + kind: "set-block", + opId: "replace", + dimension: "overworld", + location: { x: 0, y: 0, z: 0 }, + layer: 0, + expectedPalette: observed.palette, + replacement: { + kind: "literal", + palette: { typeId: ${JSON.stringify(replacementTypeId)}, states: {}, version: observed.palette.version } + }, + blockEntityPolicy: "require-absent" + }] + }; + } + }; +} +`; +} + +function artifactOnlySource(): string { + return ` +export function createProcessor() { + return { + implementationRevision: "unused-v1", + logicalInputs: [], + async run(input) { + return { logicalInputs: input.logicalInputs, artifacts: [{ id: "result", value: true }], diagnostics: [], mutations: [] }; + } + }; +} +`; +} + +function transformOnlySource(): string { + return ` +export function createProcessor() { + return { + implementationRevision: "transform-v1", + logicalInputs: [], + async run(input) { + return { + logicalInputs: input.logicalInputs, + artifacts: [], + audit: { kind: "transform-only" }, + diagnostics: [], + mutations: [] + }; + } + }; +} +`; +} + +async function blockType(worldDirectory: string): Promise { + return withVerifiedWorldSnapshot( + { + worldName: "fixture", + sourceWorldDirectory: worldDirectory, + }, + async (snapshot) => { + let typeId: string | undefined; + await withBedrockWorldObservations( + { dbPath: snapshot.dbPath }, + async (observations) => { + for await (const observation of observations.blocks({ + dimension: "overworld", + bounds: { + min: { x: 0, y: 0, z: 0 }, + max: { x: 0, y: 0, z: 0 }, + }, + })) { + if (observation.layer === 0) + typeId = observation.palette.typeId; + } + }, + ); + return typeId; + }, + ); +} + +async function inventory(root: string): Promise { + const result: string[] = []; + await visit(""); + return result.sort(); + + async function visit(relativeDirectory: string): Promise { + for (const entry of await readdir(path.join(root, relativeDirectory), { + withFileTypes: true, + })) { + const relativePath = path + .join(relativeDirectory, entry.name) + .replace(/\\/g, "/"); + if (entry.isDirectory()) { + await visit(relativePath); + } else { + const hash = createHash("sha256"); + for await (const chunk of createReadStream( + path.join(root, relativePath), + )) { + hash.update(chunk); + } + result.push(`${relativePath}:${hash.digest("hex")}`); + } + } + } +} diff --git a/packages/blr/test/world-processing-cache.test.ts b/packages/blr/test/world-processing-cache.test.ts new file mode 100644 index 0000000..22d436b --- /dev/null +++ b/packages/blr/test/world-processing-cache.test.ts @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import type { + ResolvedWorldProcessorConfig, + WorldObservationFacade, +} from "../src/world-processing.js"; +import { runWorldProcessorGraph } from "../src/world-processing/runner.js"; +import { createTempDirectory } from "./helpers.js"; + +const emptyObservations: WorldObservationFacade = { + async *blocks() {}, + async *signs() {}, +}; + +test("runWorldProcessorGraph caches by complete logical identity and check mode stays read-only", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-runner-cache-"); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + await writeFile(path.join(projectRoot, "input.json"), `{"value":1}\n`); + await writeFile( + path.join(projectRoot, "tooling", "provider.ts"), + countingProviderSource(), + ); + const config = processorConfig({ + id: "counter", + inputPaths: ["input.json"], + }); + const cacheRoot = path.join(projectRoot, ".blr", "cache"); + const base = { + projectRoot, + configs: [config], + sourceIdentity: { + worldName: "Bedrock level", + contentHash: "world-one", + }, + observations: emptyObservations, + pipeline: "build" as const, + signal: new AbortController().signal, + cacheRoot, + }; + + const first = await runWorldProcessorGraph({ ...base, mode: "bake" }); + const [cacheFile] = await readdir( + path.join(cacheRoot, "world-processing", "counter"), + ); + assert.ok(cacheFile); + await writeFile( + path.join(cacheRoot, "world-processing", "counter", cacheFile), + "{corrupt", + ); + const recovered = await runWorldProcessorGraph({ ...base, mode: "bake" }); + const second = await runWorldProcessorGraph({ ...base, mode: "bake" }); + assert.equal(first[0]?.cacheHit, false); + assert.equal(recovered[0]?.cacheHit, false); + assert.equal(second[0]?.cacheHit, true); + assert.deepEqual(recovered[0]?.result, second[0]?.result); + assert.equal(first[0]?.result.artifacts[0]?.value, 1); + assert.equal(recovered[0]?.result.artifacts[0]?.value, 2); + + await writeFile(path.join(projectRoot, "input.json"), `{"value":2}\n`); + const changedInput = await runWorldProcessorGraph({ + ...base, + mode: "bake", + }); + assert.equal(changedInput[0]?.cacheHit, false); + assert.equal(changedInput[0]?.result.artifacts[0]?.value, 3); + + const checkRoot = path.join(projectRoot, "check-cache"); + const checked = await runWorldProcessorGraph({ + ...base, + sourceIdentity: { + worldName: "Bedrock level", + contentHash: "world-two", + }, + cacheRoot: checkRoot, + mode: "check", + }); + assert.equal(checked[0]?.cacheHit, false); + assert.equal(await directoryIsEmpty(checkRoot), true); +}); + +test("processor outputs and logical identity are independent of the invoking pipeline", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-runner-pipeline-"); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + await writeFile( + path.join(projectRoot, "tooling", "provider.ts"), + pipelineIndependentProviderSource(), + ); + const config = processorConfig({ id: "portable" }); + const base = { + projectRoot, + configs: [config], + sourceIdentity: { + worldName: "Bedrock level", + contentHash: "world", + }, + observations: emptyObservations, + mode: "bake" as const, + signal: new AbortController().signal, + cacheRoot: path.join(projectRoot, ".blr", "cache"), + }; + + const built = await runWorldProcessorGraph({ + ...base, + pipeline: "build", + }); + const worldBuilt = await runWorldProcessorGraph({ + ...base, + pipeline: "world-build", + }); + + assert.equal(worldBuilt[0]?.cacheHit, true); + assert.equal(worldBuilt[0]?.logicalInputHash, built[0]?.logicalInputHash); + assert.deepEqual(built[0]?.result.artifacts[0]?.value, [ + "config", + "dependencies", + "inputFiles", + "logicalInputs", + "observations", + "signal", + "sourceIdentity", + ]); +}); + +test("runWorldProcessorGraph orders dependencies canonically and supplies their results", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-runner-graph-"); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + await writeFile( + path.join(projectRoot, "tooling", "provider.ts"), + graphProviderSource(), + ); + const configs = [ + processorConfig({ id: "second", dependsOn: ["first"] }), + processorConfig({ id: "first" }), + ]; + const results = await runWorldProcessorGraph({ + projectRoot, + configs, + sourceIdentity: { + worldName: "Bedrock level", + contentHash: "world", + }, + observations: emptyObservations, + pipeline: "build", + mode: "check", + signal: new AbortController().signal, + cacheRoot: path.join(projectRoot, ".cache"), + }); + assert.deepEqual( + results.map((result) => result.processorId), + ["first", "second"], + ); + assert.deepEqual(results[1]?.result.artifacts[0]?.value, ["first"]); +}); + +test("runWorldProcessorGraph rejects dynamic logical inputs, cycles, corrupt cache, and cancellation", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-runner-errors-"); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + await writeFile( + path.join(projectRoot, "tooling", "provider.ts"), + invalidLogicalInputProviderSource(), + ); + const config = processorConfig({ id: "invalid" }); + const common = { + projectRoot, + sourceIdentity: { + worldName: "Bedrock level", + contentHash: "world", + }, + observations: emptyObservations, + pipeline: "build" as const, + mode: "bake" as const, + cacheRoot: path.join(projectRoot, ".cache"), + }; + await assert.rejects( + () => + runWorldProcessorGraph({ + ...common, + configs: [config], + signal: new AbortController().signal, + }), + /result logicalInputs must exactly echo/i, + ); + + await assert.rejects( + () => + runWorldProcessorGraph({ + ...common, + configs: [ + processorConfig({ id: "a", dependsOn: ["b"] }), + processorConfig({ id: "b", dependsOn: ["a"] }), + ], + signal: new AbortController().signal, + }), + /dependency cycle/i, + ); + + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + () => + runWorldProcessorGraph({ + ...common, + configs: [config], + signal: controller.signal, + }), + /aborted/i, + ); +}); + +function processorConfig( + overrides: Partial & { id: string }, +): ResolvedWorldProcessorConfig { + const { id, ...rest } = overrides; + return { + id, + module: "./tooling/provider.ts", + export: "createProcessor", + sourceWorld: "Bedrock level", + capabilities: ["artifact"], + dependsOn: [], + inputPaths: [], + outputRoot: "src/generated/test", + payloadFileNames: { result: "result.json" }, + runtimePointerPath: "src/generated/test/current.generated.ts", + applyOn: { + dev: true, + build: true, + package: true, + check: true, + worldBuild: true, + worldPush: true, + }, + ...rest, + }; +} + +function countingProviderSource(): string { + return ` +const state = globalThis; +export function createProcessor() { + return { + implementationRevision: "counter-v1", + logicalInputs: [{ id: "input", kind: "file", path: "input.json" }], + async run(input) { + state.__blrCounter = (state.__blrCounter ?? 0) + 1; + return { + logicalInputs: input.logicalInputs, + artifacts: [{ id: "result", value: state.__blrCounter }], + diagnostics: [], + mutations: [] + }; + } + }; +} +`; +} + +function graphProviderSource(): string { + return ` +export function createProcessor() { + return { + implementationRevision: "graph-v1", + logicalInputs: [], + async run(input) { + return { + logicalInputs: input.logicalInputs, + artifacts: [{ id: "result", value: Object.keys(input.dependencies) }], + diagnostics: [], + mutations: [] + }; + } + }; +} +`; +} + +function invalidLogicalInputProviderSource(): string { + return ` +export function createProcessor() { + return { + implementationRevision: "invalid-v1", + logicalInputs: [], + async run(input) { + return { + logicalInputs: [...input.logicalInputs, { id: "dynamic", kind: "value", contentHash: "bad" }], + artifacts: [], diagnostics: [], mutations: [] + }; + } + }; +} +`; +} + +function pipelineIndependentProviderSource(): string { + return ` +export function createProcessor() { + return { + implementationRevision: "portable-v1", + logicalInputs: [], + async run(input) { + return { + logicalInputs: input.logicalInputs, + artifacts: [{ id: "result", value: Object.keys(input).sort() }], + diagnostics: [], + mutations: [] + }; + } + }; +} +`; +} + +async function directoryIsEmpty(directory: string): Promise { + try { + return (await readdir(directory)).length === 0; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT"; + } +} diff --git a/packages/blr/test/world-processing-mutations.test.ts b/packages/blr/test/world-processing-mutations.test.ts new file mode 100644 index 0000000..bf8f76c --- /dev/null +++ b/packages/blr/test/world-processing-mutations.test.ts @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { + WorldBlockObservation, + WorldBlockPaletteEntry, + WorldMutation, + WorldObservationFacade, +} from "../src/world-processing.js"; +import { normalizeWorldMutationPlans } from "../src/world-processing/mutations.js"; + +const air = palette("minecraft:air"); +const signPalette = palette( + "minecraft:spruce_standing_sign", + { ground_sign_direction: 12 }, + 18_488_832, +); +const barrier = palette("minecraft:barrier", {}, 18_488_832); + +test("normalizeWorldMutationPlans resolves observed palettes and emits canonical primitive block writes", async () => { + const observations = facade([ + block({ x: 0, y: 80, z: 0 }, 0, signPalette), + block({ x: 1, y: 80, z: 0 }, 0, air), + ]); + const mutations: WorldMutation[] = [ + { + kind: "set-block", + opId: "replace-sign", + dimension: "overworld", + location: { x: 0, y: 80, z: 0 }, + layer: 0, + expectedPalette: signPalette, + replacement: { + kind: "observed", + dimension: "overworld", + location: { x: 1, y: 80, z: 0 }, + layer: 0, + }, + blockEntityPolicy: "remove", + }, + ]; + + const result = await normalizeWorldMutationPlans( + [{ processorId: "locations", mutations }], + observations, + ); + assert.deepEqual(result, [ + { + kind: "block-write", + dimension: "overworld", + location: { x: 0, y: 80, z: 0 }, + layer: 0, + expectedPalette: signPalette, + replacementPalette: air, + blockEntityPolicy: "remove", + claims: [{ processorId: "locations", opId: "replace-sign" }], + }, + ]); +}); + +test("consume-sign expands to an atomic layer-zero write and block-entity removal while leaving fluid layers unclaimed", async () => { + const sign = block({ x: -1, y: 80, z: 1 }, 0, signPalette, { + id: "Sign", + signFaces: [], + }); + const water = block( + { x: -1, y: 80, z: 1 }, + 1, + palette("minecraft:water", { liquid_depth: 0 }), + ); + const sourceAir = block({ x: 0, y: 80, z: 1 }, 0, air); + const result = await normalizeWorldMutationPlans( + [ + { + processorId: "locations", + mutations: [ + { + kind: "consume-sign", + opId: "consume-lamp-marker", + dimension: "overworld", + location: { x: -1, y: 80, z: 1 }, + expectedPalette: signPalette, + expectedBlockEntityId: "Sign", + replacement: { + kind: "observed", + dimension: "overworld", + location: sourceAir.location, + layer: 0, + }, + otherLayerPolicy: "preserve", + }, + ], + }, + ], + facade([sign, water, sourceAir]), + ); + assert.equal(result.length, 2); + assert.deepEqual( + result.map((entry) => [entry.kind, entry.layer ?? null]), + [ + ["block-write", 0], + ["block-entity-remove", null], + ], + ); + assert.equal( + result.some( + (entry) => entry.kind === "block-write" && entry.layer === 1, + ), + false, + ); +}); + +test("bounded replacement expands only exact matches in canonical coordinate order", async () => { + const stone = palette("minecraft:stone", {}, 18_488_832); + const observations = facade([ + block({ x: 1, y: 2, z: 1 }, 0, stone), + block({ x: -1, y: 2, z: 1 }, 0, stone), + block({ x: 0, y: 2, z: 1 }, 0, barrier), + ]); + const result = await normalizeWorldMutationPlans( + [ + { + processorId: "decor", + mutations: [ + { + kind: "replace-blocks", + opId: "stone-to-barrier", + dimension: "overworld", + bounds: { + min: { x: -1, y: 2, z: 1 }, + max: { x: 1, y: 2, z: 1 }, + }, + layer: 0, + matchPalette: stone, + replacement: { kind: "literal", palette: barrier }, + blockEntityPolicy: "require-absent", + }, + ], + }, + ], + observations, + ); + assert.deepEqual( + result.map((entry) => entry.location.x), + [-1, 1], + ); +}); + +test("normalization fails closed on stale expectations, absent subchunks, duplicate op ids, and conflicting claims", async () => { + const target = block({ x: 0, y: 0, z: 0 }, 0, air); + const stale: WorldMutation = { + kind: "set-block", + opId: "stale", + dimension: "overworld", + location: target.location, + layer: 0, + expectedPalette: barrier, + replacement: { kind: "literal", palette: air }, + blockEntityPolicy: "require-absent", + }; + await assert.rejects( + () => + normalizeWorldMutationPlans( + [{ processorId: "one", mutations: [stale] }], + facade([target]), + ), + /expected palette.*does not match/i, + ); + await assert.rejects( + () => + normalizeWorldMutationPlans( + [ + { + processorId: "one", + mutations: [ + { + ...stale, + expectedPalette: air, + location: { x: 20, y: 0, z: 20 }, + }, + ], + }, + ], + facade([target]), + ), + /existing subchunk.*20,0,20/i, + ); + await assert.rejects( + () => + normalizeWorldMutationPlans( + [ + { + processorId: "one", + mutations: [ + { ...stale, expectedPalette: air }, + { ...stale, expectedPalette: air }, + ], + }, + ], + facade([target]), + ), + /duplicate opId stale/i, + ); + + const one: WorldMutation = { + ...stale, + expectedPalette: air, + replacement: { kind: "literal", palette: barrier }, + }; + const two: WorldMutation = { + ...one, + opId: "other", + replacement: { + kind: "literal", + palette: palette("minecraft:gold_block", {}, 18_488_832), + }, + }; + await assert.rejects( + () => + normalizeWorldMutationPlans( + [ + { processorId: "one", mutations: [one] }, + { processorId: "two", mutations: [two] }, + ], + facade([target]), + ), + /conflicting block claims.*overworld.*0,0,0.*layer 0/i, + ); +}); + +test("byte-identical claims coalesce and retain every claimant", async () => { + const target = block({ x: 0, y: 0, z: 0 }, 0, air); + const mutation: WorldMutation = { + kind: "set-block", + opId: "place", + dimension: "overworld", + location: target.location, + layer: 0, + expectedPalette: air, + replacement: { kind: "literal", palette: barrier }, + blockEntityPolicy: "require-absent", + }; + const result = await normalizeWorldMutationPlans( + [ + { processorId: "a", mutations: [mutation] }, + { + processorId: "b", + mutations: [{ ...mutation, opId: "also-place" }], + }, + ], + facade([target]), + ); + assert.equal(result.length, 1); + assert.deepEqual(result[0]?.claims, [ + { processorId: "a", opId: "place" }, + { processorId: "b", opId: "also-place" }, + ]); +}); + +function palette( + typeId: string, + states: Record = {}, + version = 0, +): WorldBlockPaletteEntry { + return { typeId, states, version }; +} + +function block( + location: { x: number; y: number; z: number }, + layer: number, + blockPalette: WorldBlockPaletteEntry, + blockEntity?: { id: string; signFaces?: readonly [] }, +): WorldBlockObservation { + return { + dimension: "overworld", + location, + layer, + palette: blockPalette, + ...(blockEntity + ? { + blockEntity: { + id: blockEntity.id, + location, + value: {}, + ...(blockEntity.signFaces + ? { signFaces: blockEntity.signFaces } + : {}), + }, + } + : {}), + }; +} + +function facade( + entries: readonly WorldBlockObservation[], +): WorldObservationFacade { + return { + async *blocks(query) { + for (const entry of entries) { + if (entry.dimension !== query.dimension) continue; + if ( + entry.location.x < query.bounds.min.x || + entry.location.x > query.bounds.max.x || + entry.location.y < query.bounds.min.y || + entry.location.y > query.bounds.max.y || + entry.location.z < query.bounds.min.z || + entry.location.z > query.bounds.max.z + ) { + continue; + } + if ( + query.typeIds && + !query.typeIds.includes(entry.palette.typeId) + ) { + continue; + } + if ( + !query.includeAir && + entry.palette.typeId === "minecraft:air" + ) { + continue; + } + yield entry; + } + }, + async *signs() {}, + }; +} diff --git a/packages/blr/test/world-processing-observations.test.ts b/packages/blr/test/world-processing-observations.test.ts new file mode 100644 index 0000000..775e7bc --- /dev/null +++ b/packages/blr/test/world-processing-observations.test.ts @@ -0,0 +1,410 @@ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { LevelDB } from "@8crafter/leveldb-zlib"; +import { loadMcbeLeveldbHelpers } from "../src/mcbe-leveldb-adapter.js"; +import { withBedrockWorldObservations } from "../src/world-processing/observation-facade.js"; +import { + createBedrockTerrainDb, + type TestSubchunkBlock, +} from "./world-image-helpers.js"; +import { createTempDirectory } from "./helpers.js"; + +test("the bounded facade yields deeply frozen observations in dimension/x/y/z/layer order", async (t) => { + const root = await createTempDirectory(t, "blr-observations-"); + const dbPath = path.join(root, "db"); + const blocks: TestSubchunkBlock[] = [ + { + chunkX: -1, + chunkZ: 0, + subChunkY: 5, + blockName: "minecraft:spruce_standing_sign", + states: { ground_sign_direction: 12 }, + version: 18_488_832, + localX: 15, + localY: 0, + localZ: 1, + }, + { + chunkX: 0, + chunkZ: 0, + subChunkY: 5, + blockName: "minecraft:stone", + localX: 0, + localY: 1, + localZ: 0, + }, + ]; + await createBedrockTerrainDb(dbPath, blocks); + await addSignBlockEntity(dbPath, { + x: -1, + y: 80, + z: 1, + front: "~\n@object\nlamppost\n\n", + back: "id.back\n@custom\na\nb", + }); + + await withBedrockWorldObservations({ dbPath }, async (observations) => { + const result = await collect( + observations.blocks({ + dimension: "overworld", + bounds: { + min: { x: -1, y: 80, z: 0 }, + max: { x: 0, y: 81, z: 1 }, + }, + }), + ); + assert.deepEqual( + result.map((entry) => [ + entry.location.x, + entry.location.y, + entry.location.z, + entry.layer, + entry.palette.typeId, + ]), + [ + [-1, 80, 1, 0, "minecraft:spruce_standing_sign"], + [0, 81, 0, 0, "minecraft:stone"], + ], + ); + const sign = result[0]!; + assert.deepEqual(sign.signOrientation, { + kind: "standing", + groundSignDirection: 12, + yawDegrees: -90, + }); + assert.deepEqual(sign.blockEntity?.signFaces?.[0]?.lines, [ + "~", + "@object", + "lamppost", + "", + ]); + assert.ok(Object.isFrozen(sign)); + assert.ok(Object.isFrozen(sign.location)); + assert.ok(Object.isFrozen(sign.palette)); + assert.ok(Object.isFrozen(sign.palette.states)); + assert.ok(Object.isFrozen(sign.blockEntity)); + assert.ok(Object.isFrozen(sign.blockEntity?.signFaces)); + assert.ok(Object.isFrozen(sign.blockEntity?.signFaces?.[0]?.lines)); + }); +}); + +test("the bounded facade applies negative floor division, type filters, and exact query/run limits", async (t) => { + const root = await createTempDirectory(t, "blr-observation-limits-"); + const dbPath = path.join(root, "db"); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: -1, + chunkZ: -1, + subChunkY: -1, + blockName: "minecraft:gold_block", + localX: 15, + localY: 15, + localZ: 15, + }, + ]); + + await withBedrockWorldObservations( + { + dbPath, + limits: { maxPositionsPerQuery: 1, maxPositionsPerRun: 2 }, + }, + async (observations) => { + const first = await collect( + observations.blocks({ + dimension: "overworld", + bounds: { + min: { x: -1, y: -1, z: -1 }, + max: { x: -1, y: -1, z: -1 }, + }, + typeIds: ["minecraft:gold_block"], + }), + ); + assert.equal(first.length, 1); + assert.deepEqual(first[0]?.location, { x: -1, y: -1, z: -1 }); + + const filtered = await collect( + observations.blocks({ + dimension: "overworld", + bounds: { + min: { x: -1, y: -1, z: -1 }, + max: { x: -1, y: -1, z: -1 }, + }, + typeIds: ["minecraft:diamond_block"], + }), + ); + assert.deepEqual(filtered, []); + + await assert.rejects( + async () => + collect( + observations.blocks({ + dimension: "overworld", + bounds: { + min: { x: -1, y: -1, z: -1 }, + max: { x: 0, y: -1, z: -1 }, + }, + }), + ), + /query covers 2 positions.*limit is 1/i, + ); + await assert.rejects( + async () => + collect( + observations.blocks({ + dimension: "overworld", + bounds: { + min: { x: -1, y: -1, z: -1 }, + max: { x: -1, y: -1, z: -1 }, + }, + }), + ), + /run would cover 3 positions.*limit is 2/i, + ); + }, + ); +}); + +test("the sign facade streams all coherent signs without charging world-volume limits", async (t) => { + const root = await createTempDirectory(t, "blr-sign-observations-"); + const dbPath = path.join(root, "db"); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: -1, + chunkZ: 0, + subChunkY: 5, + blockName: "minecraft:spruce_standing_sign", + states: { ground_sign_direction: 12 }, + version: 18_488_832, + localX: 15, + localY: 0, + localZ: 1, + }, + { + chunkX: 62_500, + chunkZ: -62_500, + subChunkY: 4, + blockName: "minecraft:spruce_wall_sign", + states: { facing_direction: 5 }, + version: 18_488_832, + localX: 0, + localY: 0, + localZ: 0, + }, + ]); + await addSignBlockEntity(dbPath, { + x: -1, + y: 80, + z: 1, + front: "near\n@name\nnear\n", + back: "\n\n\n", + }); + await addSignBlockEntity(dbPath, { + x: 1_000_000, + y: 64, + z: -1_000_000, + front: "far\n@name\nfar\n", + back: "\n\n\n", + }); + + await withBedrockWorldObservations( + { + dbPath, + limits: { + maxPositionsPerQuery: 1, + maxPositionsPerRun: 1, + maxSignsPerQuery: 2, + maxSignsPerRun: 3, + }, + }, + async (observations) => { + const all = await collect(observations.signs()); + assert.deepEqual( + all.map((entry) => [ + entry.location.x, + entry.location.y, + entry.location.z, + entry.palette.typeId, + entry.signOrientation, + ]), + [ + [ + -1, + 80, + 1, + "minecraft:spruce_standing_sign", + { + kind: "standing", + groundSignDirection: 12, + yawDegrees: -90, + }, + ], + [ + 1_000_000, + 64, + -1_000_000, + "minecraft:spruce_wall_sign", + { + kind: "wall", + facingDirection: 5, + yawDegrees: -90, + }, + ], + ], + ); + assert.ok(Object.isFrozen(all[0])); + assert.equal(all[0]?.blockEntity.id, "Sign"); + + const bounded = await collect( + observations.signs({ + dimension: "overworld", + bounds: { + min: { x: 999_999, y: 0, z: -1_000_001 }, + max: { x: 1_000_001, y: 100, z: -999_999 }, + }, + }), + ); + assert.deepEqual( + bounded.map((entry) => entry.location), + [{ x: 1_000_000, y: 64, z: -1_000_000 }], + ); + }, + ); +}); + +test("the sign facade enforces independent result limits", async (t) => { + const root = await createTempDirectory(t, "blr-sign-observation-limits-"); + const dbPath = path.join(root, "db"); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: 0, + chunkZ: 0, + subChunkY: 4, + blockName: "minecraft:standing_sign", + states: { ground_sign_direction: 0 }, + localX: 0, + localY: 0, + localZ: 0, + }, + { + chunkX: 1, + chunkZ: 0, + subChunkY: 4, + blockName: "minecraft:standing_sign", + states: { ground_sign_direction: 0 }, + localX: 0, + localY: 0, + localZ: 0, + }, + ]); + await addSignBlockEntity(dbPath, { + x: 0, + y: 64, + z: 0, + front: "one\n@x\n\n", + back: "\n\n\n", + }); + await addSignBlockEntity(dbPath, { + x: 16, + y: 64, + z: 0, + front: "two\n@x\n\n", + back: "\n\n\n", + }); + + await withBedrockWorldObservations( + { dbPath, limits: { maxSignsPerQuery: 1, maxSignsPerRun: 1 } }, + async (observations) => { + await assert.rejects( + async () => collect(observations.signs()), + /sign query found 2 signs.*limit is 1/i, + ); + const one = await collect( + observations.signs({ + bounds: { + min: { x: 0, y: 64, z: 0 }, + max: { x: 0, y: 64, z: 0 }, + }, + }), + ); + assert.equal(one.length, 1); + await assert.rejects( + async () => + collect( + observations.signs({ + bounds: { + min: { x: 16, y: 64, z: 0 }, + max: { x: 16, y: 64, z: 0 }, + }, + }), + ), + /sign run would return 2 signs.*limit is 1/i, + ); + }, + ); +}); + +async function addSignBlockEntity( + dbPath: string, + input: { x: number; y: number; z: number; front: string; back: string }, +): Promise { + await mkdir(dbPath, { recursive: true }); + const helpers = await loadMcbeLeveldbHelpers(); + const db = new LevelDB(dbPath, { createIfMissing: false }); + const chunkX = Math.floor(input.x / 16); + const chunkZ = Math.floor(input.z / 16); + await db.open(); + try { + await db.put( + helpers.generateChunkKeyFromIndices( + { x: chunkX, z: chunkZ, dimension: "overworld" }, + "BlockEntity", + ), + helpers.entryContentTypeToFormatMap.BlockEntity.serialize({ + type: "compound", + value: { + blockEntities: { + type: "list", + value: { + type: "compound", + value: [ + { + id: { type: "string", value: "Sign" }, + x: { type: "int", value: input.x }, + y: { type: "int", value: input.y }, + z: { type: "int", value: input.z }, + FrontText: signText(input.front), + BackText: signText(input.back), + IsWaxed: { type: "byte", value: 0 }, + }, + ], + }, + }, + }, + }), + ); + } finally { + await db.close(); + } +} + +function signText(text: string): { + type: "compound"; + value: Record; +} { + return { + type: "compound", + value: { + Text: { type: "string", value: text }, + IgnoreLighting: { type: "byte", value: 0 }, + SignTextColor: { type: "int", value: -16_777_216 }, + }, + }; +} + +async function collect(input: AsyncIterable): Promise { + const result: T[] = []; + for await (const value of input) result.push(value); + return result; +} diff --git a/packages/blr/test/world-processing-package.test.ts b/packages/blr/test/world-processing-package.test.ts new file mode 100644 index 0000000..7a16ee8 --- /dev/null +++ b/packages/blr/test/world-processing-package.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { runPackageCommand } from "../src/commands/package.js"; +import { withBedrockWorldObservations } from "../src/world-processing/observation-facade.js"; +import { withVerifiedWorldSnapshot } from "../src/world-processing/world-snapshot.js"; +import { + createBedrockHeightmapDb, + createBedrockTerrainDb, +} from "./world-image-helpers.js"; +import { createTempDirectory, writeJsonFile } from "./helpers.js"; + +test("package world targets consume one processed world while preserving the authored source", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-package-processed-"); + await createProject(projectRoot); + const source = path.join(projectRoot, "worlds", "Bedrock level"); + const before = await inventory(source); + const previous = process.cwd(); + process.chdir(projectRoot); + try { + await runPackageCommand("world", { worldFormat: "zip" }); + } finally { + process.chdir(previous); + } + const workspace = path.join(projectRoot, "dist", "packages", "world"); + assert.equal(await blockType(workspace), "minecraft:diamond_block"); + assert.deepEqual(await inventory(source), before); +}); + +test("package targets without a world do not execute transform-only processors", async (t) => { + const projectRoot = await createTempDirectory( + t, + "blr-package-no-world-processing-", + ); + await createProject(projectRoot); + await writeFile( + path.join(projectRoot, "tooling", "processor.mjs"), + `export function createProcessor() { return { implementationRevision: "v1", logicalInputs: [], async run() { throw new Error("transform-only processor must not run"); } }; }\n`, + ); + const previous = process.cwd(); + process.chdir(projectRoot); + try { + await runPackageCommand("behavior-pack", {}); + } finally { + process.chdir(previous); + } +}); + +async function createProject(projectRoot: string): Promise { + const behaviorRoot = path.join(projectRoot, "behavior_packs", "game"); + const worldRoot = path.join(projectRoot, "worlds", "Bedrock level"); + await mkdir(behaviorRoot, { recursive: true }); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + await mkdir(worldRoot, { recursive: true }); + await writeJsonFile(path.join(projectRoot, "package.json"), { + name: "processed-package", + private: true, + }); + await writeJsonFile(path.join(behaviorRoot, "manifest.json"), { + format_version: 2, + header: { + name: "Game", + description: "Game", + uuid: "11111111-1111-1111-1111-111111111111", + version: [0, 1, 0], + min_engine_version: [1, 26, 0], + }, + modules: [ + { + type: "data", + uuid: "22222222-2222-2222-2222-222222222222", + version: [0, 1, 0], + }, + ], + }); + await writeJsonFile(path.join(projectRoot, "blr.config.json"), { + schemaVersion: 1, + projectVersion: 1, + namespace: "fixture", + dev: { localServer: { worldName: "Bedrock level" } }, + worldProcessors: [ + { + id: "package-transform", + module: "./tooling/processor.mjs", + export: "createProcessor", + sourceWorld: "Bedrock level", + capabilities: ["transform"], + applyOn: { package: true }, + }, + ], + }); + await writeFile( + path.join(projectRoot, "tooling", "processor.mjs"), + `export function createProcessor() { return { implementationRevision: "v1", logicalInputs: [], async run(input) { let block; for await (const value of input.observations.blocks({ dimension: "overworld", bounds: { min: {x:0,y:0,z:0}, max:{x:0,y:0,z:0} }, includeAir:true })) if (value.layer === 0) block = value; return { logicalInputs: input.logicalInputs, artifacts: [], diagnostics: [], mutations: [{ kind:"set-block", opId:"diamond", dimension:"overworld", location:{x:0,y:0,z:0}, layer:0, expectedPalette:block.palette, replacement:{kind:"literal", palette:{typeId:"minecraft:diamond_block",states:{},version:block.palette.version}}, blockEntityPolicy:"require-absent" }] }; } }; }\n`, + ); + await writeFile(path.join(worldRoot, "levelname.txt"), "Fixture\n"); + const dbPath = path.join(worldRoot, "db"); + await createBedrockHeightmapDb(dbPath, [ + { chunkX: 0, chunkZ: 0, baseHeight: 1 }, + ]); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: 0, + chunkZ: 0, + subChunkY: 0, + blockName: "minecraft:stone", + localX: 0, + localY: 0, + localZ: 0, + }, + ]); +} + +async function blockType(worldDirectory: string): Promise { + return withVerifiedWorldSnapshot( + { worldName: "fixture", sourceWorldDirectory: worldDirectory }, + async (snapshot) => { + let result: string | undefined; + await withBedrockWorldObservations( + { dbPath: snapshot.dbPath }, + async (observations) => { + for await (const observation of observations.blocks({ + dimension: "overworld", + bounds: { + min: { x: 0, y: 0, z: 0 }, + max: { x: 0, y: 0, z: 0 }, + }, + })) { + if (observation.layer === 0) + result = observation.palette.typeId; + } + }, + ); + return result; + }, + ); +} + +async function inventory(root: string): Promise { + const result: string[] = []; + await visit(""); + return result.sort(); + async function visit(relative: string): Promise { + for (const entry of await readdir(path.join(root, relative), { + withFileTypes: true, + })) { + const child = path.join(relative, entry.name); + if (entry.isDirectory()) { + await visit(child); + } else { + const hash = createHash("sha256"); + for await (const chunk of createReadStream( + path.join(root, child), + )) + hash.update(chunk); + result.push( + `${child.replace(/\\/g, "/")}:${hash.digest("hex")}`, + ); + } + } + } +} diff --git a/packages/blr/test/world-processing-provider.test.ts b/packages/blr/test/world-processing-provider.test.ts new file mode 100644 index 0000000..551b5a6 --- /dev/null +++ b/packages/blr/test/world-processing-provider.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { + loadWorldProcessorProvider, + type LoadedWorldProcessorProvider, +} from "../src/world-processing/provider-loader.js"; +import { createTempDirectory } from "./helpers.js"; + +test("loadWorldProcessorProvider bundles trusted project TypeScript and its local graph", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-provider-"); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + await writeFile( + path.join(projectRoot, "tooling", "value.ts"), + `export const value = "first";\n`, + ); + await writeFile( + path.join(projectRoot, "tooling", "provider.ts"), + providerSource("createProvider"), + ); + + const first = await load(projectRoot, "createProvider"); + assert.equal(first.processor.implementationRevision, "provider-v1"); + assert.deepEqual(first.processor.logicalInputs, [ + { id: "solver", kind: "value", value: "solver-v1" }, + ]); + assert.match(first.bundleHash, /^[a-f0-9]{64}$/); + assert.match(first.providerContentHash, /^[a-f0-9]{64}$/); + assert.ok( + first.localInputs.some((entry) => + entry.relativePath.endsWith("tooling/provider.ts"), + ), + ); + assert.ok( + first.localInputs.some((entry) => + entry.relativePath.endsWith("tooling/value.ts"), + ), + ); + + await writeFile( + path.join(projectRoot, "tooling", "value.ts"), + `export const value = "second";\n`, + ); + const second = await load(projectRoot, "createProvider"); + assert.notEqual(second.bundleHash, first.bundleHash); + assert.notEqual(second.providerContentHash, first.providerContentHash); +}); + +test("loadWorldProcessorProvider rejects missing exports and invalid contracts", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-provider-errors-"); + await writeFile( + path.join(projectRoot, "provider.ts"), + `export const notAFactory = 1;\n`, + ); + await assert.rejects( + () => load(projectRoot, "missing"), + /does not export.*missing/i, + ); + await assert.rejects( + () => load(projectRoot, "notAFactory"), + /must be a function/i, + ); + + await writeFile( + path.join(projectRoot, "provider.ts"), + `export function invalid() { return { implementationRevision: "", logicalInputs: [], run: 1 }; }\n`, + ); + await assert.rejects( + () => load(projectRoot, "invalid"), + /implementationRevision.*non-empty/i, + ); +}); + +async function load( + projectRoot: string, + exportName: string, +): Promise { + return loadWorldProcessorProvider({ + projectRoot, + module: "./tooling/provider.ts".replace( + "tooling/provider.ts", + (await fileExists(path.join(projectRoot, "tooling", "provider.ts"))) + ? "tooling/provider.ts" + : "provider.ts", + ), + exportName, + }); +} + +async function fileExists(filePath: string): Promise { + try { + await import("node:fs/promises").then(({ stat }) => stat(filePath)); + return true; + } catch { + return false; + } +} + +function providerSource(exportName: string): string { + return ` +import { value } from "./value.js"; +export function ${exportName}() { + return { + implementationRevision: "provider-v1", + logicalInputs: [{ id: "solver", kind: "value", value: "solver-v1" }], + async run(input) { + return { + logicalInputs: input.logicalInputs, + artifacts: [{ id: "value", value }], + diagnostics: [], + mutations: [] + }; + } + }; +} +`; +} diff --git a/packages/blr/test/world-processing-runtime.test.ts b/packages/blr/test/world-processing-runtime.test.ts new file mode 100644 index 0000000..34f9205 --- /dev/null +++ b/packages/blr/test/world-processing-runtime.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { loadBlurConfig } from "../src/config.js"; +import { buildProject } from "../src/runtime.js"; +import { + createBedrockHeightmapDb, + createBedrockTerrainDb, +} from "./world-image-helpers.js"; +import { createTempDirectory, writeJsonFile } from "./helpers.js"; + +test("buildProject publishes world-derived artifacts before staging pack content", async (t) => { + const projectRoot = await createTempDirectory(t, "blr-runtime-processors-"); + await createProject(projectRoot); + const { config } = await loadBlurConfig(projectRoot); + const artifacts = await buildProject(projectRoot, config, { + production: true, + pipeline: "build", + }); + assert.ok(artifacts.stageBehaviorPackDirectory); + const resultPath = await findFile( + path.join(artifacts.stageBehaviorPackDirectory, "generated", "sets"), + "result.json", + ); + assert.deepEqual(JSON.parse(await readFile(resultPath, "utf8")), { + bakedBeforeStage: true, + }); +}); + +async function createProject(projectRoot: string): Promise { + const behaviorRoot = path.join(projectRoot, "behavior_packs", "game"); + const worldRoot = path.join(projectRoot, "worlds", "Bedrock level"); + await mkdir(behaviorRoot, { recursive: true }); + await mkdir(path.join(projectRoot, "tooling"), { recursive: true }); + await mkdir(worldRoot, { recursive: true }); + await writeJsonFile(path.join(projectRoot, "package.json"), { + name: "processor-runtime-fixture", + private: true, + }); + await writeJsonFile(path.join(behaviorRoot, "manifest.json"), { + format_version: 2, + header: { + name: "Game", + description: "Game", + uuid: "11111111-1111-1111-1111-111111111111", + version: [0, 1, 0], + min_engine_version: [1, 26, 0], + }, + modules: [ + { + type: "data", + uuid: "22222222-2222-2222-2222-222222222222", + version: [0, 1, 0], + }, + ], + }); + await writeJsonFile(path.join(projectRoot, "blr.config.json"), { + schemaVersion: 1, + projectVersion: 1, + namespace: "fixture", + dev: { localServer: { worldName: "Bedrock level" } }, + worldProcessors: [ + { + id: "pack-artifact", + module: "./tooling/processor.mjs", + export: "createProcessor", + sourceWorld: "Bedrock level", + capabilities: ["artifact"], + outputRoot: "behavior_packs/game/generated", + payloadFileNames: { result: "result.json" }, + applyOn: { build: true }, + }, + ], + }); + await writeFile( + path.join(projectRoot, "tooling", "processor.mjs"), + `export function createProcessor() { return { implementationRevision: "v1", logicalInputs: [], async run(input) { return { logicalInputs: input.logicalInputs, artifacts: [{ id: "result", value: { bakedBeforeStage: true } }], diagnostics: [], mutations: [] }; } }; }\n`, + ); + await writeFile(path.join(worldRoot, "levelname.txt"), "Fixture\n"); + const dbPath = path.join(worldRoot, "db"); + await createBedrockHeightmapDb(dbPath, [ + { chunkX: 0, chunkZ: 0, baseHeight: 1 }, + ]); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: 0, + chunkZ: 0, + subChunkY: 0, + blockName: "minecraft:stone", + localX: 0, + localY: 0, + localZ: 0, + }, + ]); +} + +async function findFile(root: string, name: string): Promise { + for (const entry of await readdir(root, { withFileTypes: true })) { + const target = path.join(root, entry.name); + if (entry.isDirectory()) { + const nested = await findFile(target, name).catch(() => undefined); + if (nested) return nested; + } else if (entry.name === name) { + return target; + } + } + throw new Error(`Could not find ${name} under ${root}.`); +} diff --git a/packages/blr/test/world-processing-snapshot.test.ts b/packages/blr/test/world-processing-snapshot.test.ts new file mode 100644 index 0000000..a110e01 --- /dev/null +++ b/packages/blr/test/world-processing-snapshot.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { + mkdir, + readFile, + readdir, + symlink, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { withVerifiedWorldSnapshot } from "../src/world-processing/world-snapshot.js"; +import { createTempDirectory } from "./helpers.js"; + +test("withVerifiedWorldSnapshot copies and verifies an immutable source without exposing it as the DB input", async (t) => { + const root = await createTempDirectory(t, "blr-world-snapshot-"); + const source = await createWorldSource(root); + const tempRoot = path.join(root, "snapshots"); + const before = await readFile(path.join(source, "db", "000001.ldb")); + + const identity = await withVerifiedWorldSnapshot( + { + worldName: "Bedrock level", + sourceWorldDirectory: source, + tempRoot, + sourceRevision: "sha256:tracked", + sourceVersion: "version-1", + }, + async (snapshot) => { + assert.notEqual(snapshot.worldDirectory, source); + assert.ok(snapshot.worldDirectory.startsWith(tempRoot)); + assert.equal( + await readFile( + path.join(snapshot.dbPath, "000001.ldb"), + "utf8", + ), + "database bytes", + ); + assert.equal(snapshot.sourceIdentity.worldName, "Bedrock level"); + assert.match(snapshot.sourceIdentity.contentHash, /^[a-f0-9]{64}$/); + assert.equal( + snapshot.sourceIdentity.sourceRevision, + "sha256:tracked", + ); + assert.equal(snapshot.sourceIdentity.sourceVersion, "version-1"); + return snapshot.sourceIdentity; + }, + ); + + assert.equal(identity.worldName, "Bedrock level"); + assert.deepEqual( + await readFile(path.join(source, "db", "000001.ldb")), + before, + ); + assert.deepEqual(await readdir(tempRoot), []); +}); + +test("withVerifiedWorldSnapshot detects same-byte source rewrites through mtime quiescence and cleans up", async (t) => { + const root = await createTempDirectory(t, "blr-world-snapshot-race-"); + const source = await createWorldSource(root); + const tempRoot = path.join(root, "snapshots"); + const sourceFile = path.join(source, "levelname.txt"); + + await assert.rejects( + () => + withVerifiedWorldSnapshot( + { + worldName: "Bedrock level", + sourceWorldDirectory: source, + tempRoot, + hooks: { + afterCopy: async () => { + const data = await readFile(sourceFile); + await writeFile(sourceFile, data); + const later = new Date(Date.now() + 5_000); + await utimes(sourceFile, later, later); + }, + }, + }, + async () => undefined, + ), + /changed while the verified snapshot was being created/i, + ); + assert.deepEqual(await readdir(tempRoot), []); +}); + +test("withVerifiedWorldSnapshot rejects symlinks and cleans up after operation failures or cancellation", async (t) => { + const root = await createTempDirectory(t, "blr-world-snapshot-errors-"); + const source = await createWorldSource(root); + const tempRoot = path.join(root, "snapshots"); + const external = path.join(root, "external.txt"); + await writeFile(external, "outside"); + try { + await symlink(external, path.join(source, "linked.txt"), "file"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") { + t.skip("This Windows environment cannot create test symlinks."); + return; + } + throw error; + } + await assert.rejects( + () => + withVerifiedWorldSnapshot( + { + worldName: "Bedrock level", + sourceWorldDirectory: source, + tempRoot, + }, + async () => undefined, + ), + /symbolic link/i, + ); + assert.deepEqual(await readdir(tempRoot), []); + + await unlink(path.join(source, "linked.txt")); + await writeFile(path.join(source, "linked.txt"), "regular"); + await assert.rejects( + () => + withVerifiedWorldSnapshot( + { + worldName: "Bedrock level", + sourceWorldDirectory: source, + tempRoot, + }, + async () => { + throw new Error("operation failed"); + }, + ), + /operation failed/i, + ); + assert.deepEqual(await readdir(tempRoot), []); + + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + () => + withVerifiedWorldSnapshot( + { + worldName: "Bedrock level", + sourceWorldDirectory: source, + tempRoot, + signal: controller.signal, + }, + async () => undefined, + ), + /aborted/i, + ); + assert.deepEqual(await readdir(tempRoot), []); +}); + +async function createWorldSource(root: string): Promise { + const source = path.join(root, "worlds", "Bedrock level"); + await mkdir(path.join(source, "db"), { recursive: true }); + await writeFile(path.join(source, "db", "CURRENT"), "MANIFEST-000001\n"); + await writeFile(path.join(source, "db", "000001.ldb"), "database bytes"); + await writeFile(path.join(source, "levelname.txt"), "Bedrock level\n"); + return source; +} diff --git a/packages/blr/test/world-processing-writer.test.ts b/packages/blr/test/world-processing-writer.test.ts new file mode 100644 index 0000000..c84b563 --- /dev/null +++ b/packages/blr/test/world-processing-writer.test.ts @@ -0,0 +1,403 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { LevelDB } from "@8crafter/leveldb-zlib"; +import { loadMcbeLeveldbHelpers } from "../src/mcbe-leveldb-adapter.js"; +import { withBedrockWorldObservations } from "../src/world-processing/observation-facade.js"; +import { decodeBedrockSubchunk } from "../src/world-processing/bedrock-subchunk.js"; +import type { PrimitiveWorldMutation } from "../src/world-processing/mutations.js"; +import type { WorldBlockObservation } from "../src/world-processing.js"; +import { applyBedrockWorldMutations } from "../src/world-processing/writer.js"; +import { + createBedrockHeightmapDb, + createBedrockTerrainDb, +} from "./world-image-helpers.js"; +import { createTempDirectory } from "./helpers.js"; + +const version = 18_488_832; +const existingAir = { + typeId: "minecraft:air", + states: {}, + version: 0, +} as const; +const air = { typeId: "minecraft:air", states: {}, version } as const; +const sign = { + typeId: "minecraft:spruce_standing_sign", + states: { ground_sign_direction: 12 }, + version, +} as const; +const water = { + typeId: "minecraft:water", + states: { liquid_depth: 0 }, + version, +} as const; + +test("applyBedrockWorldMutations atomically consumes a waterlogged sign, preserves unrelated data, and fixes height", async (t) => { + const root = await createTempDirectory(t, "blr-writer-sign-"); + const dbPath = path.join(root, "db"); + await createBedrockHeightmapDb(dbPath, [ + { chunkX: 0, chunkZ: 0, baseHeight: 1 }, + ]); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: 0, + chunkZ: 0, + subChunkY: -1, + blockName: "minecraft:dirt", + version, + localX: 0, + localY: 15, + localZ: 0, + }, + { + chunkX: 0, + chunkZ: 0, + subChunkY: 0, + layer: 0, + blockName: sign.typeId, + states: sign.states, + version, + localX: 0, + localY: 0, + localZ: 0, + }, + { + chunkX: 0, + chunkZ: 0, + subChunkY: 0, + layer: 1, + blockName: water.typeId, + states: water.states, + version, + localX: 0, + localY: 0, + localZ: 0, + }, + { + chunkX: 0, + chunkZ: 0, + subChunkY: 0, + blockName: "minecraft:stone", + version, + localX: 2, + localY: 0, + localZ: 0, + }, + ]); + await addBlockEntities(dbPath); + const unrelatedKey = Buffer.from("unrelated-key"); + const unrelatedValue = Buffer.from("unrelated-value"); + await putRaw(dbPath, unrelatedKey, unrelatedValue); + + const mutations: PrimitiveWorldMutation[] = [ + { + kind: "block-write", + dimension: "overworld", + location: { x: 0, y: 0, z: 0 }, + layer: 0, + expectedPalette: sign, + replacementPalette: air, + blockEntityPolicy: "remove", + claims: [{ processorId: "locations", opId: "consume-sign" }], + }, + { + kind: "block-entity-remove", + dimension: "overworld", + location: { x: 0, y: 0, z: 0 }, + expectedId: "Sign", + claims: [{ processorId: "locations", opId: "consume-sign" }], + }, + ]; + const result = await applyBedrockWorldMutations({ dbPath, mutations }); + assert.equal(result.verified, true); + assert.ok(result.mutatedKeyCount >= 2); + + await withBedrockWorldObservations({ dbPath }, async (observations) => { + const entries = []; + for await (const entry of observations.blocks({ + dimension: "overworld", + bounds: { min: { x: 0, y: 0, z: 0 }, max: { x: 2, y: 0, z: 0 } }, + includeAir: true, + })) { + entries.push(entry); + } + const markerLayers = entries.filter( + (entry) => entry.location.x === 0 && entry.location.z === 0, + ); + assert.deepEqual( + markerLayers.map((entry) => [entry.layer, entry.palette]), + [ + [0, air], + [1, water], + ], + ); + assert.equal(markerLayers[0]?.blockEntity, undefined); + const stone = entries.find((entry) => entry.location.x === 2); + assert.equal(stone?.palette.typeId, "minecraft:stone"); + assert.equal(stone?.blockEntity?.id, "Chest"); + }); + assert.deepEqual(await getRaw(dbPath, unrelatedKey), unrelatedValue); + assert.equal(await readHeight(dbPath, 0, 0, 0, 0), 0); +}); + +test("writer grows packed palette width deterministically and rejects stale or absent targets before batch", async (t) => { + const root = await createTempDirectory(t, "blr-writer-palette-"); + const dbPath = path.join(root, "db"); + await createBedrockHeightmapDb(dbPath, [ + { chunkX: 0, chunkZ: 0, baseHeight: 1 }, + ]); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: 0, + chunkZ: 0, + subChunkY: 0, + blockName: "minecraft:stone", + version, + localX: 0, + localY: 0, + localZ: 0, + }, + ]); + const barrier = { + typeId: "minecraft:barrier", + states: {}, + version, + } as const; + const mutation: PrimitiveWorldMutation = { + kind: "block-write", + dimension: "overworld", + location: { x: 1, y: 0, z: 0 }, + layer: 0, + expectedPalette: existingAir, + replacementPalette: barrier, + blockEntityPolicy: "require-absent", + claims: [{ processorId: "decor", opId: "barrier" }], + }; + await applyBedrockWorldMutations({ dbPath, mutations: [mutation] }); + const decoded = await readSubchunk(dbPath, 0, 0, 0); + assert.equal(decoded.layers[0]?.storageHeader, 4); + assert.deepEqual( + decoded.layers[0]?.palette.map((entry) => entry.typeId), + ["minecraft:air", "minecraft:stone", "minecraft:barrier"], + ); + + await assert.rejects( + () => + applyBedrockWorldMutations({ + dbPath, + mutations: [ + { + ...mutation, + location: { x: 2, y: 0, z: 0 }, + expectedPalette: sign, + }, + ], + }), + /expected palette.*does not match/i, + ); + await assert.rejects( + () => + applyBedrockWorldMutations({ + dbPath, + mutations: [{ ...mutation, location: { x: 32, y: 0, z: 0 } }], + }), + /existing subchunk/i, + ); +}); + +test("injected pre-batch and post-batch verification failures are reported and never hide partial staging", async (t) => { + const root = await createTempDirectory(t, "blr-writer-failure-"); + const dbPath = path.join(root, "db"); + await createBedrockHeightmapDb(dbPath, [ + { chunkX: 0, chunkZ: 0, baseHeight: 1 }, + ]); + await createBedrockTerrainDb(dbPath, [ + { + chunkX: 0, + chunkZ: 0, + subChunkY: 0, + blockName: "minecraft:stone", + version, + localX: 0, + localY: 0, + localZ: 0, + }, + ]); + const mutation: PrimitiveWorldMutation = { + kind: "block-write", + dimension: "overworld", + location: { x: 1, y: 0, z: 0 }, + layer: 0, + expectedPalette: existingAir, + replacementPalette: { + typeId: "minecraft:barrier", + states: {}, + version, + }, + blockEntityPolicy: "require-absent", + claims: [{ processorId: "decor", opId: "barrier" }], + }; + await assert.rejects( + () => + applyBedrockWorldMutations({ + dbPath, + mutations: [mutation], + hooks: { + beforeBatch: () => { + throw new Error("before batch"); + }, + }, + }), + /before batch/i, + ); + assert.equal( + (await readBlock(dbPath, 1, 0, 0, 0)).palette.typeId, + "minecraft:air", + ); + await assert.rejects( + () => + applyBedrockWorldMutations({ + dbPath, + mutations: [mutation], + hooks: { + afterBatchBeforeVerify: () => { + throw new Error("verification interrupted"); + }, + }, + }), + /verification interrupted/i, + ); + assert.equal( + (await readBlock(dbPath, 1, 0, 0, 0)).palette.typeId, + "minecraft:barrier", + ); +}); + +async function addBlockEntities(dbPath: string): Promise { + const helpers = await loadMcbeLeveldbHelpers(); + const db = new LevelDB(dbPath, { createIfMissing: false }); + await db.open(); + try { + await db.put( + helpers.generateChunkKeyFromIndices( + { x: 0, z: 0, dimension: "overworld" }, + "BlockEntity", + ), + helpers.entryContentTypeToFormatMap.BlockEntity.serialize({ + type: "compound", + value: { + blockEntities: { + type: "list", + value: { + type: "compound", + value: [ + blockEntity("Sign", 0, 0, 0), + blockEntity("Chest", 2, 0, 0), + ], + }, + }, + }, + }), + ); + } finally { + await db.close(); + } +} + +function blockEntity(id: string, x: number, y: number, z: number) { + return { + id: { type: "string", value: id }, + x: { type: "int", value: x }, + y: { type: "int", value: y }, + z: { type: "int", value: z }, + }; +} + +async function putRaw( + dbPath: string, + key: Buffer, + value: Buffer, +): Promise { + const db = new LevelDB(dbPath, { createIfMissing: false }); + await db.open(); + try { + await db.put(key, value); + } finally { + await db.close(); + } +} + +async function getRaw(dbPath: string, key: Buffer): Promise { + const db = new LevelDB(dbPath, { createIfMissing: false }); + await db.open(); + try { + return await db.get(key); + } finally { + await db.close(); + } +} + +async function readSubchunk( + dbPath: string, + chunkX: number, + chunkZ: number, + subChunkY: number, +) { + const helpers = await loadMcbeLeveldbHelpers(); + const raw = await getRaw( + dbPath, + helpers.generateChunkKeyFromIndices( + { + x: chunkX, + z: chunkZ, + dimension: "overworld", + subChunkIndex: subChunkY, + }, + "SubChunkPrefix", + ), + ); + assert.ok(raw); + return decodeBedrockSubchunk(raw); +} + +async function readBlock( + dbPath: string, + x: number, + y: number, + z: number, + layer: number, +) { + return withBedrockWorldObservations({ dbPath }, async (observations) => { + let found: WorldBlockObservation | undefined; + for await (const entry of observations.blocks({ + dimension: "overworld", + bounds: { min: { x, y, z }, max: { x, y, z } }, + includeAir: true, + })) { + if (entry.layer === layer) found = entry; + } + assert.ok(found); + return found; + }); +} + +async function readHeight( + dbPath: string, + chunkX: number, + chunkZ: number, + localX: number, + localZ: number, +): Promise { + const helpers = await loadMcbeLeveldbHelpers(); + const raw = await getRaw( + dbPath, + helpers.generateChunkKeyFromIndices( + { x: chunkX, z: chunkZ, dimension: "overworld" }, + "Data3D", + ), + ); + assert.ok(raw); + const parsed = helpers.readData3dValue(raw); + assert.ok(parsed); + return parsed.heightMap[localX]![localZ]!; +} diff --git a/packages/blr/tsconfig.json b/packages/blr/tsconfig.json index 246146a..8af0594 100644 --- a/packages/blr/tsconfig.json +++ b/packages/blr/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "declaration": true, + "declarationMap": true }, "include": ["src/**/*.ts"] }