@waica/cli 0.5.0 → 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 (58) hide show
  1. package/README.md +42 -37
  2. package/dist/editor/assets/{cssMode-2_8FrcPt.js → cssMode-DfLN2qUF.js} +1 -1
  3. package/dist/editor/assets/{freemarker2-BgSLfjNS.js → freemarker2-BM-wWcXR.js} +1 -1
  4. package/dist/editor/assets/{handlebars-CZIkUhwZ.js → handlebars-C68SV5M9.js} +1 -1
  5. package/dist/editor/assets/{html-B66ooLhD.js → html-dQgLbIki.js} +1 -1
  6. package/dist/editor/assets/{htmlMode-BiIT--Mv.js → htmlMode-Dx91fUdd.js} +1 -1
  7. package/dist/editor/assets/{index-Br-M9Tdo.js → index-592ABIaz.js} +2787 -406
  8. package/dist/editor/assets/{index-q7IkLe-P.css → index-Cy5pmBhN.css} +1 -1
  9. package/dist/editor/assets/{javascript-BZrbbzWQ.js → javascript-BmsSq5ka.js} +1 -1
  10. package/dist/editor/assets/{jsonMode-CABkbZgt.js → jsonMode-CWoPtX2M.js} +1 -1
  11. package/dist/editor/assets/{liquid-CT7pyNYe.js → liquid-DwnJlNJZ.js} +1 -1
  12. package/dist/editor/assets/{mdx-DW7MPqdm.js → mdx-DaLkvlR1.js} +1 -1
  13. package/dist/editor/assets/{python-CgBwEaNf.js → python-Cs5X5zFp.js} +1 -1
  14. package/dist/editor/assets/{razor-Dvggr_Q1.js → razor-PTwWoRk8.js} +1 -1
  15. package/dist/editor/assets/{tsMode-OfEpAJE7.js → tsMode-BqUQdgsR.js} +1 -1
  16. package/dist/editor/assets/{typescript-BQbhlbBK.js → typescript-BsJ7r2RJ.js} +1 -1
  17. package/dist/editor/assets/{xml-5qToUR1k.js → xml-DyyoPey4.js} +1 -1
  18. package/dist/editor/assets/{yaml-CFM1jPNu.js → yaml-Cjqtlp29.js} +1 -1
  19. package/dist/editor/index.html +2 -2
  20. package/dist/mcp/introspection.js +2 -0
  21. package/dist/mcp/node_modules/@waica/archetype-platformer/package.json +3 -3
  22. package/dist/mcp/node_modules/@waica/behaviors/dist/health.d.ts +4 -3
  23. package/dist/mcp/node_modules/@waica/behaviors/dist/health.js +12 -15
  24. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.d.ts +1 -0
  25. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.js +1 -0
  26. package/dist/mcp/node_modules/@waica/behaviors/package.json +2 -2
  27. package/dist/mcp/node_modules/@waica/engine/README.md +85 -0
  28. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.d.ts +39 -0
  29. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.js +157 -0
  30. package/dist/mcp/node_modules/@waica/engine/dist/component.d.ts +5 -0
  31. package/dist/mcp/node_modules/@waica/engine/dist/component.js +1 -0
  32. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.d.ts +1 -0
  33. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.js +1 -0
  34. package/dist/mcp/node_modules/@waica/engine/dist/game.d.ts +7 -0
  35. package/dist/mcp/node_modules/@waica/engine/dist/game.js +79 -1
  36. package/dist/mcp/node_modules/@waica/engine/dist/index.d.ts +7 -1
  37. package/dist/mcp/node_modules/@waica/engine/dist/index.js +3 -0
  38. package/dist/mcp/node_modules/@waica/engine/dist/input.d.ts +10 -0
  39. package/dist/mcp/node_modules/@waica/engine/dist/input.js +40 -2
  40. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.d.ts +69 -0
  41. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.js +105 -0
  42. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.d.ts +71 -0
  43. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.js +279 -0
  44. package/dist/mcp/node_modules/@waica/engine/package.json +3 -2
  45. package/dist/mcp/project-component-loader.js +506 -240
  46. package/dist/mcp/project-component-runner.js +237 -0
  47. package/dist/mcp/runtime-browser.js +336 -0
  48. package/dist/mcp/runtime-dev-server.js +273 -0
  49. package/dist/mcp/runtime-preflight.js +249 -0
  50. package/dist/mcp/runtime-service.js +8 -0
  51. package/dist/mcp/runtime-session-manager.js +265 -0
  52. package/dist/mcp/scaffolds.js +126 -6
  53. package/dist/mcp/server.js +330 -9
  54. package/dist/mcp/stdio.js +0 -25
  55. package/dist/mcp/template/src/main.ts +6 -1
  56. package/dist/mcp/validation.js +77 -6
  57. package/package.json +2 -1
  58. package/dist/mcp/native-import.cjs +0 -3
