@waica/cli 0.4.2 → 0.6.0

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 (85) hide show
  1. package/README.md +42 -37
  2. package/dist/editor/assets/{cssMode-BO8FrG9g.js → cssMode-DfLN2qUF.js} +1 -1
  3. package/dist/editor/assets/{freemarker2-C6rCz24J.js → freemarker2-BM-wWcXR.js} +1 -1
  4. package/dist/editor/assets/{handlebars-MZdu9Qw_.js → handlebars-C68SV5M9.js} +1 -1
  5. package/dist/editor/assets/{html-BrjX1TJ1.js → html-dQgLbIki.js} +1 -1
  6. package/dist/editor/assets/{htmlMode-cEvpMwiW.js → htmlMode-Dx91fUdd.js} +1 -1
  7. package/dist/editor/assets/{index-eTEBznqW.js → index-592ABIaz.js} +7154 -3186
  8. package/dist/editor/assets/{index-Btzc6zxr.css → index-Cy5pmBhN.css} +1 -1
  9. package/dist/editor/assets/{javascript-DHUgp9_L.js → javascript-BmsSq5ka.js} +1 -1
  10. package/dist/editor/assets/{jsonMode-D0PmIuaM.js → jsonMode-CWoPtX2M.js} +1 -1
  11. package/dist/editor/assets/{liquid-UM67NCct.js → liquid-DwnJlNJZ.js} +1 -1
  12. package/dist/editor/assets/{mdx-D3xXLqrP.js → mdx-DaLkvlR1.js} +1 -1
  13. package/dist/editor/assets/{python-BhLrcAva.js → python-Cs5X5zFp.js} +1 -1
  14. package/dist/editor/assets/{razor-DcG373mQ.js → razor-PTwWoRk8.js} +1 -1
  15. package/dist/editor/assets/{tsMode-ByTYaf45.js → tsMode-BqUQdgsR.js} +1 -1
  16. package/dist/editor/assets/{typescript-DfJlvcTN.js → typescript-BsJ7r2RJ.js} +1 -1
  17. package/dist/editor/assets/{xml-22dL0oCq.js → xml-DyyoPey4.js} +1 -1
  18. package/dist/editor/assets/{yaml-mgCu6yV6.js → yaml-Cjqtlp29.js} +1 -1
  19. package/dist/editor/index.html +2 -2
  20. package/dist/mcp/component-metadata.js +23 -0
  21. package/dist/mcp/introspection.js +4 -14
  22. package/dist/mcp/node_modules/@waica/archetype-platformer/dist/prefabs.js +7 -1
  23. package/dist/mcp/node_modules/@waica/archetype-platformer/dist/registry-data.js +3 -1
  24. package/dist/mcp/node_modules/@waica/archetype-platformer/package.json +3 -3
  25. package/dist/mcp/node_modules/@waica/behaviors/dist/chaser.d.ts +1 -0
  26. package/dist/mcp/node_modules/@waica/behaviors/dist/chaser.js +1 -0
  27. package/dist/mcp/node_modules/@waica/behaviors/dist/collectible.d.ts +1 -0
  28. package/dist/mcp/node_modules/@waica/behaviors/dist/collectible.js +1 -1
  29. package/dist/mcp/node_modules/@waica/behaviors/dist/hazard.d.ts +42 -1
  30. package/dist/mcp/node_modules/@waica/behaviors/dist/hazard.js +49 -3
  31. package/dist/mcp/node_modules/@waica/behaviors/dist/health.d.ts +82 -0
  32. package/dist/mcp/node_modules/@waica/behaviors/dist/health.js +147 -0
  33. package/dist/mcp/node_modules/@waica/behaviors/dist/index.d.ts +2 -0
  34. package/dist/mcp/node_modules/@waica/behaviors/dist/index.js +2 -0
  35. package/dist/mcp/node_modules/@waica/behaviors/dist/lifetime.d.ts +1 -0
  36. package/dist/mcp/node_modules/@waica/behaviors/dist/lifetime.js +1 -0
  37. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.d.ts +30 -0
  38. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.js +38 -0
  39. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.d.ts +1 -0
  40. package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.js +1 -0
  41. package/dist/mcp/node_modules/@waica/behaviors/dist/platformer-motor.d.ts +1 -0
  42. package/dist/mcp/node_modules/@waica/behaviors/dist/platformer-motor.js +10 -0
  43. package/dist/mcp/node_modules/@waica/behaviors/dist/player-states.js +26 -0
  44. package/dist/mcp/node_modules/@waica/behaviors/dist/respawnable.d.ts +5 -13
  45. package/dist/mcp/node_modules/@waica/behaviors/dist/respawnable.js +5 -11
  46. package/dist/mcp/node_modules/@waica/behaviors/package.json +2 -2
  47. package/dist/mcp/node_modules/@waica/engine/README.md +85 -0
  48. package/dist/mcp/node_modules/@waica/engine/dist/authoring-defaults.d.ts +11 -0
  49. package/dist/mcp/node_modules/@waica/engine/dist/authoring-defaults.js +51 -0
  50. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.d.ts +39 -0
  51. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.js +157 -0
  52. package/dist/mcp/node_modules/@waica/engine/dist/component.d.ts +15 -1
  53. package/dist/mcp/node_modules/@waica/engine/dist/component.js +2 -0
  54. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.d.ts +2 -0
  55. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.js +11 -0
  56. package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.d.ts +1 -0
  57. package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.js +1 -0
  58. package/dist/mcp/node_modules/@waica/engine/dist/game.d.ts +7 -0
  59. package/dist/mcp/node_modules/@waica/engine/dist/game.js +79 -1
  60. package/dist/mcp/node_modules/@waica/engine/dist/index.d.ts +8 -1
  61. package/dist/mcp/node_modules/@waica/engine/dist/index.js +4 -0
  62. package/dist/mcp/node_modules/@waica/engine/dist/input.d.ts +10 -0
  63. package/dist/mcp/node_modules/@waica/engine/dist/input.js +40 -2
  64. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.d.ts +69 -0
  65. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.js +105 -0
  66. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.d.ts +71 -0
  67. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.js +279 -0
  68. package/dist/mcp/node_modules/@waica/engine/dist/state/state-machine.d.ts +2 -1
  69. package/dist/mcp/node_modules/@waica/engine/dist/state/state-machine.js +7 -1
  70. package/dist/mcp/node_modules/@waica/engine/package.json +3 -2
  71. package/dist/mcp/package-resolver.js +10 -2
  72. package/dist/mcp/project-component-loader.js +553 -0
  73. package/dist/mcp/project-component-runner.js +237 -0
  74. package/dist/mcp/runtime-browser.js +336 -0
  75. package/dist/mcp/runtime-dev-server.js +273 -0
  76. package/dist/mcp/runtime-preflight.js +249 -0
  77. package/dist/mcp/runtime-service.js +8 -0
  78. package/dist/mcp/runtime-session-manager.js +265 -0
  79. package/dist/mcp/scaffolds.js +126 -6
  80. package/dist/mcp/server.js +330 -9
  81. package/dist/mcp/template/src/characters/player.character.json +29 -2
  82. package/dist/mcp/template/src/characters/slime.character.json +6 -0
  83. package/dist/mcp/template/src/main.ts +6 -1
  84. package/dist/mcp/validation.js +240 -24
  85. package/package.json +3 -2
