From a9fa1c7fa882ab2e43f20c0fd5d554f56e7b4e32 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:01:44 +0000 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=94=A7=20mcb=20compiler=20=E3=81=AB?= =?UTF-8?q?=20formatVersion=20=E6=B3=A8=E5=85=A5=E3=81=A8=20quiet=20option?= =?UTF-8?q?=20=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=20headless=20=E6=A4=9C?= =?UTF-8?q?=E8=A8=BC=E3=81=AE=E8=B6=B3=E5=A0=B4=E3=82=92=E4=BD=9C=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/datapackCompiler/mcbCompiler.ts | 36 +++++++++++++++------ src/tests/mcbCompile.test.ts | 32 ++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 src/tests/mcbCompile.test.ts diff --git a/src/systems/datapackCompiler/mcbCompiler.ts b/src/systems/datapackCompiler/mcbCompiler.ts index b2564dc9..3e569ea6 100644 --- a/src/systems/datapackCompiler/mcbCompiler.ts +++ b/src/systems/datapackCompiler/mcbCompiler.ts @@ -2,6 +2,7 @@ import { Compiler, VariableMap } from 'mc-build/mcl/Compiler' import { Parser } from 'mc-build/mcl/Parser' import { TemplateRegisterer } from 'mc-build/mcl/TemplateRegisterer' import { Tokenizer } from 'mc-build/mcl/TokenizerImpl' +import * as NodePath from 'node:path' import { getMisodeVersion } from '../minecraft/versionManager' import type { ExportedFile } from '../util' @@ -11,6 +12,16 @@ interface CompilerOptions { variables: Record version: string exportedFiles: Map + /** + * Data Pack format version. 省略時は `version` から misode の版数データを取得する。 + * headless なテストからはネットワークアクセスを避けるため明示的に渡す。 + */ + formatVersion?: number + /** + * 進捗ログを抑制する。 テストで `variables` 全体が毎回 dump されるのを避けるために使う + * (rig / animations を含むため 1 回で数百 KB になる)。 + */ + quiet?: boolean } export async function compileMcbProject({ @@ -19,13 +30,18 @@ export async function compileMcbProject({ variables, version, exportedFiles, + formatVersion, + quiet = false, }: CompilerOptions) { - console.group('Compiling', sourceFiles) - console.log('Variables:', variables) + if (!quiet) { + console.group('Compiling', sourceFiles) + console.log('Variables:', variables) + } TemplateRegisterer.register() - const misodeVersionData = await getMisodeVersion(version) + const resolvedFormatVersion = + formatVersion ?? (await getMisodeVersion(version)).data_pack_version const compiler = new Compiler('src', { libDir: null, @@ -37,7 +53,7 @@ export async function compileMcbProject({ ioThreadCount: null, dontEmitComments: true, setup: null, - formatVersion: misodeVersionData.data_pack_version, + formatVersion: resolvedFormatVersion, }) compiler.disableRequire = true @@ -45,7 +61,7 @@ export async function compileMcbProject({ cleanup: () => undefined, finished: () => true, write: (localPath, content) => { - const writePath = PathModule.join(destPath, localPath) + const writePath = NodePath.join(destPath, localPath) exportedFiles.set(writePath, { content, includeInAJMeta: true, @@ -53,7 +69,7 @@ export async function compileMcbProject({ }, } - console.time('MC-Build compiled in') + if (!quiet) console.time('MC-Build compiled in') const mcbTemplateFiles = Object.entries(sourceFiles).filter(([path]) => path.endsWith('.mcbt')) const mcbFiles = Object.entries(sourceFiles).filter(([path]) => path.endsWith('.mcb')) @@ -77,9 +93,11 @@ export async function compileMcbProject({ } compiler.compile(VariableMap.fromObject(variables)) - console.timeEnd('MC-Build compiled in') - console.log('Exported files:', exportedFiles.keys()) - console.groupEnd() + if (!quiet) { + console.timeEnd('MC-Build compiled in') + console.log('Exported files:', exportedFiles.keys()) + console.groupEnd() + } return exportedFiles } diff --git a/src/tests/mcbCompile.test.ts b/src/tests/mcbCompile.test.ts new file mode 100644 index 00000000..07c9b19f --- /dev/null +++ b/src/tests/mcbCompile.test.ts @@ -0,0 +1,32 @@ +/** + * `.mcb` テンプレートの生成物を headless で検査する。 + * + * Blockbench も Minecraft サーバも起動せずに datapack の生成結果を確認するための土台。 + * `compileDataPack` (index.ts) 全体は `Project!` global / `getFsModule()` / svelte store に + * 依存して Node 上では動かないが、`compileMcbProject` は sourceFiles / variables / + * exportedFiles を受け取るだけなので、その手前を自前で組めば回せる。 + */ +import { describe, expect, it } from 'vitest' +import { compileMcbProject } from '../systems/datapackCompiler/mcbCompiler' +import type { ExportedFile } from '../systems/util' + +describe('compileMcbProject smoke', () => { + it('最小の .mcb から関数ファイルを生成できる', async () => { + const exportedFiles = new Map() + + await compileMcbProject({ + sourceFiles: { + 'src/smoke.mcb': 'function hello {\n\tsay hi\n}\n', + }, + destPath: '.', + variables: {}, + version: '1.20.4', + exportedFiles, + // misode の版数取得 (外部 fetch) を迂回する。 値は 1.20.4 の data pack format。 + formatVersion: 26, + quiet: true, + }) + + expect(exportedFiles.size).toBeGreaterThan(0) + }) +}) From 697ebc60ec9cc838f98ab8e176aa9b51536a057d Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:01:44 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=90=9B=20TSB=20=E3=81=AE=20outdated?= =?UTF-8?q?=5Frig=20=E3=81=8C=20entity=20NBT=20=E3=82=92=E8=AA=AD=E3=82=93?= =?UTF-8?q?=E3=81=A7=E3=81=84=E3=81=9F=203=20=E9=80=A3=E3=83=90=E3=82=B0?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/datapackCompiler/1.20.4-tsb/global.mcb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/systems/datapackCompiler/1.20.4-tsb/global.mcb b/src/systems/datapackCompiler/1.20.4-tsb/global.mcb index 966c116b..e84e75bf 100644 --- a/src/systems/datapackCompiler/1.20.4-tsb/global.mcb +++ b/src/systems/datapackCompiler/1.20.4-tsb/global.mcb @@ -217,11 +217,16 @@ dir global { data remove storage <%temp_storage%> args data remove storage <%temp_storage%> uuids - data modify storage <%temp_storage%> uuids set from entity @s data.uuids + # UUID 一覧は entity NBT ではなく data_manager が読み込んだ storage 側にある + # (entity の保存 NBT に `data` compound は存在せず、 読もうとすると NbtPath が + # not-found 例外を投げて score が 0 に落ちる = loop に一度も入らない)。 + data modify storage <%temp_storage%> uuids set from storage <%temp_storage%> entry.data.uuids execute store result score #aj.length <%OBJECTIVES.I()%> run data get storage <%temp_storage%> uuids execute if score #aj.length <%OBJECTIVES.I()%> matches 1.. run block loop_over_uuids { - data modify storage <%temp_storage%> args.current_uuid set from storage <%temp_storage%> uuids[-1].uuid + # 要素は compound ではなく UUID 文字列そのもの (gu の out をそのまま append している)。 + # key 名は呼び先 `entity_stack_by_uuid` の `#ARGS: {uuid: string}` に合わせる。 + data modify storage <%temp_storage%> args.uuid set from storage <%temp_storage%> uuids[-1] data remove storage <%temp_storage%> uuids[-1] function ./entity_stack_by_uuid with storage <%temp_storage%> args From 5c2c9f90773ca2840288c15913d3e405df391ec0 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:08:59 +0000 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=90=9B=20default=20variant=20?= =?UTF-8?q?=E3=81=AE=E3=81=BF=E3=81=AE=20blueprint=20=E3=81=A7=E3=82=82=20?= =?UTF-8?q?root=20On-Apply=20=E3=81=8C=E5=87=BA=E5=8A=9B=E3=81=95=E3=82=8C?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=20gate=20=E3=82=92=E5=88=86?= =?UTF-8?q?=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../datapackCompiler/1.20.4-tsb/main.mcb | 55 +++++++++++-------- src/systems/datapackCompiler/index.ts | 13 +++++ 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/systems/datapackCompiler/1.20.4-tsb/main.mcb b/src/systems/datapackCompiler/1.20.4-tsb/main.mcb index 2a88bd53..5fe1ef03 100644 --- a/src/systems/datapackCompiler/1.20.4-tsb/main.mcb +++ b/src/systems/datapackCompiler/1.20.4-tsb/main.mcb @@ -78,17 +78,30 @@ function on_load { } %%> - # Phase C 案 V : variant 適用テーブルを on_load で同期書き込み (サイズ < 1KB)。 - # `aj.:meta d.variants..` = `{cmd?: int, cfg?: compound}`、 影響しない - # bone は path 不在 = no-op。 cleanup の `d` 配下 remove で消えるので残骸残りなし。 - # 加えて root On-Apply Function を持つ variant には `on_apply:1b` を並べる - # (= `variants/_apply` の dispatch ガード、 詳細は variants/_apply 側のコメント)。 + # TSB Optimized Export : animation_hash 判定で reload 時のキュー再構築をスキップ。 + # 同一値の set value は throw + success 0 になる仕様 + # (`DataCommands.java::ERROR_MERGE_UNCHANGED`) を利用、 hash 変化時のみ init_queue。 + # 部分展開中 reload は hash 一致でスキップされるが、 残った queue は global tick の + # load_dispatch_step が引き続き処理するため最終的に完全展開される。 + # datapack 入れ替え時の旧 storage 残骸は許容 (主に同じ cell の上書きで除去、 + # 一部 unreferenced cell は残るが運用上問題なし)。 + # 詳細 : docs/tsb-known-issues/reload-skip-on-hash-match.md + execute store success score #h <%OBJECTIVES.I()%> run data modify storage <%project_storage.replace(':', '.')%>:state d.animation_hash set value "<%animation_hash%>" + execute if score #h <%OBJECTIVES.I()%> matches 1 run function <%blueprint_id%>/load/init_queue + } + + # Phase C 案 V : variant 適用テーブルを on_load で同期書き込み (サイズ < 1KB)。 + # `aj.:meta d.variants..` = `{cmd?: int, cfg?: compound}`、 影響しない + # bone は path 不在 = no-op。 TSB 経路は on_load で cleanup を呼ばないため、 残骸は + # 下の `set value` による毎回の上書きで潰す (= gate が false の経路だけ remove が要る)。 + # 加えて root On-Apply Function を持つ variant には `on_apply:1b` を並べる + # (= `variants/_apply` の dispatch ガード、 詳細は variants/_apply 側のコメント)。 + # animation の有無とは独立に必要なので `needs_variant_functions` で判定する + # (= animation 0 個の blueprint でも variants/_apply の dispatch 条件を成立させる)。 + IF (tsb_optimized_export && needs_variant_functions) { <%% if (Object.keys(rig.variants).length > 0) { const filteredBones = Object.values(rig.nodes).filter(n => BONE_TYPES.includes(n.type)) - // variant が 1 個だけの blueprint では variants dir 自体を生成しないので、 - // on_apply フラグも書かない (= 読む側が存在しない)。 - const emitOnApplyFlag = Object.keys(rig.variants).length > 1 const variantParts = [] for (const variant of Object.values(rig.variants)) { const bonePartsList = [] @@ -119,26 +132,20 @@ function on_load { bonePartsList.push(`"${i}":{${parts.join(',')}}`) }) // on_apply フラグは bone key と同じ compound に並べる (bone key は数値文字列なので衝突しない)。 - if (emitOnApplyFlag && variant.on_apply_function) bonePartsList.push('on_apply:1b') + if (variant.on_apply_function) bonePartsList.push('on_apply:1b') if (bonePartsList.length === 0) continue variantParts.push(`${variant.name}:{${bonePartsList.join(',')}}`) } - if (variantParts.length > 0) { - emit(`data modify storage ${project_storage.replace(':', '.')}:meta d.variants set value {${variantParts.join(',')}}`) - } + // 空でも `set value {}` で必ず上書きする (= 前回 export で書いた `on_apply:1b` 等が + // storage に残り、 フラグ不要になった後も dispatch が成立し続けるのを防ぐ)。 + emit(`data modify storage ${project_storage.replace(':', '.')}:meta d.variants set value {${variantParts.join(',')}}`) } %%> - - # TSB Optimized Export : animation_hash 判定で reload 時のキュー再構築をスキップ。 - # 同一値の set value は throw + success 0 になる仕様 - # (`DataCommands.java::ERROR_MERGE_UNCHANGED`) を利用、 hash 変化時のみ init_queue。 - # 部分展開中 reload は hash 一致でスキップされるが、 残った queue は global tick の - # load_dispatch_step が引き続き処理するため最終的に完全展開される。 - # datapack 入れ替え時の旧 storage 残骸は許容 (主に同じ cell の上書きで除去、 - # 一部 unreferenced cell は残るが運用上問題なし)。 - # 詳細 : docs/tsb-known-issues/reload-skip-on-hash-match.md - execute store success score #h <%OBJECTIVES.I()%> run data modify storage <%project_storage.replace(':', '.')%>:state d.animation_hash set value "<%animation_hash%>" - execute if score #h <%OBJECTIVES.I()%> matches 1 run function <%blueprint_id%>/load/init_queue + } ELSE IF (tsb_optimized_export) { + # variant 関連を一切出力しない構成では上の `set value` が出ないため、 旧 export の + # `d.variants` が storage に残り続ける。 TSB 経路は on_load で cleanup を呼ばない + # 方針なので、 この経路だけは明示的に remove する。 + data remove storage <%project_storage.replace(':', '.')%>:meta d.variants } } @@ -1383,7 +1390,7 @@ dir remove { } } -IF (Object.keys(rig.variants).length > 1) { +IF (needs_variant_functions) { dir variants { IF (tsb_optimized_export) { # Phase C 案 V : variant 適用関数を共通 1 ファイル + wrapper N 個 + dispatch 1 個に集約。 diff --git a/src/systems/datapackCompiler/index.ts b/src/systems/datapackCompiler/index.ts index 24cc2880..0dde955a 100644 --- a/src/systems/datapackCompiler/index.ts +++ b/src/systems/datapackCompiler/index.ts @@ -566,6 +566,18 @@ const dataPackCompiler: DataPackCompiler = async ({ .map(() => '..') .join('/') + // variant 関連の関数と metadata を出力する必要があるか。 + // rig.variants には default が常に含まれるので length > 1 は「カスタム variant あり」の意味。 + // default のみでも root On-Apply Function や variant keyframe があれば variants/ が要る。 + const hasCustomVariants = Object.keys(rig.variants).length > 1 + const hasVariantOnApply = Object.values(rig.variants).some(v => + Boolean(v.on_apply_function?.trim()) + ) + const hasVariantKeyframes = animations.some(a => + a.frames.some(f => (f.variants?.length ?? 0) > 0) + ) + const needsVariantFunctions = hasCustomVariants || hasVariantOnApply || hasVariantKeyframes + const variables = { relativePathToSrc, blueprint_id: aj.blueprint_id, @@ -631,6 +643,7 @@ const dataPackCompiler: DataPackCompiler = async ({ .length > 0, has_cameras: Object.values(rig.nodes).filter(n => n.type === 'camera').length > 0, has_animations: animations.length > 0, + needs_variant_functions: needsVariantFunctions, getNodeTags, BONE_TYPES, project_storage: `${aj.blueprint_id}`, From 83411b8744ba0a56134df1cd1b23437d7ab672a3 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:25:57 +0000 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=85=20tellraw=20=E3=81=AE=20test=20?= =?UTF-8?q?=E3=82=92=20formats/blueprint=20=E3=81=AE=20mock=20=E3=81=A7?= =?UTF-8?q?=E5=BE=A9=E6=97=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tests/tellraw.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/tests/tellraw.test.ts b/src/tests/tellraw.test.ts index eb3fd622..8f96b88c 100644 --- a/src/tests/tellraw.test.ts +++ b/src/tests/tellraw.test.ts @@ -27,6 +27,17 @@ vi.mock('../util/minecraftUtil', () => ({ vi.mock('../systems/animationRenderer', () => ({})) vi.mock('../systems/rigRenderer', () => ({})) +// tellraw.ts は v1.10.2 取り込みで `projectTargetVersionIsAtLeast` を使うようになったが、 +// formats/blueprint は svelte component / svg asset / blockbench-patch-manager を芋づるで +// 引くため vitest では解決できない (= `window is not defined` で collect 段階から落ちる)。 +// 実際に使うのはこの 1 関数だけなので、 忠実な最小コピーで差し替える。 +vi.mock('../formats/blueprint', () => ({ + projectTargetVersionIsAtLeast(version: string): boolean { + if (!Project?.animated_java) return false + return !compareVersions(version, Project.animated_java.target_minecraft_version) + }, +})) + // `generic-stream` の broken ESM resolution は vitest.config.ts の resolve.alias 経由で // `src/tests/stubs/genericStream.ts` に差し替え済。 ここでは追加 mock 不要。 From 1447d7efe3992fb632b42d721ae3f1953f08b9aa Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:30:34 +0000 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=94=A7=20vitest=20=E3=81=A7=20deeps?= =?UTF-8?q?late/lib/nbt=20=E3=82=92=E8=A7=A3=E6=B1=BA=E3=81=A7=E3=81=8D?= =?UTF-8?q?=E3=82=8B=E3=82=88=E3=81=86=20alias=20=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vitest.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index 876fe2aa..e033903f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,16 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ + resolve: { + alias: { + // src 側は `deepslate/lib/nbt` で import しているが、 deepslate の package.json + // "exports" が公開しているのは `./nbt` (= `lib/nbt/main.js`) だけ。 production build では + // `.scripts/esbuild.ts` の DEPENDENCY_QUARKS plugin が手動解決しているため通るが、 + // vitest には同 plugin が無く import-analysis で落ちる。 `lib/nbt/main.js` は + // `lib/nbt/index.js` の re-export なので、 alias で公開 subpath に寄せて吸収する。 + 'deepslate/lib/nbt': 'deepslate/nbt', + }, + }, test: { dir: 'src/tests', server: { From 838282e456d2ea70120c4fbcbb9f7af6879e726a Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:30:34 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=85=20headless=20harness=20?= =?UTF-8?q?=E3=81=A8=20TSB=20=E7=94=9F=E6=88=90=E7=89=A9=E3=81=AE=E5=9B=9E?= =?UTF-8?q?=E5=B8=B0=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tests/fixtures/minimalRig.ts | 501 +++++++++++++++++++++++++++++++ src/tests/mcbCompile.test.ts | 203 ++++++++++++- 2 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 src/tests/fixtures/minimalRig.ts diff --git a/src/tests/fixtures/minimalRig.ts b/src/tests/fixtures/minimalRig.ts new file mode 100644 index 00000000..4214e151 --- /dev/null +++ b/src/tests/fixtures/minimalRig.ts @@ -0,0 +1,501 @@ +/** + * `compileMcbProject` を headless で回すための最小 rig fixture。 + * + * `compileDataPack` (datapackCompiler/index.ts) 全体は `Project!` global / `getFsModule()` / + * svelte store に依存して Node 上では動かないが、 `compileMcbProject` は + * sourceFiles / variables / destPath / version / exportedFiles を受け取るだけなので、 + * その手前 (= `variables` の組み立て + `Project` global の stub) を自前で組めば回せる。 + * + * **前提** : このモジュールは `../formats/blueprint` / `../util/minecraftUtil` が + * `vi.mock` されている test file からのみ import できる (= どちらも Blockbench 結合の + * 重いモジュールを芋づるで引くため)。 mock の実体は `mcbCompile.test.ts` を参照。 + */ +import * as fs from 'node:fs' +import * as NodePath from 'node:path' +import { fileURLToPath } from 'node:url' + +import { TextComponent } from 'book-and-quill' +import { NbtCompound, NbtFloat, NbtInt, NbtList, NbtString } from 'deepslate/nbt' + +import { DisplayEntityConfig } from '../../nodeConfigs' +import type { IRenderedAnimation } from '../../systems/animationRenderer' +import ENTITY_NAMES from '../../systems/datapackCompiler/entityNames' +import { compileMcbProject } from '../../systems/datapackCompiler/mcbCompiler' +import OBJECTIVES from '../../systems/datapackCompiler/objectives' +import TAGS, { getNodeTags, getRootEntityTags } from '../../systems/datapackCompiler/tags' +import TELLRAW from '../../systems/datapackCompiler/tellraw' +import type { AnyRenderedNode, IRenderedRig } from '../../systems/rigRenderer' +import { + arrayToNbtFloatArray, + type ExportedFile, + matrixToNbtFloatArray, + transformationToNbt, +} from '../../systems/util' +import { eulerFromQuaternion, roundTo } from '../../util/misc' + +// --- fixture の固定値 ------------------------------------------------------- + +/** fixture が使う blueprint ID。 `aj:test_rig` → 生成先 `data/aj/functions/test_rig/...`。 */ +export const BLUEPRINT_ID = 'aj:test_rig' +/** fixture が対象にする Minecraft version (= TSB 最適化経路がサポートする唯一の版)。 */ +export const TARGET_VERSION = '1.20.4' +/** 1.20.4 の data pack format。 misode の外部 fetch を迂回するために明示的に渡す。 */ +export const DATA_PACK_FORMAT = 26 +/** + * `index.ts:583` は `Math.random()` で export_version を作るが、 fixture では差分比較を + * 安定させるため固定値を使う。 + */ +export const EXPORT_VERSION = '00000000' +/** bone passenger の item id (= production の `aj.display_item`)。 */ +export const DISPLAY_ITEM = 'minecraft:stone' + +const BONE_TYPES = ['bone', 'text_display', 'item_display', 'block_display'] + +const BONE_UUID = 'fixture-bone' +const LOCATOR_UUID = 'fixture-locator' +const CAMERA_UUID = 'fixture-camera' + +const REPO_ROOT = NodePath.resolve(NodePath.dirname(fileURLToPath(import.meta.url)), '../../..') +const MCB_DIR = NodePath.join(REPO_ROOT, 'src/systems/datapackCompiler/1.20.4-tsb') + +// --- options ---------------------------------------------------------------- + +export interface FixtureOptions { + /** variant の一覧。省略時は default 1 個のみ。 */ + variants?: Array<{ + name: string + isDefault?: boolean + onApplyFunction?: string + }> + /** animation の一覧。省略時は空 (= has_animations: false)。 */ + animations?: Array<{ + name: string + /** 各 frame の variants 配列。variant keyframe の有無を作るために使う。 */ + frameVariants?: Array + }> + /** + * TSB 最適化経路を使うか。省略時 true。 + * + * 切り替わるのは `variables.tsb_optimized_export` だけで、 読む `.mcb` は常に + * `1.20.4-tsb/` (= 両分岐を持つ) のまま。 純正 `1.20.4/` テンプレートには切り替わらない。 + */ + tsbOptimized?: boolean +} + +// --- Blockbench global の stub ---------------------------------------------- + +/** + * Blockbench が提供する `compareVersions` global。 `a` が `b` より新しいとき true + * (= `blockbench-types/custom/util.d.ts:68` の契約)。 + */ +function compareVersionsImpl(versionA: string, versionB: string): boolean { + const a = String(versionA).split('.').map(Number) + const b = String(versionB).split('.').map(Number) + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i] ?? 0 + const y = b[i] ?? 0 + if (x !== y) return x > y + } + return false +} + +/** + * `THREE.Matrix4` の最小 stub。 `matrixToNbtFloatArray` が使う copy / transpose / toArray + * だけを持つ (= three は本 repo の依存に含まれず、 Blockbench が runtime で供給するため)。 + */ +class Matrix4Stub { + elements: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] + + copy(other: { elements: number[] }): this { + this.elements = [...other.elements] + return this + } + + transpose(): this { + const e = this.elements + // column-major 4×4 の転置 (three.js Matrix4.transpose と同じ入れ替え)。 + const swap = (i: number, j: number) => { + const t = e[i] + e[i] = e[j] + e[j] = t + } + swap(1, 4) + swap(2, 8) + swap(6, 9) + swap(3, 12) + swap(7, 13) + swap(11, 14) + return this + } + + toArray(): number[] { + return [...this.elements] + } +} + +/** + * `Project!.animated_java` を参照するモジュール群のために global を立てる。 + * + * 実際にエラーが出て必要だと分かったフィールドだけを載せている : + * - `blueprint_id` : tellraw.ts の TELLRAW_PREFIX / tags.ts の PROJECT_* タグ + * - `target_minecraft_version` : tellraw.ts の compareVersions 分岐 / nodeConfigs.ts の toNBT + * - `custom_rig_entity_tags` : tags.ts の getNodeTags / getRootEntityTags + */ +function installGlobals(): void { + const g = globalThis as any + g.compareVersions ??= compareVersionsImpl + g.THREE ??= { Matrix4: Matrix4Stub } + g.Project = { + animated_java: { + blueprint_id: BLUEPRINT_ID, + target_minecraft_version: TARGET_VERSION, + custom_rig_entity_tags: '', + }, + } + TextComponent.defaultMinecraftVersion = TARGET_VERSION +} + +// --- rig / animation の組み立て --------------------------------------------- + +/** 全 node が共有する identity な default_transform。 */ +function identityTransform() { + return { + matrix: new Matrix4Stub(), + decomposed: { + translation: { x: 0, y: 0, z: 0, toArray: () => [0, 0, 0] }, + left_rotation: { x: 0, y: 0, z: 0, w: 1, toArray: () => [0, 0, 0, 1] }, + scale: { x: 1, y: 1, z: 1, toArray: () => [1, 1, 1] }, + }, + pos: [0, 0, 0], + rot: [0, 0, 0], + scale: [1, 1, 1], + head_rot: [0, 0], + } +} + +function buildVariants(options: FixtureOptions) { + const specs = options.variants ?? [{ name: 'default', isDefault: true }] + const variants: Record = {} + for (const spec of specs) { + // variant UUID は名前から決定的に導出する (= fixture 内で frameVariants から引けるように)。 + const uuid = `variant-${spec.name}` + variants[uuid] = { + name: spec.name, + display_name: spec.name, + uuid, + texture_map: {}, + excluded_nodes: [], + ...(spec.isDefault ? { is_default: true as const } : {}), + ...(spec.onApplyFunction ? { on_apply_function: spec.onApplyFunction } : {}), + models: { + [BONE_UUID]: { + model: null, + custom_model_data: 1, + resource_location: `${BLUEPRINT_ID}/bone`, + item_model: `${BLUEPRINT_ID}/bone`, + }, + }, + } + } + return variants +} + +/** + * bone 1 / locator 1 (`config.use_entity: true`) / camera 1 の固定構成。 + * + * `bone_id` は `IRenderedRig` に保存されず `main.mcb` が `Object.values(rig.nodes)` の + * **挿入順** から割り当てるため、 このオブジェクトリテラルの記述順がそのまま ID 順になる。 + */ +function buildRigNodes(): Record { + const nodes = { + [BONE_UUID]: { + type: 'bone', + name: 'body', + storage_name: 'body', + uuid: BONE_UUID, + parent: 'root', + default_transform: identityTransform(), + base_scale: 1, + bounding_box: null, + configs: { default: {}, variants: {} }, + }, + [LOCATOR_UUID]: { + type: 'locator', + name: 'muzzle', + storage_name: 'muzzle', + uuid: LOCATOR_UUID, + parent: 'root', + default_transform: identityTransform(), + max_distance: 1, + config: { use_entity: true, entity_type: 'minecraft:marker' }, + }, + [CAMERA_UUID]: { + type: 'camera', + name: 'cam', + storage_name: 'cam', + uuid: CAMERA_UUID, + parent: 'root', + default_transform: identityTransform(), + max_distance: 1, + }, + } + return nodes as unknown as Record +} + +function buildRig(options: FixtureOptions): IRenderedRig { + return { + nodes: buildRigNodes(), + variants: buildVariants(options), + textures: {}, + model_export_folder: '', + texture_export_folder: '', + includes_custom_models: false, + target_minecraft_version: TARGET_VERSION, + } as unknown as IRenderedRig +} + +function buildAnimations(options: FixtureOptions, rig: IRenderedRig): IRenderedAnimation[] { + const specs = options.animations ?? [] + const nodes = Object.values(rig.nodes) + const modified_nodes: Record = {} + for (const node of nodes) modified_nodes[node.uuid] = node + + // variant 名 → variant UUID。 frameVariants は名前で書けるようにして、 ここで解決する。 + const variantUuidByName = new Map() + for (const [uuid, variant] of Object.entries(rig.variants)) { + variantUuidByName.set(variant.name, uuid) + } + + return specs.map(spec => { + const frameVariants = spec.frameVariants ?? [undefined] + const frames = frameVariants.map((names, time) => { + const node_transforms: Record = {} + for (const node of nodes) node_transforms[node.uuid] = identityTransform() + const variants = names?.map(name => variantUuidByName.get(name) ?? name) + return { + time, + node_transforms, + ...(variants && variants.length > 0 ? { variants } : {}), + } + }) + return { + name: spec.name, + storage_name: spec.name, + uuid: `animation-${spec.name}`, + loop_delay: 0, + frames, + duration: frames.length, + loop_mode: 'once' as const, + modified_nodes, + } as unknown as IRenderedAnimation + }) +} + +/** + * `index.ts:46` の `generateRootEntityPassengers` を fixture 用に写したもの。 + * production 版は非 export + `Variant.getDefault()` (= Blockbench の Variant registry) に + * 依存するため、 default variant を rig から直接引く形に置き換えている。 + * 対象は 1.20.4 / bone のみ (locator / camera は root に乗らないので switch の default で skip)。 + */ +function buildRootEntityPassengers(rig: IRenderedRig): string { + const allVariants = Object.values(rig.variants) + // `isDefault` を 1 つも指定しない options でも落ちないよう、 先頭 variant に fallback する。 + const defaultVariant = allVariants.find(v => v.is_default) ?? allVariants[0] + const passengers = new NbtList() + + for (const [uuid, node] of Object.entries(rig.nodes)) { + if (node.type === 'struct' || node.type === 'null_object') continue + + const passenger = new NbtCompound() + passenger.set('Tags', getNodeTags(node, rig)) + + if (BONE_TYPES.includes(node.type)) { + passenger + .set('height', new NbtFloat(3)) + .set('width', new NbtFloat(3)) + .set('teleport_duration', new NbtInt(0)) + .set('interpolation_duration', new NbtInt(1)) + .set( + 'transformation', + new NbtCompound() + .set('translation', arrayToNbtFloatArray([0, 0, 0])) + .set('left_rotation', arrayToNbtFloatArray([0, 0, 0, 1])) + .set('right_rotation', arrayToNbtFloatArray([0, 0, 0, 1])) + .set('scale', arrayToNbtFloatArray([0, 0, 0])) + ) + } + + if (node.type === 'bone') { + const variantModel = defaultVariant.models[uuid] + const item = new NbtCompound() + .set('id', new NbtString(DISPLAY_ITEM)) + .set( + 'tag', + new NbtCompound().set( + 'CustomModelData', + new NbtInt(variantModel.custom_model_data) + ) + ) + .set('Count', new NbtInt(1)) + passenger + .set('id', new NbtString('minecraft:item_display')) + .set('item', item) + .set('item_display', new NbtString('head')) + + const configs = (node as any).configs + if (configs?.default) { + DisplayEntityConfig.fromJSON(configs.default).toNBT(passenger) + } + } else { + // root entity に乗らない node (locator / camera) は passenger にしない。 + continue + } + + passengers.add(passenger) + } + + return passengers.toString() +} + +/** `index.ts:330` の `nodeSorter` (非 export) をそのまま写したもの。 */ +function nodeSorter(a: AnyRenderedNode, b: AnyRenderedNode): number { + if (a.type === 'locator' && b.type !== 'locator') return 1 + if (a.type !== 'locator' && b.type === 'locator') return -1 + return 0 +} + +// --- variables -------------------------------------------------------------- + +/** + * `compileMcbProject` に渡す `variables` 一式を組み立てる。 + * キー構成は `datapackCompiler/index.ts` の `const variables = {` (= 581 行目付近) と 1:1。 + */ +export function buildFixtureVariables(options: FixtureOptions = {}): Record { + installGlobals() + + const tsbOptimized = options.tsbOptimized ?? true + const rig = buildRig(options) + const animations = buildAnimations(options, rig) + + // `parseResourceLocation(BLUEPRINT_ID).path` 相当 (= `aj:test_rig` → `test_rig`)。 + const path = BLUEPRINT_ID.split(':').slice(1).join('') + const relativePathToSrc = path + .split('/') + .map(() => '..') + .join('/') + + const hasCustomVariants = Object.keys(rig.variants).length > 1 + const hasVariantOnApply = Object.values(rig.variants).some(v => + Boolean(v.on_apply_function?.trim()) + ) + const hasVariantKeyframes = animations.some(a => + a.frames.some(f => (f.variants?.length ?? 0) > 0) + ) + + return { + relativePathToSrc, + blueprint_id: BLUEPRINT_ID, + interpolation_duration: 1, + teleportation_duration: 1, + display_item: DISPLAY_ITEM, + rig, + animations, + export_version: EXPORT_VERSION, + root_entity_passengers: buildRootEntityPassengers(rig), + TAGS, + OBJECTIVES, + TELLRAW, + ENTITY_NAMES, + on_summon_function: '', + on_remove_function: '', + on_pre_tick_function: '', + on_post_tick_function: '', + matrixToNbtFloatArray, + transformationToNbt, + use_storage_for_animation: true, + // TSB 経路の `createAnimationStorageTsb` は常に `[]` を返す (= cell は別ファイルに書き出され、 + // `.mcb` の `animationStorage.join('\n')` は非 TSB 経路でしか使われない)。 非 TSB 経路の + // `createAnimationStorage` は非 export + svelte store 依存なので、 fixture では常に空にする。 + animationStorage: [], + tsb_optimized_export: tsbOptimized, + tsb_quantization_digits_default: 5, + tsb_cells_per_tick: 1000, + tsb_max_line_bytes: 1_000_000, + tsb_silent_uninstall: true, + tsb_load_debug_log: false, + rig_hash: 'fixture_rig_hash', + animation_hash: 'fixture_animation_hash', + boundingBox: [48, 48], + DisplayEntityConfig, + roundTo, + nodeSorter, + getRotationFromQuaternion: eulerFromQuaternion, + has_locators: Object.values(rig.nodes).filter(n => n.type === 'locator').length > 0, + has_interactions: Object.values(rig.nodes).filter(n => n.type === 'interaction').length > 0, + has_entity_locators: + Object.values(rig.nodes).filter( + n => n.type === 'locator' && (n as any).config?.use_entity + ).length > 0, + has_ticking_locators: + Object.values(rig.nodes).filter( + n => n.type === 'locator' && (n as any).config?.on_tick_function + ).length > 0, + has_cameras: Object.values(rig.nodes).filter(n => n.type === 'camera').length > 0, + has_animations: animations.length > 0, + needs_variant_functions: hasCustomVariants || hasVariantOnApply || hasVariantKeyframes, + getNodeTags, + BONE_TYPES, + project_storage: `${BLUEPRINT_ID}`, + temp_storage: `animated_java:temp`, + gu_storage: `animated_java:gu`, + data_storage: `animated_java:data`, + auto_update_rig_orientation: false, + debug_mode: false, + use_entity_stacking: false, + root_entity_tags: getRootEntityTags().toString(), + } +} + +// --- compile ---------------------------------------------------------------- + +/** + * fixture の rig / animation で `1.20.4-tsb` の `.mcb` をコンパイルし、 + * 生成された mcfunction / json を `パス → 内容` の Map で返す。 + * + * - `.mcb` は `mcbFiles.ts` 経由ではなく `fs.readFileSync` で直接読む + * (= esbuild plugin の文字列 import は vitest で解決できないため) + * - Map のキーは `PathModule.join` 由来の区切り差を吸収するため `/` に normalize する + */ +export async function compileFixture(options: FixtureOptions = {}): Promise> { + const variables = buildFixtureVariables(options) + const exportedFiles = new Map() + + const globalTemplates = fs.readFileSync(NodePath.join(MCB_DIR, 'global.mcbt'), 'utf8') + const global = fs.readFileSync(NodePath.join(MCB_DIR, 'global.mcb'), 'utf8') + const main = fs.readFileSync(NodePath.join(MCB_DIR, 'main.mcb'), 'utf8') + + // `parseResourceLocation(BLUEPRINT_ID).fullPath` 相当 (= `aj:test_rig` → `aj/test_rig`)。 + const [namespace, ...rest] = BLUEPRINT_ID.split(':') + const fullPath = `${namespace}/${rest.join('')}` + + await compileMcbProject({ + sourceFiles: { + 'src/global.mcbt': globalTemplates, + 'src/animated_java.mcb': global, + [`src/${fullPath}.mcb`]: + `import ${variables.relativePathToSrc as string}/global.mcbt\n` + main, + }, + destPath: '.', + variables, + version: TARGET_VERSION, + exportedFiles, + formatVersion: DATA_PACK_FORMAT, + quiet: true, + }) + + const result = new Map() + for (const [path, file] of exportedFiles) { + result.set(path.split(NodePath.sep).join('/'), file.content.toString()) + } + return result +} diff --git a/src/tests/mcbCompile.test.ts b/src/tests/mcbCompile.test.ts index 07c9b19f..42835a6f 100644 --- a/src/tests/mcbCompile.test.ts +++ b/src/tests/mcbCompile.test.ts @@ -6,9 +6,47 @@ * 依存して Node 上では動かないが、`compileMcbProject` は sourceFiles / variables / * exportedFiles を受け取るだけなので、その手前を自前で組めば回せる。 */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +// formats/blueprint は svelte component / svg asset / Blockbench API を芋づるで引くため +// vitest では解決できない。 tellraw.ts と systems/util.ts が使うのは +// projectTargetVersionIsAtLeast だけなので、 忠実な最小コピーで差し替える。 +vi.mock('../formats/blueprint', () => ({ + projectTargetVersionIsAtLeast(version: string): boolean { + if (!Project?.animated_java) return false + return !compareVersions(version, Project.animated_java.target_minecraft_version) + }, +})) + +// minecraftUtil は constants.getFsModule / systems/minecraft/* を芋づるで引く。 +// tellraw.ts が使うのは toSmallCaps だけで、 生成物の assert 対象は小文字装飾の +// 有無ではないため identity で差し替える。 +vi.mock('../util/minecraftUtil', () => ({ + toSmallCaps: (str: string) => str, +})) + +// tellraw.ts の `import { type IRenderedAnimation } from '../animationRenderer'` は +// verbatimModuleSyntax の下で side-effect import として残るため、 型しか使っていないのに +// Blockbench 結合の実体 (= 最終的に svelte-patching-tools/blockbench → Dialog global) が +// ロードされてしまう。 実行時に参照される値は無いので空モジュールで差し替える。 +vi.mock('../systems/animationRenderer', () => ({})) +vi.mock('../systems/rigRenderer', () => ({})) + import { compileMcbProject } from '../systems/datapackCompiler/mcbCompiler' import type { ExportedFile } from '../systems/util' +import { compileFixture } from './fixtures/minimalRig' + +/** Map から `suffix` で終わるキーのファイル内容を 1 つだけ取り出す。 */ +function getFileBySuffix(files: Map, suffix: string): string { + const matches = [...files.keys()].filter(path => path.endsWith(suffix)) + expect(matches, `no file matching '${suffix}'`).toHaveLength(1) + return files.get(matches[0])! +} + +/** Map に `suffix` で終わるキーが存在するか。 */ +function hasFileBySuffix(files: Map, suffix: string): boolean { + return [...files.keys()].some(path => path.endsWith(suffix)) +} describe('compileMcbProject smoke', () => { it('最小の .mcb から関数ファイルを生成できる', async () => { @@ -30,3 +68,166 @@ describe('compileMcbProject smoke', () => { expect(exportedFiles.size).toBeGreaterThan(0) }) }) + +describe('1.20.4-tsb fixture compile', () => { + it('bone / locator / camera を含む rig から datapack を生成できる', async () => { + const files = await compileFixture() + + expect(files.size).toBeGreaterThan(0) + // global (animated_java namespace) と project (blueprint namespace) の両方が出ること。 + expect( + [...files.keys()].some(path => path.includes('/animated_java/')), + 'animated_java namespace の関数が無い' + ).toBe(true) + expect( + [...files.keys()].some(path => path.includes('/aj/')), + 'blueprint namespace の関数が無い' + ).toBe(true) + }) +}) + +/** + * `1.20.4-tsb/global.mcb` の `dir remove` / `outdated_rig` 回帰テスト。 + * + * 旧実装は 3 つのバグが重なって「古い locator / camera が削除されず残る」状態だった : + * 1. UUID 一覧を entity NBT (`from entity @s data.uuids`) から読もうとしていた + * (= entity の保存 NBT に `data` compound は存在せず、 NbtPath が not-found で落ちる) + * 2. list の要素を compound 扱いして `uuids[-1].uuid` を読んでいた + * (= 実際の要素は gu の out をそのまま append した UUID 文字列) + * 3. 引数 key が呼び先 `entity_stack_by_uuid` の `#ARGS: {uuid: string}` と食い違っていた + * (`args.current_uuid`) + * + * ループ本体は `block loop_over_uuids { ... }` として書かれているため、 mc-build が + * 兄弟ファイル `remove/loop_over_uuids.mcfunction` に切り出す。 2 つ目のバグの検査対象は + * そちら側になる (= 名前付き block なので `zzz/<数字>` と違って番号が動かない)。 + * + * 生成物の `zzz/<数字>` 匿名関数の番号は分岐数に依存して変わるため、 Map 全体の snapshot は + * 取らず、 名前の付いたファイルに対する意味的な assert のみを書く。 + */ +describe('1.20.4-tsb global/remove/outdated_rig', () => { + it('UUID 一覧を storage 側から読み、文字列要素をそのまま args.uuid に渡す', async () => { + const files = await compileFixture() + const outdatedRig = getFileBySuffix(files, '/remove/outdated_rig.mcfunction') + const loopOverUuids = getFileBySuffix(files, '/remove/loop_over_uuids.mcfunction') + + // 1. UUID 一覧は data_manager が読み込んだ storage 側から取る。 + expect(outdatedRig).toContain('set from storage animated_java:temp entry.data.uuids') + // 2 + 3. 要素は UUID 文字列そのもの、 key は呼び先の #ARGS に合わせて `uuid`。 + expect(loopOverUuids).toContain('args.uuid set from storage animated_java:temp uuids[-1]') + + // 旧バグの痕跡が残っていないこと。 + for (const content of [outdatedRig, loopOverUuids]) { + expect(content).not.toContain('from entity @s data.uuids') + expect(content).not.toContain('uuids[-1].uuid') + expect(content).not.toContain('args.current_uuid') + } + }) +}) + +/** + * blueprint 側 (= `aj:test_rig`) の on_load。 `animated_java` 側の + * `global/on_load.mcfunction` と区別するため namespace 込みの path で絞る。 + */ +const PROJECT_ON_LOAD = '/aj/functions/test_rig/on_load.mcfunction' + +/** 生成物のうち `variants/` 配下のパスだけを列挙する (= gate が開いたかの判定に使う)。 */ +function variantFilePaths(files: Map): string[] { + return [...files.keys()].filter(path => path.includes('/variants/')).sort() +} + +/** on_load から variant metadata (`:meta d.variants`) を触る行だけを抜き出す。 */ +function variantMetaLines(onLoad: string): string[] { + return onLoad.split('\n').filter(line => line.includes(':meta d.variants')) +} + +/** + * `needs_variant_functions` gate の回帰テスト。 + * + * 旧実装の gate は `Object.keys(rig.variants).length > 1` だったが、 `rig.variants` には + * default が常に 1 個含まれるため、 実質「カスタム variant あり」の意味になっていた。 + * 結果、 default variant しか無い blueprint では `dir variants` が丸ごと生成されず、 + * UI で root On-Apply Function を設定しても datapack に出力されなかった。 + * + * 現行 gate は `hasCustomVariants || hasVariantOnApply || hasVariantKeyframes` の OR で、 + * animation の有無からも独立している。 + */ +describe('1.20.4-tsb variants gate (needs_variant_functions)', () => { + it('default variant のみ + On-Apply あり + animation なしでも variants/ が出る', async () => { + const files = await compileFixture({ + variants: [{ name: 'default', isDefault: true, onApplyFunction: 'say applied' }], + }) + + expect(hasFileBySuffix(files, '/variants/default/apply.mcfunction')).toBe(true) + expect(hasFileBySuffix(files, '/variants/_apply.mcfunction')).toBe(true) + // root On-Apply Function 本体も variant 単位で切り出される。 + expect(hasFileBySuffix(files, '/variants/default/_on_apply.mcfunction')).toBe(true) + + // metadata は animation 用の block ではなく needs_variant_functions 側の block から出る。 + const onLoad = getFileBySuffix(files, PROJECT_ON_LOAD) + const metaLines = variantMetaLines(onLoad) + expect(metaLines).toHaveLength(1) + expect(metaLines[0]).toContain('d.variants set value') + // `_apply` の dispatch ガードになるフラグ。 + expect(metaLines[0]).toContain('on_apply:1b') + }) + + it('animation を足しても On-Apply の出力結果は変わらない', async () => { + const withoutAnimations = await compileFixture({ + variants: [{ name: 'default', isDefault: true, onApplyFunction: 'say applied' }], + }) + const withAnimations = await compileFixture({ + variants: [{ name: 'default', isDefault: true, onApplyFunction: 'say applied' }], + animations: [{ name: 'idle' }], + }) + + // variants/ の生成物と d.variants の中身がどちらも一致すること。 + expect(variantFilePaths(withAnimations)).toEqual(variantFilePaths(withoutAnimations)) + expect(variantMetaLines(getFileBySuffix(withAnimations, PROJECT_ON_LOAD))).toEqual( + variantMetaLines(getFileBySuffix(withoutAnimations, PROJECT_ON_LOAD)) + ) + + expect(hasFileBySuffix(withAnimations, '/variants/default/_on_apply.mcfunction')).toBe(true) + }) + + it('On-Apply が無くても variant keyframe があれば variants/ が出る', async () => { + const files = await compileFixture({ + animations: [{ name: 'idle', frameVariants: [['default'], undefined] }], + }) + + expect(hasFileBySuffix(files, '/variants/_apply.mcfunction')).toBe(true) + expect(hasFileBySuffix(files, '/variants/_apply_at_frame.mcfunction')).toBe(true) + // On-Apply Function は無いので _on_apply は出ない。 + expect(hasFileBySuffix(files, '/variants/default/_on_apply.mcfunction')).toBe(false) + + expect(variantMetaLines(getFileBySuffix(files, PROJECT_ON_LOAD))[0]).toContain( + 'd.variants set value' + ) + }) + + it('On-Apply も variant keyframe も無ければ variants/ は出ず d.variants を remove する', async () => { + const files = await compileFixture() + + expect(variantFilePaths(files)).toEqual([]) + + // TSB 経路は on_load で cleanup を呼ばないため、 gate が閉じた経路では + // 旧 export が残した d.variants を明示的に消す必要がある。 + const metaLines = variantMetaLines(getFileBySuffix(files, PROJECT_ON_LOAD)) + expect(metaLines).toHaveLength(1) + expect(metaLines[0]).toContain('data remove storage aj.test_rig:meta d.variants') + expect(metaLines[0]).not.toContain('set value') + }) + + it('custom variant があれば従来どおり全 variant の apply が出る', async () => { + const files = await compileFixture({ + variants: [{ name: 'default', isDefault: true }, { name: 'damaged' }], + }) + + expect(hasFileBySuffix(files, '/variants/default/apply.mcfunction')).toBe(true) + expect(hasFileBySuffix(files, '/variants/damaged/apply.mcfunction')).toBe(true) + + const metaLines = variantMetaLines(getFileBySuffix(files, PROJECT_ON_LOAD)) + expect(metaLines).toHaveLength(1) + expect(metaLines[0]).toContain('d.variants set value') + expect(metaLines[0]).toContain('damaged:') + }) +}) From 7312ea01fc396ad4c768918eab63212c0b8566e3 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:42:22 +0000 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=90=9B=20d.variants=20=E3=81=AE=20r?= =?UTF-8?q?emove=20=E3=81=AB=E5=AD=98=E5=9C=A8=E7=A2=BA=E8=AA=8D=E3=82=92?= =?UTF-8?q?=E6=8C=9F=E3=81=BF=E5=88=9D=E5=9B=9E=20load=20=E3=81=AE?= =?UTF-8?q?=E4=BE=8B=E5=A4=96=E3=82=92=E9=81=BF=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/systems/datapackCompiler/1.20.4-tsb/main.mcb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/systems/datapackCompiler/1.20.4-tsb/main.mcb b/src/systems/datapackCompiler/1.20.4-tsb/main.mcb index 5fe1ef03..73c8cdf3 100644 --- a/src/systems/datapackCompiler/1.20.4-tsb/main.mcb +++ b/src/systems/datapackCompiler/1.20.4-tsb/main.mcb @@ -145,7 +145,9 @@ function on_load { # variant 関連を一切出力しない構成では上の `set value` が出ないため、 旧 export の # `d.variants` が storage に残り続ける。 TSB 経路は on_load で cleanup を呼ばない # 方針なので、 この経路だけは明示的に remove する。 - data remove storage <%project_storage.replace(':', '.')%>:meta d.variants + # 不在時の `data remove` は ERROR_MERGE_UNCHANGED を投げる (= 初回 load で毎回踏む) ため、 + # 存在確認を挟む。 + execute if data storage <%project_storage.replace(':', '.')%>:meta d.variants run data remove storage <%project_storage.replace(':', '.')%>:meta d.variants } } From f6edf8f67e0a05a24dd0624928fec1ca23aad1a6 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:46:39 +0000 Subject: [PATCH 08/10] =?UTF-8?q?=E2=9C=85=20variant=20=E3=81=AE=20dispatc?= =?UTF-8?q?h=20=E7=B5=90=E7=B7=9A=E3=81=A8=20metadata=20guard=20=E3=82=92?= =?UTF-8?q?=20assert=20=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tests/mcbCompile.test.ts | 60 +++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/src/tests/mcbCompile.test.ts b/src/tests/mcbCompile.test.ts index 42835a6f..131694e2 100644 --- a/src/tests/mcbCompile.test.ts +++ b/src/tests/mcbCompile.test.ts @@ -34,7 +34,7 @@ vi.mock('../systems/rigRenderer', () => ({})) import { compileMcbProject } from '../systems/datapackCompiler/mcbCompiler' import type { ExportedFile } from '../systems/util' -import { compileFixture } from './fixtures/minimalRig' +import { compileFixture, type FixtureOptions } from './fixtures/minimalRig' /** Map から `suffix` で終わるキーのファイル内容を 1 つだけ取り出す。 */ function getFileBySuffix(files: Map, suffix: string): string { @@ -140,6 +140,17 @@ function variantMetaLines(onLoad: string): string[] { return onLoad.split('\n').filter(line => line.includes(':meta d.variants')) } +/** + * root On-Apply Function の本体。 生成物に素通しで載ることを確認するためのマーカーなので、 + * 他の生成行と衝突しない文字列にしてある。 + */ +const ON_APPLY_BODY = 'say aj_fixture_on_apply' + +/** default variant だけ + root On-Apply あり (= 旧 gate が握り潰していた構成)。 */ +const DEFAULT_ONLY_WITH_ON_APPLY: FixtureOptions = { + variants: [{ name: 'default', isDefault: true, onApplyFunction: ON_APPLY_BODY }], +} + /** * `needs_variant_functions` gate の回帰テスト。 * @@ -152,15 +163,11 @@ function variantMetaLines(onLoad: string): string[] { * animation の有無からも独立している。 */ describe('1.20.4-tsb variants gate (needs_variant_functions)', () => { - it('default variant のみ + On-Apply あり + animation なしでも variants/ が出る', async () => { - const files = await compileFixture({ - variants: [{ name: 'default', isDefault: true, onApplyFunction: 'say applied' }], - }) + it('default variant のみ + On-Apply あり + animation なしでも variants/ が出て呼び出しが繋がる', async () => { + const files = await compileFixture(DEFAULT_ONLY_WITH_ON_APPLY) expect(hasFileBySuffix(files, '/variants/default/apply.mcfunction')).toBe(true) expect(hasFileBySuffix(files, '/variants/_apply.mcfunction')).toBe(true) - // root On-Apply Function 本体も variant 単位で切り出される。 - expect(hasFileBySuffix(files, '/variants/default/_on_apply.mcfunction')).toBe(true) // metadata は animation 用の block ではなく needs_variant_functions 側の block から出る。 const onLoad = getFileBySuffix(files, PROJECT_ON_LOAD) @@ -169,14 +176,30 @@ describe('1.20.4-tsb variants gate (needs_variant_functions)', () => { expect(metaLines[0]).toContain('d.variants set value') // `_apply` の dispatch ガードになるフラグ。 expect(metaLines[0]).toContain('on_apply:1b') + + // --- ファイルの存在だけでなく、 呼び出しが実際に繋がっていることを見る -------------- + // (= 実機検証ができないので、 wrapper → _apply → _on_apply の 3 段を生成物で追う) + + // 1. wrapper は共通 _apply に variant 名を渡して dispatch する。 + const wrapper = getFileBySuffix(files, '/variants/default/apply.mcfunction') + expect(wrapper).toContain('function aj:test_rig/variants/_apply {variant: "default"}') + + // 2. 共通 _apply は metadata の on_apply フラグを条件に variant 別 _on_apply を呼ぶ。 + // `$(variant)` は実行時マクロなのでコンパイル後もそのまま残る (= 行頭 `$` の macro line)。 + const apply = getFileBySuffix(files, '/variants/_apply.mcfunction') + expect(apply).toContain( + '$execute if data storage aj.test_rig:meta d.variants.$(variant).on_apply at @s run function aj:test_rig/variants/$(variant)/_on_apply' + ) + + // 3. _on_apply には UI で設定した On-Apply Function の本文がそのまま載る。 + const onApply = getFileBySuffix(files, '/variants/default/_on_apply.mcfunction') + expect(onApply).toContain(ON_APPLY_BODY) }) it('animation を足しても On-Apply の出力結果は変わらない', async () => { - const withoutAnimations = await compileFixture({ - variants: [{ name: 'default', isDefault: true, onApplyFunction: 'say applied' }], - }) + const withoutAnimations = await compileFixture(DEFAULT_ONLY_WITH_ON_APPLY) const withAnimations = await compileFixture({ - variants: [{ name: 'default', isDefault: true, onApplyFunction: 'say applied' }], + ...DEFAULT_ONLY_WITH_ON_APPLY, animations: [{ name: 'idle' }], }) @@ -186,7 +209,12 @@ describe('1.20.4-tsb variants gate (needs_variant_functions)', () => { variantMetaLines(getFileBySuffix(withoutAnimations, PROJECT_ON_LOAD)) ) - expect(hasFileBySuffix(withAnimations, '/variants/default/_on_apply.mcfunction')).toBe(true) + // _on_apply の本文も一致すること (= animation 経路だけ本文が落ちる退行も拾う)。 + const onApplyPath = '/variants/default/_on_apply.mcfunction' + expect(getFileBySuffix(withAnimations, onApplyPath)).toBe( + getFileBySuffix(withoutAnimations, onApplyPath) + ) + expect(getFileBySuffix(withAnimations, onApplyPath)).toContain(ON_APPLY_BODY) }) it('On-Apply が無くても variant keyframe があれば variants/ が出る', async () => { @@ -210,11 +238,13 @@ describe('1.20.4-tsb variants gate (needs_variant_functions)', () => { expect(variantFilePaths(files)).toEqual([]) // TSB 経路は on_load で cleanup を呼ばないため、 gate が閉じた経路では - // 旧 export が残した d.variants を明示的に消す必要がある。 + // 旧 export が残した d.variants を明示的に消す必要がある。 不在時の `data remove` は + // ERROR_MERGE_UNCHANGED を投げる (= 初回 load で毎回踏む) ので存在確認付きで出す。 const metaLines = variantMetaLines(getFileBySuffix(files, PROJECT_ON_LOAD)) expect(metaLines).toHaveLength(1) - expect(metaLines[0]).toContain('data remove storage aj.test_rig:meta d.variants') - expect(metaLines[0]).not.toContain('set value') + expect(metaLines[0]).toBe( + 'execute if data storage aj.test_rig:meta d.variants run data remove storage aj.test_rig:meta d.variants' + ) }) it('custom variant があれば従来どおり全 variant の apply が出る', async () => { From 8ca8da2aaae6463d4851a332b7ccdbea50aa35e9 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:58:22 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=E2=9C=85=20smoke=20test=20=E3=81=A7?= =?UTF-8?q?=E7=94=9F=E6=88=90=E3=81=95=E3=82=8C=E3=81=9F=E9=96=A2=E6=95=B0?= =?UTF-8?q?=E3=81=AE=E6=9C=AC=E6=96=87=E3=81=BE=E3=81=A7=E7=A2=BA=E8=AA=8D?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tests/mcbCompile.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tests/mcbCompile.test.ts b/src/tests/mcbCompile.test.ts index 131694e2..5faaaf3d 100644 --- a/src/tests/mcbCompile.test.ts +++ b/src/tests/mcbCompile.test.ts @@ -66,6 +66,11 @@ describe('compileMcbProject smoke', () => { }) expect(exportedFiles.size).toBeGreaterThan(0) + // 件数だけでは「何かが出た」 以上のことを保証できないので、 対象の関数と本文まで見る。 + const paths = [...exportedFiles.keys()].map(p => p.replaceAll('\\', '/')) + const helloPath = paths.find(p => p.endsWith('/hello.mcfunction')) + expect(helloPath, 'hello.mcfunction が生成されていない').toBeDefined() + expect(String(exportedFiles.get(helloPath!)!.content)).toContain('say hi') }) }) From 52c312c4ad2c07011df268fd16eeaaa5023b68d5 Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:09:25 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=94=A7=20CI=20=E3=82=92=20fork=20?= =?UTF-8?q?=E7=94=A8=E3=81=AB=E6=95=B4=E7=90=86=E3=81=97=20vitest=20?= =?UTF-8?q?=E3=81=8C=E8=B5=B0=E3=82=8B=E3=82=88=E3=81=86=20bun=20run=20tes?= =?UTF-8?q?t=20=E3=81=B8=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8f790875..4ac1dfc1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ on: branches: [main] jobs: - publish: + build: runs-on: ubuntu-latest permissions: @@ -17,7 +17,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - registry-url: https://registry.npmjs.org/ - name: Install dependencies run: bun install --frozen-lockfile @@ -25,5 +24,7 @@ jobs: - name: Build run: bun run prod + # `bun test` (= bun 内蔵ランナー) ではなく package.json の test script を通す。 + # テストは vitest で書かれており、 `vi.mock` を使うものは内蔵ランナーでは動かない。 - name: Run tests - run: bun test + run: bun run test