@@ -6,7 +6,10 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextpro
6
6
  import { createProject } from './create-project.js';
7
7
  import { describeArchetype, listComponents, projectSummary, } from './introspection.js';
8
8
  import { ABSOLUTE_PATH_MESSAGE, WaicaToolError, assertAbsoluteProjectPath, requireWaicaProject, } from './project-path.js';
9
- import { scaffoldComponent, scaffoldRole, scaffoldState, scaffoldUi, } from './scaffolds.js';
9
+ import { scaffoldComponent, scaffoldPrefab, scaffoldRole, scaffoldState, scaffoldUi, } from './scaffolds.js';
10
+ import { RuntimeToolError, } from './runtime-service.js';
11
+ import { createDefaultRuntimeSessionManager } from './runtime-session-manager.js';
12
+ import { ProjectComponentLoader } from './project-component-loader.js';
10
13
  import { validateProject } from './validation.js';
11
14
  const PROJECT_PATH = {
12
15
  type: 'string',
@@ -69,6 +72,29 @@ export const TOOLS = [
69
72
  inputSchema: schema({ name: { type: 'string', minLength: 1 } }, ['name']),
70
73
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
71
74
  },
75
+ {
76
+ name: 'scaffold_prefab',
77
+ description: 'Create the editor-compatible starter prefab for a character, object or tile.',
78
+ inputSchema: schema({
79
+ name: { type: 'string', minLength: 1, description: 'Prefab file name, without the type suffix.' },
80
+ type: {
81
+ type: 'string',
82
+ enum: ['character', 'object', 'tile'],
83
+ description: 'Prefab category; it decides the directory and the file suffix.',
84
+ },
85
+ role: {
86
+ type: 'string',
87
+ minLength: 1,
88
+ description: 'Character role from the active archetype (characters only); defaults to player.',
89
+ },
90
+ identity: {
91
+ type: 'string',
92
+ enum: ['player', 'enemy', 'npc', 'custom'],
93
+ description: 'What the character is to the game (characters only); adds its starter components.',
94
+ },
95
+ }, ['name', 'type']),
96
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
97
+ },
72
98
  {
73
99
  name: 'scaffold_role',
74
100
  description: 'Create the editor-compatible starter for a custom character role.',
@@ -95,7 +121,92 @@ export const TOOLS = [
95
121
  inputSchema: schema({ name: { type: 'string', minLength: 1 } }, ['name']),
96
122
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
97
123
  },
124
+ {
125
+ name: 'start_project',
126
+ description: 'Start or reuse a browser-backed Run Session for a trusted Waica Project.',
127
+ inputSchema: schema({
128
+ browser_executable_path: { type: 'string', minLength: 1 },
129
+ headless: { type: 'boolean', default: true },
130
+ viewport: {
131
+ type: 'object',
132
+ properties: {
133
+ width: { type: 'integer', minimum: 1 },
134
+ height: { type: 'integer', minimum: 1 },
135
+ },
136
+ required: ['width', 'height'],
137
+ additionalProperties: false,
138
+ },
139
+ timeout_ms: { type: 'integer', minimum: 1_000, maximum: 120_000, default: 30_000 },
140
+ }),
141
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
142
+ },
143
+ {
144
+ name: 'stop_project',
145
+ description: 'Stop a Project Run Session and all browser and process resources it owns.',
146
+ inputSchema: schema({}),
147
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
148
+ },
149
+ {
150
+ name: 'inspect_runtime',
151
+ description: 'Read a filtered Runtime Snapshot from a running Project.',
152
+ inputSchema: schema({
153
+ entity_ids: { type: 'array', items: { type: 'string' } },
154
+ entity_names: { type: 'array', items: { type: 'string' } },
155
+ component_types: { type: 'array', items: { type: 'string' } },
156
+ }),
157
+ annotations: { readOnlyHint: true, openWorldHint: false },
158
+ },
159
+ {
160
+ name: 'control_runtime',
161
+ description: 'Inject a semantic action or change deterministic frame control for a Run Session.',
162
+ inputSchema: {
163
+ type: 'object',
164
+ properties: {
165
+ project_path: PROJECT_PATH,
166
+ operation: {
167
+ type: 'string',
168
+ enum: ['press', 'hold', 'release', 'pause', 'resume', 'step'],
169
+ },
170
+ action: { type: 'string', minLength: 1 },
171
+ dt: { type: 'number', exclusiveMinimum: 0, maximum: 0.1 },
172
+ frames: { type: 'integer', minimum: 1, maximum: 600 },
173
+ },
174
+ required: ['project_path', 'operation'],
175
+ additionalProperties: false,
176
+ oneOf: [
177
+ {
178
+ properties: { operation: { enum: ['press', 'hold', 'release'] } },
179
+ required: ['action'],
180
+ not: { anyOf: [{ required: ['dt'] }, { required: ['frames'] }] },
181
+ },
182
+ {
183
+ properties: { operation: { enum: ['pause', 'resume'] } },
184
+ not: {
185
+ anyOf: [{ required: ['action'] }, { required: ['dt'] }, { required: ['frames'] }],
186
+ },
187
+ },
188
+ {
189
+ properties: { operation: { const: 'step' } },
190
+ not: { anyOf: [{ required: ['action'] }] },
191
+ },
192
+ ],
193
+ },
194
+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: false },
195
+ },
196
+ {
197
+ name: 'capture_screenshot',
198
+ description: 'Capture the composited Waica Game surface from a running Project.',
199
+ inputSchema: schema({}),
200
+ annotations: { readOnlyHint: true, openWorldHint: false },
201
+ },
98
202
  ];