@@ -1,7 +1,10 @@
1
+ import { resolveComponentUpdateSchedule, } from '@waica/engine';
1
2
  import { readFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { discoverArchetypes, pickArchetype } from './archetypes.js';
5
+ import { classDefaults, objectRecord } from './component-metadata.js';
4
6
  import { PackageResolver, mixedSourceWarnings, provenanceRows, } from './package-resolver.js';
7
+ import { loadProjectComponents, } from './project-component-loader.js';
5
8
  import { directFiles, requireWaicaProject } from './project-path.js';
6
9
  function add(context, severity, code, message, file, ref) {
7
10
  context.findings.push({ severity, code, message, file, ...(ref ? { ref } : {}) });
@@ -45,10 +48,63 @@ function componentList(value) {
45
48
  return [];
46
49
  return value.filter((entry) => !!entry && typeof entry === 'object' && typeof entry.type === 'string');
47
50
  }
48
- function objectRecord(value) {
49
- return value && typeof value === 'object' && !Array.isArray(value)
50
- ? value
51
- : {};
51
+ function classMetadata(Class) {
52
+ // A throwing constructor has no observable defaults, matching list_components.
53
+ return { Class, params: Class.params ?? {}, defaults: classDefaults(Class) };
54
+ }
55
+ /** Clip names declared by the sibling AnimatedSprite, or undefined when there is none (no animation contract to check). */
56
+ function siblingClips(siblings) {
57
+ const animated = siblings.find((component) => component.type === 'AnimatedSprite');
58
+ return animated ? new Set(Object.keys(objectRecord(animated.props?.clips))) : undefined;
59
+ }
60
+ /** Whether `action` has at least one real binding — an own key, since bindings is keyed by an untrusted string. */
61
+ function isBoundAction(bindings, action) {
62
+ return Object.hasOwn(bindings, action) && (bindings[action]?.length ?? 0) > 0;
63
+ }
64
+ function validateParamReferences(entries, siblings, file, context) {
65
+ const clips = siblingClips(siblings);
66
+ for (const { component, only } of entries) {
67
+ const metadata = context.componentMetadata.get(component.type);
68
+ if (!metadata)
69
+ continue;
70
+ const props = objectRecord(component.props);
71
+ for (const [param, spec] of Object.entries(metadata.params)) {
72
+ if (only && !only.has(param))
73
+ continue;
74
+ if (!spec.ref || spec.options !== undefined)
75
+ continue;
76
+ const value = Object.hasOwn(props, param) ? props[param] : metadata.defaults[param];
77
+ if (typeof value !== 'string' || value === '')
78
+ continue;
79
+ const field = `${component.type}.${param}`;
80
+ switch (spec.ref) {
81
+ case 'prefab':
82
+ if (!context.prefabRefs.has(value)) {
83
+ add(context, 'error', 'broken-prefab-ref', `Component "${component.type}" param "${param}" references missing prefab "${value}".`, file, field);
84
+ }
85
+ break;
86
+ case 'clip':
87
+ if (clips && !clips.has(value)) {
88
+ add(context, 'error', 'missing-clip', `Component "${component.type}" param "${param}" references missing animation clip "${value}".`, file, field);
89
+ }
90
+ break;
91
+ case 'action':
92
+ if (!isBoundAction(context.bindings, value)) {
93
+ add(context,
94
+ // Consistent with the pre-existing state-transition check for the
95
+ // same condition (below): an unbound action is a real gap the
96
+ // agent should look at, but not one that flips ok:false.
97
+ 'warning', 'input-action-unbound', `Component "${component.type}" param "${param}" references unbound input action "${value}".`, file, field);
98
+ }
99
+ break;
100
+ case 'stat':
101
+ if (!context.declaredStats.has(value)) {
102
+ add(context, 'warning', 'undeclared-stat', `Component "${component.type}" param "${param}" references undeclared stat "${value}"; runtime writes may still create it.`, file, field);
103
+ }
104
+ break;
105
+ }
106
+ }
107
+ }
52
108
  }
53
109
  function machineStates(component) {
54
110
  return objectRecord(component.props?.states);
@@ -85,10 +141,7 @@ function stateCodeExists(role, state, context) {
85
141
  (context.roleStateSources.get(role) ?? []).some((source) => roleSourceHasState(source, state)));
86
142
  }
87
143
  function validateStateMachines(components, file, ref, context) {
88
- const animated = components.find((component) => component.type === 'AnimatedSprite');
89
- const clips = animated
90
- ? new Set(Object.keys(objectRecord(animated.props?.clips)))
91
- : undefined;
144
+ const clips = siblingClips(components);
92
145
  for (const machine of components.filter((component) => component.type === 'StateMachine')) {
93
146
  const states = machineStates(machine);
94
147
  const realNames = Object.keys(states).filter((name) => name !== '*');
@@ -98,9 +151,15 @@ function validateStateMachines(components, file, ref, context) {
98
151
  : (realNames[0] ?? '');
99
152
  for (const name of realNames) {
100
153
  const definition = objectRecord(states[name]);
101
- const clip = typeof definition.clip === 'string' ? definition.clip : name;
154
+ const explicitClip = typeof definition.clip === 'string' ? definition.clip : undefined;
155
+ const clip = explicitClip ?? name;
156
+ // No "empty means none" special case here: the runtime looks up
157
+ // `this.states[state]?.clip ?? state`, and '' survives that nullish
158
+ // coalesce, so an explicit empty clip is looked up literally and must
159
+ // be validated like any other explicit value (unlike Collectible.stat,
160
+ // which the runtime genuinely treats as unset).
102
161
  if (clips && !clips.has(clip)) {
103
- add(context, 'warning', 'missing-clip', `State "${name}" uses missing animation clip "${clip}".`, file, ref ?? name);
162
+ add(context, explicitClip === undefined ? 'warning' : 'error', 'missing-clip', `State "${name}" uses missing animation clip "${clip}".`, file, explicitClip === undefined ? (ref ?? name) : `StateMachine.states.${name}.clip`);
104
163
  }
105
164
  const transitions = Array.isArray(definition.transitions) ? definition.transitions : [];
106
165
  for (const transition of transitions) {
@@ -113,7 +172,7 @@ function validateStateMachines(components, file, ref, context) {
113
172
  }
114
173
  if (typeof on === 'string' && on.startsWith('input:')) {
115
174
  const action = on.slice('input:'.length);
116
- if (!context.bindings[action]?.length) {
175
+ if (!isBoundAction(context.bindings, action)) {
117
176
  add(context, 'warning', 'input-action-unbound', `Input action "${action}" has no bindings.`, file, action);
118
177
  }
119
178
  }
@@ -141,11 +200,63 @@ function resolvedEntityComponents(entity, prefab) {
141
200
  }));
142
201
  return [...inherited, ...componentList(entity.components)];
143
202
  }
203
+ function componentUpdateIssueKey(issue) {
204
+ switch (issue.code) {
205
+ case 'duplicate-component':
206
+ return `${issue.code}:${issue.componentName}:${issue.count}`;
207
+ case 'invalid-update-constraint':
208
+ return `${issue.code}:${issue.reason}:${issue.declarer}:${issue.target ?? ''}`;
209
+ case 'component-update-cycle':
210
+ return `${issue.code}:${issue.componentNames.join('\0')}`;
211
+ }
212
+ }
213
+ function componentUpdateIssues(components, context) {
214
+ const result = resolveComponentUpdateSchedule(components.map((component) => component.type), context.componentRegistry);
215
+ return result.ok ? [] : [...result.issues];
216
+ }
217
+ function addComponentUpdateFinding(issue, file, ref, context) {
218
+ let findingFile = file;
219
+ let findingRef = ref;
220
+ if (issue.code === 'invalid-update-constraint') {
221
+ const source = context.componentMetadata.get(issue.declarer)?.sourceFile;
222
+ if (source) {
223
+ const key = `${source}:${componentUpdateIssueKey(issue)}`;
224
+ if (context.reportedClassConstraints.has(key))
225
+ return;
226
+ context.reportedClassConstraints.add(key);
227
+ findingFile = source;
228
+ findingRef = issue.declarer;
229
+ }
230
+ }
231
+ add(context, 'error', issue.code, issue.cause, findingFile, findingRef);
232
+ }
233
+ function validateComponentUpdateSchedule(components, file, ref, context, inheritedIssues = new Set()) {
234
+ for (const issue of componentUpdateIssues(components, context)) {
235
+ if (inheritedIssues.has(componentUpdateIssueKey(issue)))
236
+ continue;
237
+ addComponentUpdateFinding(issue, file, ref, context);
238
+ }
239
+ }
240
+ function validateComponentClassUpdateContracts(context) {
241
+ for (const componentName of Object.keys(context.componentRegistry).sort()) {
242
+ const metadata = context.componentMetadata.get(componentName);
243
+ const result = resolveComponentUpdateSchedule([componentName], context.componentRegistry);
244
+ if (result.ok)
245
+ continue;
246
+ for (const issue of result.issues) {
247
+ if (issue.code !== 'invalid-update-constraint')
248
+ continue;
249
+ addComponentUpdateFinding(issue, metadata?.sourceFile ?? 'package.json', componentName, context);
250
+ }
251
+ }
252
+ }
144
253
  function validatePrefab(prefab, file, ref, context) {
145
254
  const components = componentList(prefab.components);
146
255
  for (const component of components)
147
256
  checkComponent(component, file, ref, context);
257
+ validateParamReferences(components.map((component) => ({ component })), components, file, context);
148
258
  validateStateMachines(components, file, ref, context);
259
+ validateComponentUpdateSchedule(components, file, ref, context);
149
260
  }
150
261
  function validateScene(scene, file, prefabs, uiNames, context) {
151
262
  const rawEntities = Array.isArray(scene.entities) ? scene.entities : [];
@@ -186,6 +297,57 @@ function validateScene(scene, file, prefabs, uiNames, context) {
186
297
  }
187
298
  }
188
299
  }
300
+ // Lazily resolved: only computed when a consumer below actually needs
301
+ // the merged prefab+override component list (an override that changes a
302
+ // ref param, a clip-context change, an inline ref:clip lookup that needs
303
+ // the effective sibling AnimatedSprite, or state-machine revalidation).
304
+ // A plain "prefab: ref" entity with no overrides and no inline
305
+ // components never pays for building it (restores the pre-typed-refs
306
+ // laziness that only ran this for state-behavior changes).
307
+ let cachedEffectiveComponents;
308
+ const effectiveComponents = () => {
309
+ cachedEffectiveComponents ??= resolvedEntityComponents(entity, prefab);
310
+ return cachedEffectiveComponents;
311
+ };
312
+ const hasRefKind = (type, kind) => Object.values(context.componentMetadata.get(type)?.params ?? {}).some((spec) => spec.ref === kind && spec.options === undefined);
313
+ // Undefined scope means "check every declared ref param" — correct for
314
+ // inline components (never validated elsewhere) and for a component a
315
+ // clip-context change forces a full recheck of. A Set scopes the check
316
+ // to only the override's changed params, so a prefab-level finding for
317
+ // a param the override never touched is not reported a second time
318
+ // under the scene file.
319
+ const referenceScopes = new Map();
320
+ for (const component of inline)
321
+ referenceScopes.set(component, undefined);
322
+ const inlineTypes = new Set(inline.map((component) => component.type));
323
+ for (const [type, rawPatch] of Object.entries(overrides)) {
324
+ if (inlineTypes.has(type))
325
+ continue;
326
+ const metadata = context.componentMetadata.get(type);
327
+ const patch = objectRecord(rawPatch);
328
+ const changedRefParams = new Set(Object.entries(metadata?.params ?? {})
329
+ .filter(([param, spec]) => spec.ref !== undefined && spec.options === undefined && Object.hasOwn(patch, param))
330
+ .map(([param]) => param));
331
+ if (changedRefParams.size === 0)
332
+ continue;
333
+ const effective = effectiveComponents().find((component) => component.type === type);
334
+ if (effective && !referenceScopes.has(effective))
335
+ referenceScopes.set(effective, changedRefParams);
336
+ }
337
+ const changesClipContext = inline.some((component) => component.type === 'AnimatedSprite') ||
338
+ Object.hasOwn(overrides, 'AnimatedSprite');
339
+ if (changesClipContext) {
340
+ for (const component of effectiveComponents()) {
341
+ // A clip-context change can affect params this component didn't
342
+ // itself change, so this always widens to a full check rather than
343
+ // narrowing an already-scoped entry from the override loop above.
344
+ if (hasRefKind(component.type, 'clip'))
345
+ referenceScopes.set(component, undefined);
346
+ }
347
+ }
348
+ const needsSiblings = cachedEffectiveComponents !== undefined ||
349
+ [...referenceScopes.keys()].some((component) => hasRefKind(component.type, 'clip'));
350
+ validateParamReferences([...referenceScopes.entries()].map(([component, only]) => ({ component, only })), needsSiblings ? effectiveComponents() : [...referenceScopes.keys()], file, context);
189
351
  // Re-evaluate inherited state behavior only when this entity actually
190
352
  // changes a StateMachine or AnimatedSprite. Unrelated overrides keep the
191
353
  // prefab-level finding as the single source of truth.
@@ -193,7 +355,14 @@ function validateScene(scene, file, prefabs, uiNames, context) {
193
355
  const changesStateBehavior = inline.some((component) => stateTypes.has(component.type)) ||
194
356
  Object.keys(overrides).some((type) => stateTypes.has(type));
195
357
  if (changesStateBehavior) {
196
- validateStateMachines(resolvedEntityComponents(entity, prefab), file, entityRef, context);
358
+ validateStateMachines(effectiveComponents(), file, entityRef, context);
359
+ }
360
+ // A plain prefab instance inherits the prefab-level result already emitted
361
+ // above. Inline components create a new effective composition; report only
362
+ // the issues they introduce, not every inherited issue again.
363
+ if (inline.length > 0) {
364
+ const inheritedIssues = new Set(componentUpdateIssues(componentList(prefab?.components), context).map(componentUpdateIssueKey));
365
+ validateComponentUpdateSchedule(effectiveComponents(), file, entityRef, context, inheritedIssues);
197
366
  }
198
367
  }
199
368
  }
@@ -211,7 +380,8 @@ function statBindings(html) {
211
380
  }
212
381
  return names;
213
382
  }
214
- export async function validateProject(projectPath) {
383
+ export async function validateProject(projectPath, options = {}) {
384
+ options.signal?.throwIfAborted();
215
385
  const check = await requireWaicaProject(projectPath);
216
386
  const findings = [];
217
387
  const fixedPaths = [
@@ -234,15 +404,32 @@ export async function validateProject(projectPath) {
234
404
  : null;
235
405
  const resolver = new PackageResolver(projectPath);
236
406
  const discoveryWarnings = [];
237
- const [engine, behaviors, archetypes, projectComponents, roleStateSources] = await Promise.all([
407
+ const [engine, behaviors, archetypes, projectComponents, roleStateSources, loadedProjectComponents,] = await Promise.all([
238
408
  resolver.load('@waica/engine'),
239
409
  resolver.load('@waica/behaviors'),
240
410
  discoverArchetypes(projectPath, resolver, discoveryWarnings, activeId ? [activeId] : []),
241
411
  projectComponentCandidates(projectPath),
242
412
  projectRoleStateSources(projectPath),
413
+ options.componentLoader
414
+ ? options.componentLoader.load(projectPath, resolver, { signal: options.signal })
415
+ : loadProjectComponents(projectPath, resolver, { signal: options.signal }),
243
416
  ]);
417
+ for (const failure of loadedProjectComponents.failures) {
418
+ add({ findings },
419
+ // component-load-unsupported means Node's strip-only loader cannot run
420
+ // code that can still be perfectly valid in the project's Vite/browser
421
+ // toolchain (asset imports, TS enums, an old Node host) — that is not a
422
+ // project defect, so it must not flip a healthy project to ok:false.
423
+ failure.code === 'component-load-unsupported' ? 'info' : 'error', failure.code, `Cannot execute project module: ${failure.message}`, failure.file);
424
+ }
244
425
  const manifest = pickArchetype(archetypes, activeId, projectPath).manifest;
245
426
  const controls = objectRecord(objectRecord(fixed.get('src/controls.json')).bindings);
427
+ // Bound/unbound is decided from controls.json alone: the shipped runtime
428
+ // installs exactly controls.json's bindings (template main.ts passes
429
+ // controls.bindings raw; engine DEFAULT_BINDINGS is {}), so an action an
430
+ // archetype defines by default but controls.json drops is genuinely
431
+ // unbound at runtime even though discoverArchetypes never guarantees
432
+ // manifest.bindings exists (only manifest.id is validated).
246
433
  const bindings = {};
247
434
  for (const [name, value] of Object.entries(controls)) {
248
435
  if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) {
@@ -250,16 +437,28 @@ export async function validateProject(projectPath) {
250
437
  }
251
438
  }
252
439
  const stateFiles = new Set((await directFiles(path.join(projectPath, 'src/states'), '.ts')).map((file) => file.slice(0, -'.ts'.length)));
253
- const context = {
254
- findings,
255
- manifest,
256
- knownComponents: new Set(Object.keys(manifest.registry.components)),
257
- projectComponents,
258
- stateFiles,
259
- roleStateSources,
260
- bindings,
440
+ const declaredStats = new Set(Object.keys(objectRecord(objectRecord(fixed.get('src/stats.json')).stats)));
441
+ const componentRegistry = {
442
+ ...manifest.registry.components,
261
443
  };
444
+ const componentMetadata = new Map(Object.entries(componentRegistry).map(([name, Class]) => [
445
+ name,
446
+ classMetadata(Class),
447
+ ]));
448
+ for (const [name, description] of Object.entries(loadedProjectComponents.components)) {
449
+ componentRegistry[name] = description.Class;
450
+ componentMetadata.set(name, {
451
+ Class: description.Class,
452
+ params: description.params,
453
+ defaults: description.defaults,
454
+ sourceFile: description.file,
455
+ });
456
+ projectComponents.add(name);
457
+ }
458
+ // Reconstruct the complete ref set before validating any prefab, so a
459
+ // lexically earlier file can refer to one discovered later in the tree.
262
460
  const prefabs = new Map();
461
+ const prefabFiles = [];
263
462
  for (const [directory, type] of [
264
463
  ['characters', 'character'],
265
464
  ['objects', 'object'],
@@ -274,12 +473,29 @@ export async function validateProject(projectPath) {
274
473
  const ref = `${directory}/${file.slice(0, -suffix.length)}`;
275
474
  const prefab = parsed;
276
475
  prefabs.set(ref, prefab);
277
- validatePrefab(prefab, relative, ref, context);
476
+ prefabFiles.push({ prefab, relative, ref });
278
477
  }
279
478
  }
479
+ const context = {
480
+ findings,
481
+ manifest,
482
+ knownComponents: new Set(Object.keys(manifest.registry.components)),
483
+ projectComponents,
484
+ componentMetadata,
485
+ componentRegistry,
486
+ reportedClassConstraints: new Set(),
487
+ prefabRefs: new Set(prefabs.keys()),
488
+ declaredStats,
489
+ stateFiles,
490
+ roleStateSources,
491
+ bindings,
492
+ };
493
+ validateComponentClassUpdateContracts(context);
494
+ for (const { prefab, relative, ref } of prefabFiles) {
495
+ validatePrefab(prefab, relative, ref, context);
496
+ }
280
497
  const uiFiles = await directFiles(path.join(projectPath, 'src/ui'), '.html');
281
498
  const uiNames = new Set(uiFiles.map((file) => file.slice(0, -'.html'.length)));
282
- const declaredStats = new Set(Object.keys(objectRecord(objectRecord(fixed.get('src/stats.json')).stats)));
283
499
  for (const file of uiFiles) {
284
500
  const relative = `src/ui/${file}`;
285
501
  const html = await readFile(path.join(projectPath, relative), 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/cli",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Waica in one command — `npx @waica/cli` opens the editor, `waica mcp` serves the MCP server",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "dist"
20
20
  ],
21
21
  "engines": {
22
- "node": ">=20.19"
22
+ "node": ">=22.18"
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsc -p tsconfig.build.json && node bundle-editor.mjs && node bundle-mcp.mjs",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "^1.30.0",
30
+ "playwright-core": "^1.62.1",
30
31
  "three": "^0.185.1"
31
32
  }
32
33
  }