@forgeax/engine-pack 0.1.20 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +159 -46
  2. package/dist/__tests__/inventory-schema.test.d.ts +2 -0
  3. package/dist/__tests__/inventory-schema.test.d.ts.map +1 -0
  4. package/dist/__tests__/parameterized-pack-authoring-gateway.unit.test.d.ts +2 -0
  5. package/dist/__tests__/parameterized-pack-authoring-gateway.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/parameterized-pack-scanner.unit.test.d.ts +2 -0
  7. package/dist/__tests__/parameterized-pack-scanner.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/parameterized-pack.unit.test.d.ts +2 -0
  9. package/dist/__tests__/parameterized-pack.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/scanner-blacklist.test.d.ts +2 -0
  11. package/dist/__tests__/scanner-blacklist.test.d.ts.map +1 -0
  12. package/dist/build.d.ts +9 -4
  13. package/dist/build.d.ts.map +1 -1
  14. package/dist/build.mjs +3317 -804
  15. package/dist/build.mjs.map +1 -1
  16. package/dist/builtin.mjs +37 -4
  17. package/dist/builtin.mjs.map +1 -1
  18. package/dist/catalog-builder.d.ts.map +1 -1
  19. package/dist/cli-asset.d.ts.map +1 -1
  20. package/dist/cli-asset.mjs +1368 -850
  21. package/dist/cli-asset.mjs.map +1 -1
  22. package/dist/deriveAssetName.d.ts +11 -7
  23. package/dist/deriveAssetName.d.ts.map +1 -1
  24. package/dist/evidence/material-cook.d.ts.map +1 -1
  25. package/dist/evidence/source-inventory.d.ts.map +1 -1
  26. package/dist/guid.d.ts +15 -0
  27. package/dist/guid.d.ts.map +1 -1
  28. package/dist/guid.mjs +53 -5
  29. package/dist/guid.mjs.map +1 -1
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.mjs +1255 -561
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/inventory/binding.d.ts +6 -0
  35. package/dist/inventory/binding.d.ts.map +1 -0
  36. package/dist/inventory/declaration.d.ts +24 -0
  37. package/dist/inventory/declaration.d.ts.map +1 -0
  38. package/dist/inventory/index.d.ts +3 -0
  39. package/dist/inventory/index.d.ts.map +1 -0
  40. package/dist/inventory/sync.d.ts +5 -0
  41. package/dist/inventory/sync.d.ts.map +1 -0
  42. package/dist/material-cook.mjs +3 -1
  43. package/dist/material-cook.mjs.map +1 -1
  44. package/dist/name.mjs +4 -7
  45. package/dist/name.mjs.map +1 -1
  46. package/dist/package-finalizer.d.ts +2 -20
  47. package/dist/package-finalizer.d.ts.map +1 -1
  48. package/dist/parameterized-pack-node.d.ts +26 -0
  49. package/dist/parameterized-pack-node.d.ts.map +1 -0
  50. package/dist/parameterized-pack-node.mjs +11093 -0
  51. package/dist/parameterized-pack-node.mjs.map +1 -0
  52. package/dist/parameterized-pack.d.ts +233 -0
  53. package/dist/parameterized-pack.d.ts.map +1 -0
  54. package/dist/resolve-asset-source.d.ts +1 -3
  55. package/dist/resolve-asset-source.d.ts.map +1 -1
  56. package/dist/resolve-asset-source.mjs +6 -81
  57. package/dist/resolve-asset-source.mjs.map +1 -1
  58. package/dist/runtime.mjs +77 -7
  59. package/dist/runtime.mjs.map +1 -1
  60. package/dist/scanner.d.ts +19 -8
  61. package/dist/scanner.d.ts.map +1 -1
  62. package/dist/scanner.mjs +1169 -690
  63. package/dist/scanner.mjs.map +1 -1
  64. package/dist/schema-compiled.d.ts.map +1 -1
  65. package/dist/schema.mjs +79 -7
  66. package/dist/schema.mjs.map +1 -1
  67. package/dist/scriptable-pack-node.d.ts +8 -32
  68. package/dist/scriptable-pack-node.d.ts.map +1 -1
  69. package/dist/scriptable-pack-node.mjs +576 -1021
  70. package/dist/scriptable-pack-node.mjs.map +1 -1
  71. package/dist/scriptable-pack-worker.mjs +29 -11
  72. package/dist/scriptable-pack-worker.mjs.map +1 -1
  73. package/dist/scriptable-pack.d.ts +19 -532
  74. package/dist/scriptable-pack.d.ts.map +1 -1
  75. package/dist/scriptable-pack.mjs +1042 -527
  76. package/dist/scriptable-pack.mjs.map +1 -1
  77. package/package.json +7 -7
  78. package/schema/meta.schema.json +1 -1
  79. package/schema/pack.schema.json +76 -6
  80. package/src/__tests__/guid-collision.unit.test.ts +18 -9
  81. package/src/__tests__/inventory-schema.test.ts +109 -0
  82. package/src/__tests__/pack.unit.test.ts +1 -238
  83. package/src/__tests__/package-finalizer.contract.test.ts +55 -0
  84. package/src/__tests__/parameterized-pack-authoring-gateway.unit.test.ts +249 -0
  85. package/src/__tests__/parameterized-pack-scanner.unit.test.ts +123 -0
  86. package/src/__tests__/parameterized-pack.unit.test.ts +266 -0
  87. package/src/__tests__/resolve-asset-source.test.ts +15 -98
  88. package/src/__tests__/scanner-blacklist.test.ts +55 -0
  89. package/src/__tests__/scanner-inventory.contract.test.ts +14 -15
  90. package/src/__tests__/scanner-inventory.test.ts +24 -12
  91. package/src/__tests__/scriptable-pack-cli.integration.test.ts +4 -9
  92. package/src/__tests__/topology.unit.test.ts +23 -0
  93. package/src/build.ts +28 -18
  94. package/src/catalog-builder.ts +61 -34
  95. package/src/cli-asset.ts +70 -85
  96. package/src/deriveAssetName.ts +14 -13
  97. package/src/evidence/material-cook.ts +3 -1
  98. package/src/evidence/source-inventory.ts +34 -21
  99. package/src/guid.ts +65 -4
  100. package/src/index.ts +12 -11
  101. package/src/inventory/binding.ts +25 -0
  102. package/src/inventory/declaration.ts +168 -0
  103. package/src/inventory/index.ts +18 -0
  104. package/src/inventory/sync.ts +22 -0
  105. package/src/package-finalizer.ts +2 -42
  106. package/src/parameterized-pack-node.ts +1968 -0
  107. package/src/parameterized-pack.ts +1493 -0
  108. package/src/resolve-asset-source.ts +4 -76
  109. package/src/scanner.ts +241 -59
  110. package/src/schema-compiled.ts +2 -0
  111. package/src/scriptable-pack-node.ts +111 -722
  112. package/src/scriptable-pack-worker.ts +48 -16
  113. package/src/scriptable-pack.ts +27 -939
  114. package/dist/.tsbuildinfo +0 -1
  115. package/dist/__tests__/load-asset-config.test.d.ts +0 -2
  116. package/dist/__tests__/load-asset-config.test.d.ts.map +0 -1
  117. package/dist/__tests__/scriptable-pack.test-d.d.ts +0 -2
  118. package/dist/__tests__/scriptable-pack.test-d.d.ts.map +0 -1
  119. package/dist/__tests__/scriptable-pack.unit.test.d.ts +0 -2
  120. package/dist/__tests__/scriptable-pack.unit.test.d.ts.map +0 -1
  121. package/dist/config.d.ts +0 -6
  122. package/dist/config.d.ts.map +0 -1
  123. package/dist/config.mjs +0 -29
  124. package/dist/config.mjs.map +0 -1
  125. package/src/__tests__/load-asset-config.test.ts +0 -121
  126. package/src/__tests__/scriptable-pack.test-d.ts +0 -131
  127. package/src/__tests__/scriptable-pack.unit.test.ts +0 -789
  128. package/src/config.ts +0 -36
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { type AuthorInventory, validateAuthorInventory } from '../inventory/declaration.js';
3
+
4
+ const guid = '00000000-0000-0000-0000-000000000010';
5
+
6
+ describe('author inventory declaration contract', () => {
7
+ it('requires one semantic sourceKey for every author row', () => {
8
+ const result = validateAuthorInventory({
9
+ declarations: [{ guid, kind: 'mesh', payload: {}, refs: [] }],
10
+ });
11
+
12
+ expect(result.ok).toBe(false);
13
+ if (!result.ok) {
14
+ expect(result.error.code).toBe('inventory-source-key-missing');
15
+ expect(result.error.detail.sourceKey).toBeUndefined();
16
+ expect(result.error.expected).toContain('sourceKey');
17
+ }
18
+ });
19
+
20
+ it('rejects duplicate GUID and sourceKey before projection', () => {
21
+ const rows: AuthorInventory = {
22
+ declarations: [
23
+ { guid, sourceKey: 'hero/body', kind: 'mesh', payload: {}, refs: [] },
24
+ { guid, sourceKey: 'hero/body', kind: 'material', payload: {}, refs: [] },
25
+ ],
26
+ };
27
+ const result = validateAuthorInventory(rows);
28
+
29
+ expect(result.ok).toBe(false);
30
+ if (!result.ok) {
31
+ expect(['inventory-guid-duplicate', 'inventory-source-key-duplicate']).toContain(
32
+ result.error.code,
33
+ );
34
+ expect(result.error.detail).toHaveProperty('guid');
35
+ expect(result.error.hint).toContain('rename');
36
+ }
37
+ });
38
+
39
+ it('rejects GUID duplicates regardless of hex casing and padded source keys', () => {
40
+ const result = validateAuthorInventory({
41
+ declarations: [
42
+ { guid, sourceKey: 'hero/body', kind: 'mesh', payload: {}, refs: [] },
43
+ {
44
+ guid: guid.toUpperCase(),
45
+ sourceKey: 'hero/other',
46
+ kind: 'mesh',
47
+ payload: {},
48
+ refs: [],
49
+ },
50
+ ],
51
+ });
52
+ expect(result).toMatchObject({ ok: false, error: { code: 'inventory-guid-duplicate' } });
53
+
54
+ const padded = validateAuthorInventory({
55
+ declarations: [{ guid, sourceKey: ' hero/body', kind: 'mesh', payload: {}, refs: [] }],
56
+ });
57
+ expect(padded).toMatchObject({ ok: false, error: { code: 'inventory-source-key-missing' } });
58
+ });
59
+
60
+ it('projects declared scene binding keys without using display names', () => {
61
+ const result = validateAuthorInventory({
62
+ declarations: [
63
+ {
64
+ guid,
65
+ sourceKey: 'world/main',
66
+ kind: 'scene',
67
+ payload: {
68
+ kind: 'scene',
69
+ entities: [
70
+ { localId: 0, bindingKey: 'player', components: { Name: { value: 'Renamed' } } },
71
+ { localId: 1, bindingKey: 'camera', components: {} },
72
+ ],
73
+ },
74
+ refs: [],
75
+ },
76
+ ],
77
+ });
78
+
79
+ expect(result).toMatchObject({
80
+ ok: true,
81
+ value: { declarations: [{ sceneBindings: ['player', 'camera'] }] },
82
+ });
83
+ });
84
+
85
+ it('allows decorative scene entities without a gameplay binding', () => {
86
+ const result = validateAuthorInventory({
87
+ declarations: [
88
+ {
89
+ guid,
90
+ sourceKey: 'world/decorative',
91
+ kind: 'scene',
92
+ payload: {
93
+ kind: 'scene',
94
+ entities: [
95
+ { localId: 0, components: { Transform: { position: [0, 0, 0] } } },
96
+ { localId: 1, bindingKey: 'player', components: {} },
97
+ ],
98
+ },
99
+ refs: [],
100
+ },
101
+ ],
102
+ });
103
+
104
+ expect(result).toMatchObject({
105
+ ok: true,
106
+ value: { declarations: [{ sceneBindings: ['player'] }] },
107
+ });
108
+ });
109
+ });
@@ -1970,7 +1970,7 @@ const V1_WHITELIST = new Set([
1970
1970
  expect(result.ok).toBe(true);
1971
1971
  });