203
+ const RUNTIME_TOOL_NAMES = new Set([
204
+ 'start_project',
205
+ 'stop_project',
206
+ 'inspect_runtime',
207
+ 'control_runtime',
208
+ 'capture_screenshot',
209
+ ]);
99
210
  function requiredString(args, name, projectPath = '') {
100
211
  const value = args[name];
101
212
  if (typeof value === 'string' && value.length > 0)
@@ -106,11 +217,131 @@ function requiredString(args, name, projectPath = '') {
106
217
  projectPath,
107
218
  });
108
219
  }
109
- async function execute(name, args) {
220
+ function invalidRuntimeInput(name, projectPath, message) {
221
+ throw new RuntimeToolError({
222
+ code: 'runtime-operation-failed',
223
+ stage: name === 'control_runtime' ? 'control' : 'project',
224
+ message,
225
+ projectPath,
226
+ });
227
+ }
228
+ function assertOnlyRuntimeFields(name, args, allowed, projectPath) {
229
+ const extras = Object.keys(args).filter((key) => !allowed.includes(key));
230
+ if (extras.length > 0) {
231
+ invalidRuntimeInput(name, projectPath, `Unexpected properties: ${extras.sort().join(', ')}.`);
232
+ }
233
+ }
234
+ function assertStringArray(name, args, field, projectPath) {
235
+ const value = args[field];
236
+ if (value !== undefined && (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))) {
237
+ invalidRuntimeInput(name, projectPath, `${field} must be an array of strings.`);
238
+ }
239
+ }
240
+ function validateRuntimeArguments(name, args, projectPath) {
241
+ switch (name) {
242
+ case 'start_project': {
243
+ assertOnlyRuntimeFields(name, args, ['project_path', 'browser_executable_path', 'headless', 'viewport', 'timeout_ms'], projectPath);
244
+ if (args.browser_executable_path !== undefined &&
245
+ (typeof args.browser_executable_path !== 'string' || args.browser_executable_path.length === 0)) {
246
+ invalidRuntimeInput(name, projectPath, 'browser_executable_path must be a nonempty string.');
247
+ }
248
+ if (args.headless !== undefined && typeof args.headless !== 'boolean') {
249
+ invalidRuntimeInput(name, projectPath, 'headless must be a boolean.');
250
+ }
251
+ if (args.timeout_ms !== undefined) {
252
+ const timeout = args.timeout_ms;
253
+ if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout < 1_000 || timeout > 120_000) {
254
+ invalidRuntimeInput(name, projectPath, 'timeout_ms must be an integer from 1,000 through 120,000.');
255
+ }
256
+ }
257
+ if (args.viewport !== undefined) {
258
+ if (!args.viewport || typeof args.viewport !== 'object' || Array.isArray(args.viewport)) {
259
+ invalidRuntimeInput(name, projectPath, 'viewport must contain width and height.');
260
+ }
261
+ const viewport = args.viewport;
262
+ const keys = Object.keys(viewport);
263
+ if (keys.some((key) => key !== 'width' && key !== 'height') ||
264
+ keys.length !== 2 ||
265
+ typeof viewport.width !== 'number' ||
266
+ typeof viewport.height !== 'number' ||
267
+ !Number.isInteger(viewport.width) ||
268
+ !Number.isInteger(viewport.height) ||
269
+ viewport.width <= 0 ||
270
+ viewport.height <= 0 ||
271
+ viewport.width * viewport.height > 1_000_000) {
272
+ invalidRuntimeInput(name, projectPath, 'viewport width and height must be positive integers totaling at most 1,000,000 pixels.');
273
+ }
274
+ }
275
+ return;
276
+ }
277
+ case 'stop_project':
278
+ case 'capture_screenshot':
279
+ assertOnlyRuntimeFields(name, args, ['project_path'], projectPath);
280
+ return;
281
+ case 'inspect_runtime':
282
+ assertOnlyRuntimeFields(name, args, ['project_path', 'entity_ids', 'entity_names', 'component_types'], projectPath);
283
+ assertStringArray(name, args, 'entity_ids', projectPath);
284
+ assertStringArray(name, args, 'entity_names', projectPath);
285
+ assertStringArray(name, args, 'component_types', projectPath);
286
+ return;
287
+ case 'control_runtime': {
288
+ assertOnlyRuntimeFields(name, args, ['project_path', 'operation', 'action', 'dt', 'frames'], projectPath);
289
+ const operation = args.operation;
290
+ if (!['press', 'hold', 'release', 'pause', 'resume', 'step'].includes(String(operation))) {
291
+ invalidRuntimeInput(name, projectPath, 'operation is not a supported runtime control operation.');
292
+ }
293
+ if (operation === 'press' || operation === 'hold' || operation === 'release') {
294
+ if (typeof args.action !== 'string' || args.action.length === 0) {
295
+ invalidRuntimeInput(name, projectPath, `${operation} requires a nonempty action.`);
296
+ }
297
+ if (args.dt !== undefined || args.frames !== undefined) {
298
+ invalidRuntimeInput(name, projectPath, `${operation} does not accept dt or frames.`);
299
+ }
300
+ }
301
+ else if (operation === 'pause' || operation === 'resume') {
302
+ if (args.action !== undefined || args.dt !== undefined || args.frames !== undefined) {
303
+ invalidRuntimeInput(name, projectPath, `${operation} accepts no additional fields.`);
304
+ }
305
+ }
306
+ else {
307
+ if (args.action !== undefined)
308
+ invalidRuntimeInput(name, projectPath, 'step does not accept action.');
309
+ if (args.dt !== undefined &&
310
+ (typeof args.dt !== 'number' || !Number.isFinite(args.dt) || args.dt <= 0 || args.dt > 0.1)) {
311
+ invalidRuntimeInput(name, projectPath, 'dt must be finite and greater than 0 and at most 0.1.');
312
+ }
313
+ if (args.frames !== undefined &&
314
+ (typeof args.frames !== 'number' || !Number.isInteger(args.frames) || args.frames < 1 || args.frames > 600)) {
315
+ invalidRuntimeInput(name, projectPath, 'frames must be an integer from 1 through 600.');
316
+ }
317
+ }
318
+ return;
319
+ }
320
+ }
321
+ }
322
+ async function execute(name, args, runtime, signal, componentLoader) {
323
+ const isRuntimeTool = RUNTIME_TOOL_NAMES.has(name);
324
+ const rawProjectPath = args.project_path;
325
+ if (isRuntimeTool && (typeof rawProjectPath !== 'string' || rawProjectPath.length === 0)) {
326
+ invalidRuntimeInput(name, '', 'project_path must be a nonempty absolute path.');
327
+ }
110
328
  const projectPath = requiredString(args, 'project_path');
111
329
  // Keep this at the dispatch boundary so every tool has byte-identical
112
330
  // stdio-cwd semantics, including create_project.
113
- assertAbsoluteProjectPath(projectPath);
331
+ if (isRuntimeTool) {
332
+ if (!path.isAbsolute(projectPath)) {
333
+ throw new RuntimeToolError({
334
+ code: 'runtime-prerequisite-missing',
335
+ stage: 'project',
336
+ message: ABSOLUTE_PATH_MESSAGE,
337
+ projectPath,
338
+ });
339
+ }
340
+ validateRuntimeArguments(name, args, projectPath);
341
+ }
342
+ else {
343
+ assertAbsoluteProjectPath(projectPath);
344
+ }
114
345
  switch (name) {
115
346
  case 'create_project': {
116
347
  const start = args.start === undefined ? 'demo' : requiredString(args, 'start', projectPath);
@@ -130,7 +361,7 @@ async function execute(name, args) {
130
361
  case 'project_summary':
131
362
  return projectSummary(projectPath);
132
363
  case 'validate_project':
133
- return validateProject(projectPath);
364
+ return validateProject(projectPath, { signal, componentLoader });
134
365
  case 'scaffold_component': {
135
366
  const check = await requireWaicaProject(projectPath);
136
367
  return {
@@ -140,6 +371,15 @@ async function execute(name, args) {
140
371
  warnings: [],
141
372
  };
142
373
  }
374
+ case 'scaffold_prefab': {
375
+ const check = await requireWaicaProject(projectPath);
376
+ return {
377
+ ...(await scaffoldPrefab(projectPath, requiredString(args, 'name', projectPath), requiredString(args, 'type', projectPath), args.role === undefined ? undefined : requiredString(args, 'role', projectPath), args.identity === undefined ? undefined : requiredString(args, 'identity', projectPath))),
378
+ notes: check.notes,
379
+ provenance: [],
380
+ warnings: [],
381
+ };
382
+ }
143
383
  case 'scaffold_role': {
144
384
  const check = await requireWaicaProject(projectPath);
145
385
  return {
@@ -167,6 +407,39 @@ async function execute(name, args) {
167
407
  warnings: [],
168
408
  };
169
409
  }
410
+ case 'start_project':
411
+ return runtime.start({
412
+ projectPath,
413
+ ...(typeof args.browser_executable_path === 'string'
414
+ ? { browserExecutablePath: args.browser_executable_path }
415
+ : {}),
416
+ ...(typeof args.headless === 'boolean' ? { headless: args.headless } : {}),
417
+ ...(args.viewport && typeof args.viewport === 'object'
418
+ ? { viewport: args.viewport }
419
+ : {}),
420
+ ...(typeof args.timeout_ms === 'number' ? { timeoutMs: args.timeout_ms } : {}),
421
+ });
422
+ case 'stop_project':
423
+ return runtime.stop(projectPath);
424
+ case 'inspect_runtime':
425
+ return runtime.inspect({
426
+ projectPath,
427
+ ...(Array.isArray(args.entity_ids) ? { entityIds: args.entity_ids } : {}),
428
+ ...(Array.isArray(args.entity_names) ? { entityNames: args.entity_names } : {}),
429
+ ...(Array.isArray(args.component_types)
430
+ ? { componentTypes: args.component_types }
431
+ : {}),
432
+ });
433
+ case 'control_runtime':
434
+ return runtime.control({
435
+ projectPath,
436
+ operation: requiredString(args, 'operation', projectPath),
437
+ ...(typeof args.action === 'string' ? { action: args.action } : {}),
438
+ ...(typeof args.dt === 'number' ? { dt: args.dt } : {}),
439
+ ...(typeof args.frames === 'number' ? { frames: args.frames } : {}),
440
+ });
441
+ case 'capture_screenshot':
442
+ return runtime.captureScreenshot(projectPath);
170
443
  default:
171
444
  throw new WaicaToolError({
172
445
  code: 'unknown-tool',
@@ -186,11 +459,39 @@ function result(payload, isError = false) {
186
459
  ...(isError ? { isError: true } : {}),
187
460
  };
188
461
  }
189
- function errorResult(error, args) {
462
+ function screenshotResult(screenshot) {
463
+ const metadata = jsonSafe(screenshot.metadata);
464
+ return {
465
+ content: [
466
+ { type: 'text', text: JSON.stringify(metadata, null, 2) },
467
+ { type: 'image', mimeType: 'image/png', data: screenshot.data },
468
+ ],
469
+ structuredContent: metadata,
470
+ };
471
+ }
472
+ function errorResult(error, args, toolName) {
190
473
  const projectPath = typeof args.project_path === 'string' ? args.project_path : '';
474
+ if (error instanceof RuntimeToolError) {
475
+ return result({ error: error.body }, true);
476
+ }
191
477
  if (error instanceof WaicaToolError) {
192
478
  return result({ error: error.body, provenance: [] }, true);
193
479
  }
480
+ if (RUNTIME_TOOL_NAMES.has(toolName)) {
481
+ const stage = toolName === 'control_runtime'
482
+ ? 'control'
483
+ : toolName === 'stop_project'
484
+ ? 'cleanup'
485
+ : 'game';
486
+ return result({
487
+ error: {
488
+ code: 'runtime-operation-failed',
489
+ stage,
490
+ message: error instanceof Error ? error.message : String(error),
491
+ projectPath,
492
+ },
493
+ }, true);
494
+ }
194
495
  return result({
195
496
  error: {
196
497
  code: 'tool-error',
@@ -224,7 +525,9 @@ function shippedVersion() {
224
525
  directory = parent;
225
526
  }
226
527
  }
227
- export function createWaicaMcpServer() {
528
+ export function createWaicaMcpServer(options = {}) {
529
+ const runtime = options.runtime ?? createDefaultRuntimeSessionManager();
530
+ const componentLoader = options.componentLoader ?? new ProjectComponentLoader();
228
531
  const server = new Server(
229
532
  // The name stays fixed: it identifies the server to the host, not the
230
533
  // package that happens to carry it.
@@ -233,15 +536,33 @@ export function createWaicaMcpServer() {
233
536
  instructions: 'Operate on user Waica projects through absolute project_path values. Keep editing JSON and TypeScript with the host file tools.',
234
537
  });
235
538
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
236
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
539
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
237
540
  const args = (request.params.arguments ?? {});
238
541
  try {
239
- return result(await execute(request.params.name, args));
542
+ const executed = await execute(request.params.name, args, runtime, extra.signal, componentLoader);
543
+ return request.params.name === 'capture_screenshot'
544
+ ? screenshotResult(executed)
545
+ : result(executed);
240
546
  }
241
547
  catch (error) {
242
- return errorResult(error, args);
548
+ if (extra.signal.aborted)
549
+ throw (extra.signal.reason ?? error);
550
+ return errorResult(error, args, request.params.name);
243
551
  }
244
552
  });
553
+ let cleanup;
554
+ const cleanupResources = () => {
555
+ cleanup ??= Promise.all([runtime.close(), componentLoader.close()]).then(() => undefined);
556
+ return cleanup;
557
+ };
558
+ server.onclose = () => {
559
+ void cleanupResources();
560
+ };
561
+ const closeProtocol = server.close.bind(server);
562
+ server.close = async () => {
563
+ await cleanupResources();
564
+ await closeProtocol();
565
+ };
245
566
  return server;
246
567
  }
247
568
  export { ABSOLUTE_PATH_MESSAGE };
package/dist/mcp/stdio.js CHANGED
@@ -1,36 +1,11 @@
1
1
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
2
  import { prepareWorkspaceRuntime } from './workspace-runtime.js';
3
- /**
4
- * Logs to stderr (never stdout — that's the JSON-RPC transport) instead of
5
- * letting the failure reach Node's default handling. Exported so the logging
6
- * itself is unit-testable without touching the global process listeners.
7
- */
8
- export function reportProjectCodeFailure(kind, detail) {
9
- console.error(`waica-mcp: ${kind} — likely async work scheduled by a project module executed during ` +
10
- 'validate_project (e.g. a floating fetch() or a throwing setTimeout callback). ' +
11
- 'The server keeps running:', detail);
12
- }
13
- /**
14
- * validate_project runs project-owned components/roles/states in-process
15
- * (project-component-loader.ts). That code can schedule async work that
16
- * rejects or throws *after* module evaluation already returned — Node's
17
- * default handling for that is to crash the whole process, which would take
18
- * down this long-lived stdio server and every other in-flight tool call for
19
- * one module's sloppy code. A subprocess sandbox for project code is a
20
- * larger, separately tracked follow-up; this keeps the blast radius to a
21
- * logged line for now.
22
- */
23
- function installProjectCodeGuards() {
24
- process.on('unhandledRejection', (reason) => reportProjectCodeFailure('unhandledRejection', reason));
25
- process.on('uncaughtException', (error) => reportProjectCodeFailure('uncaughtException', error));
26
- }
27
3
  /**
28
4
  * Connects the MCP server to stdio. Kept separate from the bin wrapper so the
29
5
  * waica CLI can start the same server in-process from its bundled copy without
30
6
  * importing a module that runs on load.
31
7
  */
32
8
  export async function startStdioServer() {
33
- installProjectCodeGuards();
34
9
  await prepareWorkspaceRuntime();
35
10
  const { createWaicaMcpServer } = await import('./server.js');
36
11
  const server = createWaicaMcpServer();
@@ -14,7 +14,12 @@ import settings from './game.json'
14
14
 
15
15
  // Your components, roles and state code extend the installed archetype
16
16
  // baseline when main() imports them.
17
- const projectCode = import.meta.glob(['./components/*.ts', './roles/*.ts', './states/*.ts'])
17
+ const projectCode = import.meta.glob([
18
+ './components/*.ts',
19
+ './roles/*.ts',
20
+ './states/*.ts',
21
+ '!**/*.test.ts',
22
+ ])
18
23
 
19
24
  // Your prefabs ARE these files (saved by the editor, or hand-edited — they're
20
25
  // just JSON). Nothing else: delete one and the game stops knowing about it.
@@ -1,9 +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';
4
5
  import { classDefaults, objectRecord } from './component-metadata.js';
5
6
  import { PackageResolver, mixedSourceWarnings, provenanceRows, } from './package-resolver.js';
6
- import { loadProjectComponents } from './project-component-loader.js';
7
+ import { loadProjectComponents, } from './project-component-loader.js';
7
8
  import { directFiles, requireWaicaProject } from './project-path.js';
8
9
  function add(context, severity, code, message, file, ref) {
9
10
  context.findings.push({ severity, code, message, file, ...(ref ? { ref } : {}) });
@@ -49,7 +50,7 @@ function componentList(value) {
49
50
  }
50
51
  function classMetadata(Class) {
51
52
  // A throwing constructor has no observable defaults, matching list_components.
52
- return { params: Class.params ?? {}, defaults: classDefaults(Class) };
53
+ return { Class, params: Class.params ?? {}, defaults: classDefaults(Class) };
53
54
  }
54
55
  /** Clip names declared by the sibling AnimatedSprite, or undefined when there is none (no animation contract to check). */
55
56
  function siblingClips(siblings) {
@@ -171,7 +172,7 @@ function validateStateMachines(components, file, ref, context) {
171
172
  }
172
173
  if (typeof on === 'string' && on.startsWith('input:')) {
173
174
  const action = on.slice('input:'.length);
174
- if (!context.bindings[action]?.length) {
175
+ if (!isBoundAction(context.bindings, action)) {
175
176
  add(context, 'warning', 'input-action-unbound', `Input action "${action}" has no bindings.`, file, action);
176
177
  }
177
178
  }
@@ -199,12 +200,63 @@ function resolvedEntityComponents(entity, prefab) {
199
200
  }));
200
201
  return [...inherited, ...componentList(entity.components)];
201
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
+ }
202
253
  function validatePrefab(prefab, file, ref, context) {
203
254
  const components = componentList(prefab.components);
204
255
  for (const component of components)
205
256
  checkComponent(component, file, ref, context);
206
257
  validateParamReferences(components.map((component) => ({ component })), components, file, context);
207
258
  validateStateMachines(components, file, ref, context);
259
+ validateComponentUpdateSchedule(components, file, ref, context);
208
260
  }
209
261
  function validateScene(scene, file, prefabs, uiNames, context) {
210
262
  const rawEntities = Array.isArray(scene.entities) ? scene.entities : [];
@@ -305,6 +357,13 @@ function validateScene(scene, file, prefabs, uiNames, context) {
305
357
  if (changesStateBehavior) {
306
358
  validateStateMachines(effectiveComponents(), file, entityRef, context);
307
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);
366
+ }
308
367
  }
309
368
  }
310
369
  function statBindings(html) {
@@ -321,7 +380,8 @@ function statBindings(html) {
321
380
  }
322
381
  return names;
323
382
  }
324
- export async function validateProject(projectPath) {
383
+ export async function validateProject(projectPath, options = {}) {
384
+ options.signal?.throwIfAborted();
325
385
  const check = await requireWaicaProject(projectPath);
326
386
  const findings = [];
327
387
  const fixedPaths = [
@@ -350,7 +410,9 @@ export async function validateProject(projectPath) {
350
410
  discoverArchetypes(projectPath, resolver, discoveryWarnings, activeId ? [activeId] : []),
351
411
  projectComponentCandidates(projectPath),
352
412
  projectRoleStateSources(projectPath),
353
- loadProjectComponents(projectPath, resolver),
413
+ options.componentLoader
414
+ ? options.componentLoader.load(projectPath, resolver, { signal: options.signal })
415
+ : loadProjectComponents(projectPath, resolver, { signal: options.signal }),
354
416
  ]);
355
417
  for (const failure of loadedProjectComponents.failures) {
356
418
  add({ findings },
@@ -376,14 +438,20 @@ export async function validateProject(projectPath) {
376
438
  }
377
439
  const stateFiles = new Set((await directFiles(path.join(projectPath, 'src/states'), '.ts')).map((file) => file.slice(0, -'.ts'.length)));
378
440
  const declaredStats = new Set(Object.keys(objectRecord(objectRecord(fixed.get('src/stats.json')).stats)));
379
- const componentMetadata = new Map(Object.entries(manifest.registry.components).map(([name, Class]) => [
441
+ const componentRegistry = {
442
+ ...manifest.registry.components,
443
+ };
444
+ const componentMetadata = new Map(Object.entries(componentRegistry).map(([name, Class]) => [
380
445
  name,
381
446
  classMetadata(Class),
382
447
  ]));
383
448
  for (const [name, description] of Object.entries(loadedProjectComponents.components)) {
449
+ componentRegistry[name] = description.Class;
384
450
  componentMetadata.set(name, {
451
+ Class: description.Class,
385
452
  params: description.params,
386
453
  defaults: description.defaults,
454
+ sourceFile: description.file,
387
455
  });
388
456
  projectComponents.add(name);
389
457
  }
@@ -414,12 +482,15 @@ export async function validateProject(projectPath) {
414
482
  knownComponents: new Set(Object.keys(manifest.registry.components)),
415
483
  projectComponents,
416
484
  componentMetadata,
485
+ componentRegistry,
486
+ reportedClassConstraints: new Set(),
417
487
  prefabRefs: new Set(prefabs.keys()),
418
488
  declaredStats,
419
489
  stateFiles,
420
490
  roleStateSources,
421
491
  bindings,
422
492
  };
493
+ validateComponentClassUpdateContracts(context);
423
494
  for (const { prefab, relative, ref } of prefabFiles) {
424
495
  validatePrefab(prefab, relative, ref, context);
425
496
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waica/cli",
3
- "version": "0.5.0",
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",
@@ -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
  }
@@ -1,3 +0,0 @@
1
- 'use strict'
2
-
3
- module.exports = (specifier) => import(specifier)