1972
1972
 
1973
- it('AC-17: explicit source=hero.png + companion file → scan passes (backward compat)', async () => {
1973
+ it('AC-17: explicit source=hero.png + companion file → scan passes', async () => {
1974
1974
  await writeFile(join(tmpDir, 'hero.png.meta.json'), makeMeta({ source: 'hero.png' }));
1975
1975
  await writeFile(join(tmpDir, 'hero.png'), Buffer.from('fake png'));
1976
1976
 
@@ -1978,241 +1978,4 @@ const V1_WHITELIST = new Set([
1978
1978
  expect(result.ok).toBe(true);
1979
1979
  });
1980
1980
  });
1981
-
1982
- describe('w15-scanner-path-integration.test.ts — AC-5+AC-10', () => {
1983
- let tmpDir: string;
1984
-
1985
- beforeEach(async () => {
1986
- tmpDir = await mkdtemp(join(tmpdir(), 'forgeax-ac5-'));
1987
- });
1988
- afterEach(async () => {
1989
- await rm(tmpDir, { recursive: true, force: true });
1990
- });
1991
-
1992
- it('AC-5: @name/ path resolves correctly when file exists', async () => {
1993
- const libDir = join(tmpDir, 'lib');
1994
- await mkdir(libDir);
1995
- await writeFile(join(libDir, 'shared.png'), Buffer.from('fake png'));
1996
- const metaContent = JSON.stringify({
1997
- schemaVersion: '1.0.0',
1998
- kind: 'external-asset-package',
1999
- importer: 'image',
2000
- source: '@shared/shared.png',
2001
- importSettings: {},
2002
- subAssets: [
2003
- { guid: 'aaaaaaaa-bbbb-4000-8000-000000000001', sourceIndex: 0, kind: 'texture' },
2004
- ],
2005
- });
2006
- await writeFile(join(tmpDir, 'ref.meta.json'), metaContent);
2007
-
2008
- // The scan runs inside its own process.cwd() — paths table resolution uses cwd
2009
- // The tmpDir is not the cwd, so @shared/ will report pack-unknown-path.
2010
- // AC-5: we need the path table to be configured at the scan's cwd.
2011
- // For a unit test, we verify instead via resolveAssetSource directly:
2012
- const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2013
- const paths = { shared: libDir };
2014
- const result = resolveAS(join(tmpDir, 'ref.meta.json'), '@shared/shared.png', paths);
2015
- expect(result.ok).toBe(true);
2016
- if (result.ok) {
2017
- expect(result.value).toBe(join(libDir, 'shared.png'));
2018
- }
2019
- });
2020
-
2021
- it('AC-10: path configured but file missing → pack-orphan-meta', async () => {
2022
- const libDir = join(tmpDir, 'lib');
2023
- await mkdir(libDir);
2024
- // Do NOT create missing.png in libDir
2025
- const metaContent = JSON.stringify({
2026
- schemaVersion: '1.0.0',
2027
- kind: 'external-asset-package',
2028
- importer: 'image',
2029
- source: '@shared/missing.png',
2030
- importSettings: {},
2031
- subAssets: [
2032
- { guid: 'aaaaaaaa-bbbb-4000-8000-000000000001', sourceIndex: 0, kind: 'texture' },
2033
- ],
2034
- });
2035
- await writeFile(join(tmpDir, 'ref.meta.json'), metaContent);
2036
-
2037
- // resolveAssetSource resolves the path but does not stat — the resolved path will be returned ok
2038
- const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2039
- const paths = { shared: libDir };
2040
- const result = resolveAS(join(tmpDir, 'ref.meta.json'), '@shared/missing.png', paths);
2041
- expect(result.ok).toBe(true);
2042
- if (result.ok) {
2043
- expect(result.value).toBe(join(libDir, 'missing.png'));
2044
- // The actual orphan check (stat) is done by the scanner caller
2045
- }
2046
- });
2047
-
2048
- it('AC-10: scanner reports pack-orphan-meta when resolved path file does not exist', async () => {
2049
- const metaContent = makeMeta({ source: 'nowhere.png' });
2050
- await writeFile(join(tmpDir, 'orphan.meta.json'), metaContent);
2051
- // Do NOT create nowhere.png
2052
-
2053
- const result = await scan([tmpDir]);
2054
- expect(result.ok).toBe(false);
2055
- if (!result.ok) {
2056
- expect(result.error.code).toBe('pack-orphan-meta');
2057
- if (result.error.code === 'pack-orphan-meta') {
2058
- expect(result.error.detail.expectedFile).toContain('nowhere.png');
2059
- }
2060
- }
2061
- });
2062
- });
2063
-
2064
- describe('scanner @name/ end-to-end via cwd package.json — AC-5/AC-8', () => {
2065
- let tmpDir: string;
2066
- let originalCwd: string;
2067
-
2068
- beforeEach(async () => {
2069
- originalCwd = process.cwd();
2070
- tmpDir = await mkdtemp(join(tmpdir(), 'forgeax-ac5e2e-'));
2071
- process.chdir(tmpDir);
2072
- });
2073
- afterEach(async () => {
2074
- process.chdir(originalCwd);
2075
- await rm(tmpDir, { recursive: true, force: true });
2076
- });
2077
-
2078
- it('AC-5: scan() passes orphan check for @name/ meta when cwd package.json path table resolves to an existing file', async () => {
2079
- const sharedDir = join(tmpDir, 'shared-lib', 'assets');
2080
- await mkdir(sharedDir, { recursive: true });
2081
- await writeFile(join(sharedDir, 'cross.png'), Buffer.from('fake png'));
2082
- await writeFile(
2083
- join(tmpDir, 'package.json'),
2084
- JSON.stringify({
2085
- name: 'ac5-e2e-tmp',
2086
- forgeax: { assets: { paths: { shared: 'shared-lib/assets' } } },
2087
- }),
2088
- );
2089
- const scanRoot = join(tmpDir, 'metas');
2090
- await mkdir(scanRoot, { recursive: true });
2091
- await writeFile(
2092
- join(scanRoot, 'cross.png.meta.json'),
2093
- JSON.stringify({
2094
- schemaVersion: '1.0.0',
2095
- kind: 'external-asset-package',
2096
- importer: 'image',
2097
- source: '@shared/cross.png',
2098
- importSettings: {},
2099
- subAssets: [
2100
- { guid: 'aaaaaaaa-bbbb-4000-8000-000000000001', sourceIndex: 0, kind: 'texture' },
2101
- ],
2102
- }),
2103
- );
2104
-
2105
- const result = await scan([scanRoot]);
2106
- expect(result.ok).toBe(true);
2107
- });
2108
-
2109
- it('AC-8: scan() reports pack-unknown-path when @name/ references an undeclared path name', async () => {
2110
- await writeFile(
2111
- join(tmpDir, 'package.json'),
2112
- JSON.stringify({ name: 'ac8-e2e-tmp', forgeax: { assets: { paths: {} } } }),
2113
- );
2114
- const scanRoot = join(tmpDir, 'metas');
2115
- await mkdir(scanRoot, { recursive: true });
2116
- await writeFile(
2117
- join(scanRoot, 'x.png.meta.json'),
2118
- JSON.stringify({
2119
- schemaVersion: '1.0.0',
2120
- kind: 'external-asset-package',
2121
- importer: 'image',
2122
- source: '@nope/x.png',
2123
- importSettings: {},
2124
- subAssets: [
2125
- { guid: 'aaaaaaaa-bbbb-4000-8000-000000000001', sourceIndex: 0, kind: 'texture' },
2126
- ],
2127
- }),
2128
- );
2129
-
2130
- const result = await scan([scanRoot]);
2131
- expect(result.ok).toBe(false);
2132
- if (!result.ok) {
2133
- expect(result.error.code).toBe('pack-unknown-path');
2134
- }
2135
- });
2136
- });
2137
- }
2138
-
2139
- {
2140
- // ─── AC-1 regression w15 — resolveAssetSource explicit source backwards compat ───
2141
-
2142
- describe('w15-AC-1-regression.test.ts', () => {
2143
- it('AC-1: explicit source=foo.png with resolveAssetSource == resolve(metaDir, foo.png)', async () => {
2144
- const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2145
- const { resolve } = await import('node:path');
2146
- const metaPath = '/home/user/assets/foo.png.meta.json';
2147
- const result = resolveAS(metaPath, 'foo.png', {});
2148
- expect(result.ok).toBe(true);
2149
- if (result.ok) {
2150
- expect(result.value).toBe(resolve('/home/user/assets', 'foo.png'));
2151
- }
2152
- });
2153
-
2154
- it('AC-1: explicit source=sub/deep.png with resolveAssetSource == resolve(metaDir, sub/deep.png)', async () => {
2155
- const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2156
- const { resolve } = await import('node:path');
2157
- const metaPath = '/home/user/assets/main.meta.json';
2158
- const result = resolveAS(metaPath, 'sub/deep.png', {});
2159
- expect(result.ok).toBe(true);
2160
- if (result.ok) {
2161
- expect(result.value).toBe(resolve('/home/user/assets', 'sub/deep.png'));
2162
- }
2163
- });
2164
-
2165
- it('AC-1: omitted source derives from meta filename', async () => {
2166
- const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2167
- const { resolve } = await import('node:path');
2168
- const metaPath = '/home/user/assets/foo.png.meta.json';
2169
- const result = resolveAS(metaPath, undefined, {});
2170
- expect(result.ok).toBe(true);
2171
- if (result.ok) {
2172
- expect(result.value).toBe(resolve('/home/user/assets', 'foo.png'));
2173
- }
2174
- });
2175
- });
2176
-
2177
- // ─── AC-5/AC-10 source path table scanner integration (w15) ───
2178
-
2179
- describe('w15-AC-5-AC-10-path-table.test.ts', () => {
2180
- it('AC-5: @shared/ resource resolves correctly when path table configured', async () => {
2181
- const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2182
- const { resolve } = await import('node:path');
2183
- const metaPath = '/project/assets/ref.meta.json';
2184
- const paths = { shared: '/project/lib' };
2185
- const result = resolveAS(metaPath, '@shared/tex.png', paths);
2186
- expect(result.ok).toBe(true);
2187
- if (result.ok) {
2188
- expect(result.value).toBe(resolve('/project/lib', 'tex.png'));
2189
- }
2190
- });
2191
-
2192
- it('AC-5: @shared/ with nested rest path', async () => {
2193
- const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2194
- const { resolve } = await import('node:path');
2195
- const metaPath = '/project/assets/ref.meta.json';
2196
- const paths = { shared: '/project/lib' };
2197
- const result = resolveAS(metaPath, '@shared/sub/dir/tex.png', paths);
2198
- expect(result.ok).toBe(true);
2199
- if (result.ok) {
2200
- expect(result.value).toBe(resolve('/project/lib', 'sub/dir/tex.png'));
2201
- }
2202
- });
2203
-
2204
- it('AC-10: @unknown/ path name → pack-unknown-path error', async () => {
2205
- const { resolveAssetSource: resolveAS } = await import('../resolve-asset-source.js');
2206
- const metaPath = '/project/assets/ref.meta.json';
2207
- const result = resolveAS(metaPath, '@nope/tex.png', {});
2208
- expect(result.ok).toBe(false);
2209
- if (!result.ok) {
2210
- expect(result.error.code).toBe('pack-unknown-path');
2211
- if (result.error.code === 'pack-unknown-path') {
2212
- expect(result.error.detail.pathName).toBe('nope');
2213
- expect(result.error.detail.knownNames).toEqual([]);
2214
- }
2215
- }
2216
- });
2217
- });
2218
1981
  }
@@ -102,4 +102,59 @@ describe('engine-pack terminal product and finalizer contract', () => {
102
102
  expect(published.assets[0]?.artifacts.body.byteLength).toBe(body.byteLength);
103
103
  expect(writes.get(`${GUID}/body.bin`)).toBe(body);
104
104
  });
105
+
106
+ it('publishes a mesh LOD relation and keeps the lower mesh in the closure', async () => {
107
+ const lodGuid = '019e3969-1d48-7c3b-ac24-6d68f4570660';
108
+ const root = product().assets[0];
109
+ if (root === undefined) throw new Error('test product asset is missing');
110
+ const result = await finalizePackageProduct(
111
+ product({
112
+ assets: [
113
+ {
114
+ ...root,
115
+ kind: 'mesh',
116
+ payload: {
117
+ kind: 'mesh',
118
+ lods: [{ mesh: lodGuid, screenCoverage: 0.5 }],
119
+ },
120
+ refs: [lodGuid],
121
+ },
122
+ {
123
+ ...root,
124
+ guid: lodGuid,
125
+ kind: 'mesh',
126
+ payload: { kind: 'mesh' },
127
+ refs: [],
128
+ },
129
+ ],
130
+ receipts: [
131
+ {
132
+ guid: GUID,
133
+ origin: 'sourceMeta',
134
+ status: 'succeeded',
135
+ inputFingerprint: 'sha256:source',
136
+ },
137
+ {
138
+ guid: lodGuid,
139
+ origin: 'sourceMeta',
140
+ status: 'succeeded',
141
+ inputFingerprint: 'sha256:source',
142
+ },
143
+ ],
144
+ }),
145
+ {
146
+ base: '/',
147
+ packagePath: 'mesh.pack.json',
148
+ artifactPath: (guid, key) => `${guid}/${key}.bin`,
149
+ },
150
+ );
151
+ expect(result.ok).toBe(true);
152
+ if (!result.ok) return;
153
+ const publishedRoot = result.value.pack.assets.find((asset) => asset.guid === GUID);
154
+ expect(publishedRoot?.refs).toEqual([lodGuid]);
155
+ expect(publishedRoot?.payload).toMatchObject({
156
+ lods: [{ mesh: lodGuid, screenCoverage: 0.5 }],
157
+ });
158
+ expect(result.value.pack.assets.map((asset) => asset.guid)).toContain(lodGuid);
159
+ });
105
160
  });
@@ -0,0 +1,249 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { AssetGuid, PackageId } from '../guid.js';
6
+ import { createFileSystemParameterizedPackAuthoringGateway } from '../parameterized-pack-node.js';
7
+
8
+ const DIRECT_PACKAGE = '01900000-0000-7000-8000-000000000070';
9
+ const CLONE_PACKAGE = '01900000-0000-7000-8000-000000000071';
10
+ const SOURCE_PACKAGE = '01900000-0000-7000-8000-000000000072';
11
+ const INSTANCE_PACKAGE = '01900000-0000-7000-8000-000000000073';
12
+
13
+ const roots: string[] = [];
14
+
15
+ function packageId(value: string) {
16
+ const parsed = PackageId.parse(value);
17
+ if (!parsed.ok) throw parsed.error;
18
+ return parsed.value;
19
+ }
20
+
21
+ afterEach(async () => {
22
+ const root = roots.pop();
23
+ if (root !== undefined) await rm(root, { recursive: true, force: true });
24
+ });
25
+
26
+ describe('parameterized Pack authoring gateway', () => {
27
+ it('writes guid-less direct JSON, derives identity, and makes mutations idempotent', async () => {
28
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-pack-gateway-direct-'));
29
+ roots.push(root);
30
+ const gateway = createFileSystemParameterizedPackAuthoringGateway({ gameRoot: root });
31
+ const operation = {
32
+ operation: 'asset-source.create' as const,
33
+ requestId: 'create-direct',
34
+ targetPath: 'assets/direct.pack.json',
35
+ format: 'pack.json' as const,
36
+ packageId: DIRECT_PACKAGE,
37
+ initialAssets: {
38
+ 'mesh/main': { kind: 'mesh', payload: { vertices: [] }, refs: [] },
39
+ },
40
+ };
41
+
42
+ const created = await gateway.execute(operation);
43
+ expect(created).toMatchObject({
44
+ ok: true,
45
+ value: { packageId: DIRECT_PACKAGE, format: 'pack.json', targetPath: operation.targetPath },
46
+ });
47
+ const raw = JSON.parse(await readFile(join(root, operation.targetPath), 'utf8')) as Record<
48
+ string,
49
+ unknown
50
+ >;
51
+ expect(raw).toEqual({
52
+ schemaVersion: '3.0.0',
53
+ packageId: DIRECT_PACKAGE,
54
+ assets: { 'mesh/main': { kind: 'mesh', payload: { vertices: [] }, refs: [] } },
55
+ });
56
+ expect(JSON.stringify(raw)).not.toContain('guid');
57
+
58
+ const replay = await gateway.execute(operation);
59
+ expect(replay).toEqual(created);
60
+ const conflictingReplay = await gateway.execute({ ...operation, initialAssets: {} });
61
+ expect(conflictingReplay).toMatchObject({
62
+ ok: false,
63
+ error: { code: 'pack-source-revision-conflict' },
64
+ });
65
+
66
+ const unresolved = await gateway.execute({
67
+ operation: 'asset.resolve',
68
+ requestId: 'resolve-direct',
69
+ packageId: DIRECT_PACKAGE,
70
+ sourceKey: 'mesh/main',
71
+ require: 'ready',
72
+ });
73
+ expect(unresolved).toMatchObject({ ok: false, error: { code: 'asset-not-ready' } });
74
+
75
+ const publishedGateway = createFileSystemParameterizedPackAuthoringGateway({
76
+ gameRoot: root,
77
+ materialized: () => [
78
+ {
79
+ packageId: DIRECT_PACKAGE,
80
+ sourceKey: 'mesh/main',
81
+ guid: AssetGuid.format(AssetGuid.derive(packageId(DIRECT_PACKAGE), 'mesh/main')),
82
+ kind: 'mesh',
83
+ sourcePath: 'dist/pack.json',
84
+ ready: true,
85
+ artifactsReady: true,
86
+ },
87
+ ],
88
+ });
89
+ const resolved = await publishedGateway.execute({
90
+ operation: 'asset.resolve',
91
+ requestId: 'resolve-direct-published',
92
+ packageId: DIRECT_PACKAGE,
93
+ sourceKey: 'mesh/main',
94
+ require: 'ready',
95
+ });
96
+ expect(resolved).toMatchObject({
97
+ ok: true,
98
+ value: {
99
+ packageId: DIRECT_PACKAGE,
100
+ sourceKey: 'mesh/main',
101
+ status: 'ready',
102
+ guid: AssetGuid.format(AssetGuid.derive(packageId(DIRECT_PACKAGE), 'mesh/main')),
103
+ },
104
+ });
105
+
106
+ const clone = await gateway.execute({
107
+ operation: 'asset-source.clone',
108
+ requestId: 'clone-direct',
109
+ sourcePath: operation.targetPath,
110
+ targetPath: 'assets/clone.pack.json',
111
+ packageId: CLONE_PACKAGE,
112
+ });
113
+ expect(clone).toMatchObject({ ok: true, value: { packageId: CLONE_PACKAGE } });
114
+ const cloneResolved = await gateway.execute({
115
+ operation: 'asset.resolve',
116
+ requestId: 'resolve-clone',
117
+ packageId: CLONE_PACKAGE,
118
+ sourceKey: 'mesh/main',
119
+ require: 'identity',
120
+ });
121
+ expect(cloneResolved).toMatchObject({ ok: true, value: { status: 'identity' } });
122
+ });
123
+
124
+ it('creates parameterized source and instance documents with CAS-protected value edits', async () => {
125
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-pack-gateway-instance-'));
126
+ roots.push(root);
127
+ const gateway = createFileSystemParameterizedPackAuthoringGateway({ gameRoot: root });
128
+ const source = await gateway.execute({
129
+ operation: 'asset-source.create',
130
+ requestId: 'create-source',
131
+ targetPath: 'assets/source.pack.ts',
132
+ format: 'pack.ts',
133
+ packageId: SOURCE_PACKAGE,
134
+ parameters: [{ name: 'count', type: 'u32', default: 1, minimum: 1 }],
135
+ });
136
+ expect(source).toMatchObject({
137
+ ok: true,
138
+ value: { packageId: SOURCE_PACKAGE, format: 'pack.ts' },
139
+ });
140
+ const sourceText = await readFile(join(root, 'assets/source.pack.ts'), 'utf8');
141
+ expect(sourceText).toContain(`definePackageId("${SOURCE_PACKAGE}")`);
142
+ expect(sourceText).not.toContain('assets:');
143
+
144
+ const listed = await gateway.execute({ operation: 'asset.list', requestId: 'list-source' });
145
+ expect(listed).toMatchObject({
146
+ ok: true,
147
+ value: { sources: [{ packageId: SOURCE_PACKAGE, sourcePath: 'assets/source.pack.ts' }] },
148
+ });
149
+
150
+ const instance = await gateway.execute({
151
+ operation: 'asset-source.create-instance',
152
+ requestId: 'create-instance',
153
+ targetPath: 'assets/instance.pack.json',
154
+ packageId: INSTANCE_PACKAGE,
155
+ parentPackageId: SOURCE_PACKAGE,
156
+ values: { count: 4 },
157
+ });
158
+ expect(instance).toMatchObject({
159
+ ok: true,
160
+ value: {
161
+ packageId: INSTANCE_PACKAGE,
162
+ parentPackageId: SOURCE_PACKAGE,
163
+ effectiveValues: { count: 4 },
164
+ },
165
+ });
166
+ if (!instance.ok) return;
167
+ const instanceRevision = instance.value.revision;
168
+ if (instanceRevision === undefined)
169
+ throw new Error('create-instance did not return a revision');
170
+ const applied = await gateway.execute({
171
+ operation: 'asset-source.apply-values',
172
+ requestId: 'apply-values',
173
+ sourcePath: 'assets/instance.pack.json',
174
+ expectedRevision: instanceRevision,
175
+ values: { count: 5 },
176
+ });
177
+ expect(applied).toMatchObject({ ok: true, value: { effectiveValues: { count: 5 } } });
178
+ const stale = await gateway.execute({
179
+ operation: 'asset-source.apply-values',
180
+ requestId: 'apply-stale',
181
+ sourcePath: 'assets/instance.pack.json',
182
+ expectedRevision: instanceRevision,
183
+ values: { count: 6 },
184
+ });
185
+ expect(stale).toMatchObject({ ok: false, error: { code: 'pack-source-revision-conflict' } });
186
+ });
187
+
188
+ it('fails closed when the current materialized projection violates derived identity', async () => {
189
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-pack-gateway-projection-'));
190
+ roots.push(root);
191
+ const gateway = createFileSystemParameterizedPackAuthoringGateway({
192
+ gameRoot: root,
193
+ materialized: () => [
194
+ {
195
+ packageId: DIRECT_PACKAGE,
196
+ sourceKey: 'mesh/main',
197
+ guid: AssetGuid.format(AssetGuid.derive(packageId(DIRECT_PACKAGE), 'mesh/other')),
198
+ kind: 'mesh',
199
+ ready: true,
200
+ },
201
+ ],
202
+ });
203
+
204
+ const result = await gateway.execute({ operation: 'asset.list', requestId: 'bad-projection' });
205
+ expect(result).toMatchObject({
206
+ ok: false,
207
+ error: {
208
+ code: 'pack-guid-collision',
209
+ detail: {
210
+ expectedGuid: AssetGuid.format(AssetGuid.derive(packageId(DIRECT_PACKAGE), 'mesh/main')),
211
+ },
212
+ },
213
+ });
214
+ });
215
+
216
+ it('does not fall back from a missing sourcePath to another package locator', async () => {
217
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-pack-gateway-locator-'));
218
+ roots.push(root);
219
+ const gateway = createFileSystemParameterizedPackAuthoringGateway({ gameRoot: root });
220
+ await gateway.execute({
221
+ operation: 'asset-source.create',
222
+ requestId: 'create-locator-source',
223
+ targetPath: 'assets/direct.pack.json',
224
+ format: 'pack.json',
225
+ packageId: DIRECT_PACKAGE,
226
+ initialAssets: {},
227
+ });
228
+ const result = await gateway.execute({
229
+ operation: 'asset.inspect',
230
+ requestId: 'inspect-missing-locator',
231
+ sourcePath: 'assets/missing.pack.json',
232
+ packageId: DIRECT_PACKAGE,
233
+ });
234
+ expect(result).toMatchObject({ ok: false, error: { code: 'pack-source-not-found' } });
235
+ });
236
+
237
+ it('converts a broken materialized projection callback into a structured error', async () => {
238
+ const root = await mkdtemp(join(tmpdir(), 'forgeax-pack-gateway-projection-callback-'));
239
+ roots.push(root);
240
+ const gateway = createFileSystemParameterizedPackAuthoringGateway({
241
+ gameRoot: root,
242
+ materialized: () => {
243
+ throw new Error('projection unavailable');
244
+ },
245
+ });
246
+ const result = await gateway.execute({ operation: 'asset.list', requestId: 'bad-callback' });
247
+ expect(result).toMatchObject({ ok: false, error: { code: 'pack-parameter-invalid' } });
248
+ });
249
+ });