@waica/cli 0.4.1 → 0.5.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.
- package/dist/editor/assets/{cssMode-CWVlK407.js → cssMode-2_8FrcPt.js} +1 -1
- package/dist/editor/assets/{freemarker2-Bfs9nzND.js → freemarker2-BgSLfjNS.js} +1 -1
- package/dist/editor/assets/{handlebars-DTne-Xwn.js → handlebars-CZIkUhwZ.js} +1 -1
- package/dist/editor/assets/{html-DYqHipn0.js → html-B66ooLhD.js} +1 -1
- package/dist/editor/assets/{htmlMode-BQfZPL-P.js → htmlMode-BiIT--Mv.js} +1 -1
- package/dist/editor/assets/{index-4_cmje9l.js → index-Br-M9Tdo.js} +1846 -259
- package/dist/editor/assets/{index-Btzc6zxr.css → index-q7IkLe-P.css} +1 -1
- package/dist/editor/assets/{javascript-78Jd7IgG.js → javascript-BZrbbzWQ.js} +1 -1
- package/dist/editor/assets/{jsonMode--FXx7ZJu.js → jsonMode-CABkbZgt.js} +1 -1
- package/dist/editor/assets/{liquid-C5EdDSAU.js → liquid-CT7pyNYe.js} +1 -1
- package/dist/editor/assets/{mdx-BkiXoGn5.js → mdx-DW7MPqdm.js} +1 -1
- package/dist/editor/assets/{python-hLSY5QsA.js → python-CgBwEaNf.js} +1 -1
- package/dist/editor/assets/{razor-DAyuskAk.js → razor-Dvggr_Q1.js} +1 -1
- package/dist/editor/assets/{tsMode-BwUo2LKL.js → tsMode-OfEpAJE7.js} +1 -1
- package/dist/editor/assets/{typescript-C-0C7ZgR.js → typescript-BQbhlbBK.js} +1 -1
- package/dist/editor/assets/{xml-DkkM6Mc1.js → xml-5qToUR1k.js} +1 -1
- package/dist/editor/assets/{yaml-xD6ctkfV.js → yaml-CFM1jPNu.js} +1 -1
- package/dist/editor/index.html +2 -2
- package/dist/mcp/component-metadata.js +23 -0
- package/dist/mcp/introspection.js +2 -14
- package/dist/mcp/native-import.cjs +3 -0
- package/dist/mcp/node_modules/@waica/archetype-platformer/dist/prefabs.js +7 -1
- package/dist/mcp/node_modules/@waica/archetype-platformer/dist/registry-data.js +3 -1
- package/dist/mcp/node_modules/@waica/archetype-platformer/package.json +3 -3
- package/dist/mcp/node_modules/@waica/behaviors/dist/chaser.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/chaser.js +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/collectible.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/collectible.js +1 -1
- package/dist/mcp/node_modules/@waica/behaviors/dist/hazard.d.ts +42 -1
- package/dist/mcp/node_modules/@waica/behaviors/dist/hazard.js +49 -3
- package/dist/mcp/node_modules/@waica/behaviors/dist/health.d.ts +81 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/health.js +150 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/index.d.ts +2 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/index.js +2 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/lifetime.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/lifetime.js +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.d.ts +29 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.js +37 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/patrol.js +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/platformer-motor.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/platformer-motor.js +10 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/player-states.js +26 -0
- package/dist/mcp/node_modules/@waica/behaviors/dist/respawnable.d.ts +5 -13
- package/dist/mcp/node_modules/@waica/behaviors/dist/respawnable.js +5 -11
- package/dist/mcp/node_modules/@waica/behaviors/package.json +2 -2
- package/dist/mcp/node_modules/@waica/engine/dist/authoring-defaults.d.ts +11 -0
- package/dist/mcp/node_modules/@waica/engine/dist/authoring-defaults.js +51 -0
- package/dist/mcp/node_modules/@waica/engine/dist/component.d.ts +10 -1
- package/dist/mcp/node_modules/@waica/engine/dist/component.js +1 -0
- package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.js +10 -0
- package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/engine/dist/components/sprite.js +1 -0
- package/dist/mcp/node_modules/@waica/engine/dist/index.d.ts +1 -0
- package/dist/mcp/node_modules/@waica/engine/dist/index.js +1 -0
- package/dist/mcp/node_modules/@waica/engine/dist/state/state-machine.d.ts +2 -1
- package/dist/mcp/node_modules/@waica/engine/dist/state/state-machine.js +7 -1
- package/dist/mcp/node_modules/@waica/engine/package.json +1 -1
- package/dist/mcp/package-resolver.js +10 -2
- package/dist/mcp/project-component-loader.js +287 -0
- package/dist/mcp/server.js +31 -1
- package/dist/mcp/stdio.js +25 -0
- package/dist/mcp/template/src/characters/player.character.json +29 -2
- package/dist/mcp/template/src/characters/slime.character.json +6 -0
- package/dist/mcp/validation.js +168 -23
- package/package.json +2 -2
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const EXCLUDED_KEYS = new Set(['entity', 'game']);
|
|
2
|
+
function isSerializable(value) {
|
|
3
|
+
if (value === null)
|
|
4
|
+
return true;
|
|
5
|
+
const kind = typeof value;
|
|
6
|
+
if (kind === 'string' || kind === 'number' || kind === 'boolean')
|
|
7
|
+
return true;
|
|
8
|
+
if (kind !== 'object')
|
|
9
|
+
return false;
|
|
10
|
+
if (Array.isArray(value))
|
|
11
|
+
return true;
|
|
12
|
+
const proto = Object.getPrototypeOf(value);
|
|
13
|
+
return proto === Object.prototype || proto === null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The public authoring surface of a component class: own enumerable fields
|
|
17
|
+
* plus setter-backed accessors (walking the whole prototype chain, so a
|
|
18
|
+
* getter/setter pair inherited from a base class counts), read through the
|
|
19
|
+
* getter so the value is the public one. Excludes `_`-prefixed keys, the
|
|
20
|
+
* base Component wiring (`entity`, `game`), anything the class (or an
|
|
21
|
+
* ancestor) declares `transient`, and values that are `undefined` or not
|
|
22
|
+
* JSON-serializable (`Map`, `Set`, functions, class instances).
|
|
23
|
+
*/
|
|
24
|
+
export function authoringDefaults(Class, onError) {
|
|
25
|
+
let instance;
|
|
26
|
+
try {
|
|
27
|
+
instance = new Class();
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
onError?.(error);
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
const transient = new Set(Class.transient ?? []);
|
|
34
|
+
const keys = new Set(Object.keys(instance));
|
|
35
|
+
for (let proto = Object.getPrototypeOf(instance); proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
|
|
36
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(proto))) {
|
|
37
|
+
if (descriptor.set)
|
|
38
|
+
keys.add(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const result = {};
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
if (EXCLUDED_KEYS.has(key) || key.startsWith('_') || transient.has(key))
|
|
44
|
+
continue;
|
|
45
|
+
const value = instance[key];
|
|
46
|
+
if (value === undefined || !isSerializable(value))
|
|
47
|
+
continue;
|
|
48
|
+
result[key] = value;
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
@@ -7,8 +7,10 @@ export interface ParamSpec {
|
|
|
7
7
|
min?: number;
|
|
8
8
|
max?: number;
|
|
9
9
|
step?: number;
|
|
10
|
-
/** Allowed values for a string param; rendered as a dropdown. */
|
|
10
|
+
/** Allowed values for a string param; rendered as a dropdown. Takes precedence over ref. */
|
|
11
11
|
options?: string[];
|
|
12
|
+
/** Project value this string param names; rendered and validated as a typed reference. */
|
|
13
|
+
ref?: 'prefab' | 'stat' | 'action' | 'clip';
|
|
12
14
|
}
|
|
13
15
|
export interface ComponentClass<T extends Component = Component> {
|
|
14
16
|
new (): T;
|
|
@@ -21,6 +23,12 @@ export interface ComponentClass<T extends Component = Component> {
|
|
|
21
23
|
displayName?: string;
|
|
22
24
|
/** Which properties the inspector exposes, with their ranges. */
|
|
23
25
|
params?: Record<string, ParamSpec>;
|
|
26
|
+
/**
|
|
27
|
+
* Instance fields holding runtime state rather than authorable defaults.
|
|
28
|
+
* Excluded from authoringDefaults(); a subclass that does not redeclare
|
|
29
|
+
* this inherits its base's list.
|
|
30
|
+
*/
|
|
31
|
+
transient?: readonly string[];
|
|
24
32
|
}
|
|
25
33
|
/** Cardinal unit normal pointing away from the contacted Solid surface. */
|
|
26
34
|
export interface ContactNormal {
|
|
@@ -43,6 +51,7 @@ export declare abstract class Component {
|
|
|
43
51
|
static componentName: string;
|
|
44
52
|
static displayName?: string;
|
|
45
53
|
static params?: Record<string, ParamSpec>;
|
|
54
|
+
static transient?: readonly string[];
|
|
46
55
|
entity: Entity;
|
|
47
56
|
game: Game;
|
|
48
57
|
/** Runs once the component is mounted on its entity. */
|
|
@@ -19,6 +19,16 @@ export class AnimatedSprite extends Component {
|
|
|
19
19
|
offsetY: { label: 'y offset' },
|
|
20
20
|
layer: { label: 'layer', min: -5, max: 5, step: 1 },
|
|
21
21
|
};
|
|
22
|
+
static transient = [
|
|
23
|
+
'current',
|
|
24
|
+
'player',
|
|
25
|
+
'sheets',
|
|
26
|
+
'texs',
|
|
27
|
+
'mesh',
|
|
28
|
+
'frame',
|
|
29
|
+
'frameScaleX',
|
|
30
|
+
'frameScaleY',
|
|
31
|
+
];
|
|
22
32
|
/** Spritesheet URL. */
|
|
23
33
|
texture = '';
|
|
24
34
|
/** Sheet grid dimensions. */
|
|
@@ -12,6 +12,7 @@ export class Sprite extends Component {
|
|
|
12
12
|
offsetY: { label: 'y offset' },
|
|
13
13
|
layer: { label: 'layer', min: -5, max: 5, step: 1 },
|
|
14
14
|
};
|
|
15
|
+
static transient = ['mesh'];
|
|
15
16
|
// Size, color and offset are reactive so inspector edits update the live quad.
|
|
16
17
|
// Texture still needs a rebuild. TODO(H1): fully reactive props.
|
|
17
18
|
_width = 1;
|
|
@@ -4,6 +4,7 @@ export { CAMERA_DEFAULTS, resolveSceneCamera, stepSceneCamera } from './camera.j
|
|
|
4
4
|
export type { SceneCameraJson, CameraLimitsJson, ResolvedSceneCamera } from './camera.js';
|
|
5
5
|
export { Entity } from './entity.js';
|
|
6
6
|
export { Component } from './component.js';
|
|
7
|
+
export { authoringDefaults } from './authoring-defaults.js';
|
|
7
8
|
export type { ComponentClass, ContactNormal, ParamSpec, SolidContact, } from './component.js';
|
|
8
9
|
export { collectModuleComponents, mergeRegistryComponents } from './component-registry.js';
|
|
9
10
|
export type { ComponentModule } from './component-registry.js';
|
|
@@ -2,6 +2,7 @@ export { Game } from './game.js';
|
|
|
2
2
|
export { CAMERA_DEFAULTS, resolveSceneCamera, stepSceneCamera } from './camera.js';
|
|
3
3
|
export { Entity } from './entity.js';
|
|
4
4
|
export { Component } from './component.js';
|
|
5
|
+
export { authoringDefaults } from './authoring-defaults.js';
|
|
5
6
|
export { collectModuleComponents, mergeRegistryComponents } from './component-registry.js';
|
|
6
7
|
export { Input, DEFAULT_BINDINGS } from './input.js';
|
|
7
8
|
export { Stats } from './stats.js';
|
|
@@ -13,7 +13,7 @@ export interface StateTransitionJson {
|
|
|
13
13
|
* transitions instead of a real state.
|
|
14
14
|
*/
|
|
15
15
|
export interface StateJson {
|
|
16
|
-
/** Clip
|
|
16
|
+
/** Clip reference resolved against the sibling AnimatedSprite; defaults to the state's name. */
|
|
17
17
|
clip?: string;
|
|
18
18
|
transitions?: StateTransitionJson[];
|
|
19
19
|
}
|
|
@@ -53,6 +53,7 @@ export declare class StateMachine extends Component {
|
|
|
53
53
|
label: string;
|
|
54
54
|
};
|
|
55
55
|
};
|
|
56
|
+
static transient: string[];
|
|
56
57
|
/** The character's role — names the logic set providing its state code. */
|
|
57
58
|
role: string;
|
|
58
59
|
/** Starting state; defaults to the first declared state. */
|
|
@@ -47,6 +47,7 @@ export class StateMachine extends Component {
|
|
|
47
47
|
static params = {
|
|
48
48
|
role: { label: 'Role' },
|
|
49
49
|
};
|
|
50
|
+
static transient = ['current', 'elapsed', 'instanceHooks', 'signals', 'warnedClips'];
|
|
50
51
|
/** The character's role — names the logic set providing its state code. */
|
|
51
52
|
role = '';
|
|
52
53
|
/** Starting state; defaults to the first declared state. */
|
|
@@ -96,7 +97,12 @@ export class StateMachine extends Component {
|
|
|
96
97
|
// capped so a degenerate cyclic graph can't hang the loop.
|
|
97
98
|
for (let hops = 0; hops < 8; hops++) {
|
|
98
99
|
const edge = nextTransition(this.states, this.current, this.env());
|
|
99
|
-
|
|
100
|
+
// A '*' edge is re-merged against whatever state the loop just
|
|
101
|
+
// entered, so a still-queued signal (signals.clear() only runs after
|
|
102
|
+
// this loop) keeps firing every remaining hop. Without this guard
|
|
103
|
+
// that replays onExit/onEnter on the state hops already settled into
|
|
104
|
+
// — goto() has the same guard for the same reason.
|
|
105
|
+
if (!edge || edge.to === this.current)
|
|
100
106
|
break;
|
|
101
107
|
// A transition fired by a key press spends it: one press, one
|
|
102
108
|
// transition — it can't fire again from the state just entered.
|
|
@@ -15,12 +15,18 @@ export class PackageUnavailableError extends Error {
|
|
|
15
15
|
const BUNDLED_SPECIFIERS = new Set([
|
|
16
16
|
'@waica/engine',
|
|
17
17
|
'@waica/behaviors',
|
|
18
|
+
'@waica/archetype-platformer',
|
|
18
19
|
'@waica/archetype-platformer/manifest',
|
|
19
20
|
]);
|
|
20
21
|
const requireBundled = createRequire(import.meta.url);
|
|
22
|
+
/** Node resolution anchored at a game project rather than the MCP host process. */
|
|
23
|
+
export function projectAnchoredRequire(projectPath) {
|
|
24
|
+
return createRequire(path.join(projectPath, 'package.json'));
|
|
25
|
+
}
|
|
21
26
|
const BUILT_ENTRY_BY_SPECIFIER = {
|
|
22
27
|
'@waica/engine': 'dist/index.js',
|
|
23
28
|
'@waica/behaviors': 'dist/index.js',
|
|
29
|
+
'@waica/archetype-platformer': 'dist/index.js',
|
|
24
30
|
'@waica/archetype-platformer/manifest': 'dist/manifest.js',
|
|
25
31
|
};
|
|
26
32
|
function specifierFor(packageName, subpath) {
|
|
@@ -117,13 +123,15 @@ async function loadBundledModule(specifier, packageRoot) {
|
|
|
117
123
|
return import('@waica/engine');
|
|
118
124
|
case '@waica/behaviors':
|
|
119
125
|
return import('@waica/behaviors');
|
|
126
|
+
case '@waica/archetype-platformer':
|
|
127
|
+
return import('@waica/archetype-platformer');
|
|
120
128
|
case '@waica/archetype-platformer/manifest':
|
|
121
129
|
return import('@waica/archetype-platformer/manifest');
|
|
122
130
|
default:
|
|
123
131
|
throw new PackageUnavailableError(specifier);
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
|
-
function causeText(error) {
|
|
134
|
+
export function causeText(error) {
|
|
127
135
|
if (!(error instanceof Error))
|
|
128
136
|
return String(error);
|
|
129
137
|
const code = error.code;
|
|
@@ -150,7 +158,7 @@ export class PackageResolver {
|
|
|
150
158
|
constructor(projectPath) {
|
|
151
159
|
this.projectPath = projectPath;
|
|
152
160
|
if (projectPath) {
|
|
153
|
-
this.projectRequire =
|
|
161
|
+
this.projectRequire = projectAnchoredRequire(projectPath);
|
|
154
162
|
this.projectManifestUsable = projectPackageJsonUsable(projectPath);
|
|
155
163
|
}
|
|
156
164
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import * as nodeModule from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { classDefaults, objectRecord } from './component-metadata.js';
|
|
7
|
+
import { causeText, PackageResolver, projectAnchoredRequire } from './package-resolver.js';
|
|
8
|
+
import { directFiles } from './project-path.js';
|
|
9
|
+
const { createRequire } = nodeModule;
|
|
10
|
+
// Two independent identifiers travel on every module URL this loader hands
|
|
11
|
+
// out: PROJECT_PARAM routes a resolve-hook call back to the validation run
|
|
12
|
+
// that started it (an ephemeral, in-memory lookup, cleaned up per run), and
|
|
13
|
+
// LOAD_PARAM is a content hash that only changes when the file's own bytes
|
|
14
|
+
// change. Splitting them is what lets re-validating unchanged files reuse
|
|
15
|
+
// Node's module cache instead of pinning a fresh entry on every run (see
|
|
16
|
+
// loadProjectComponents below).
|
|
17
|
+
const PROJECT_PARAM = 'waica-component-project';
|
|
18
|
+
const LOAD_PARAM = 'waica-component-load';
|
|
19
|
+
const hookContexts = new Map();
|
|
20
|
+
// Loading this tiny CommonJS trampoline through Node keeps Vitest from
|
|
21
|
+
// rewriting fixture imports. The build copies the same file byte-for-byte.
|
|
22
|
+
const nativeImport = createRequire(import.meta.url)('./native-import.cjs');
|
|
23
|
+
const MODULE_HOOKS_MIN_NODE = '22.15';
|
|
24
|
+
/** Pure so it can be exercised directly, independent of the running Node. */
|
|
25
|
+
export function nodeSupportsModuleHooks(moduleApi = nodeModule) {
|
|
26
|
+
return typeof moduleApi.registerHooks === 'function';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* `node:module`'s `registerHooks` landed in Node 22.15; this MCP server is
|
|
30
|
+
* still expected to start (and serve every other tool) on the older Node
|
|
31
|
+
* hosts an agent may be running. A static named import of `registerHooks`
|
|
32
|
+
* would fail ESM linking on those hosts before the transport ever connects
|
|
33
|
+
* (see workspace-runtime.ts for the same guard on the same API), so this is
|
|
34
|
+
* a namespace import feature-checked at runtime instead.
|
|
35
|
+
*/
|
|
36
|
+
const hooksSupported = nodeSupportsModuleHooks();
|
|
37
|
+
/** The single finding emitted in place of real component loading on a host too old for module hooks. */
|
|
38
|
+
export function unsupportedNodeFailure(nodeVersion = process.version) {
|
|
39
|
+
return {
|
|
40
|
+
code: 'component-load-unsupported',
|
|
41
|
+
file: 'src',
|
|
42
|
+
message: `Deep component validation requires Node >= ${MODULE_HOOKS_MIN_NODE} (node:module registerHooks) ` +
|
|
43
|
+
`to run project components, roles and states in-process; this host runs Node ${nodeVersion}. ` +
|
|
44
|
+
'Skipping in-process module loading: component params and instance defaults from project code ' +
|
|
45
|
+
'will not appear in validate_project or list_components until the host upgrades.',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function runFromParent(parentURL) {
|
|
49
|
+
if (!parentURL)
|
|
50
|
+
return undefined;
|
|
51
|
+
try {
|
|
52
|
+
const token = new URL(parentURL).searchParams.get(PROJECT_PARAM);
|
|
53
|
+
const context = token ? hookContexts.get(token) : undefined;
|
|
54
|
+
return token && context ? { token, context } : undefined;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function versioned(url, token, content) {
|
|
61
|
+
url.searchParams.set(PROJECT_PARAM, token);
|
|
62
|
+
url.searchParams.set(LOAD_PARAM, content);
|
|
63
|
+
return url.href;
|
|
64
|
+
}
|
|
65
|
+
function projectToken(projectPath) {
|
|
66
|
+
return createHash('sha1').update(path.resolve(projectPath)).digest('hex').slice(0, 16);
|
|
67
|
+
}
|
|
68
|
+
/** Content identity for cache-busting: stable across runs, changes only when the file's bytes do. */
|
|
69
|
+
function contentToken(filePath) {
|
|
70
|
+
try {
|
|
71
|
+
return createHash('sha1').update(readFileSync(filePath)).digest('hex').slice(0, 16);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// The subsequent import attempt reports the real read/import error; this
|
|
75
|
+
// token only needs to exist so versioned() has something to stamp.
|
|
76
|
+
return 'unreadable';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const RELATIVE_EXTENSIONS = ['.ts', '.tsx', '.js'];
|
|
80
|
+
/** Mirrors the editor's resolveProjectImport (play-code.ts): direct, direct+ext, direct/index+ext. */
|
|
81
|
+
function relativeCandidates(unresolvedPath) {
|
|
82
|
+
const candidates = [
|
|
83
|
+
...RELATIVE_EXTENSIONS.map((extension) => `${unresolvedPath}${extension}`),
|
|
84
|
+
...RELATIVE_EXTENSIONS.map((extension) => path.join(unresolvedPath, `index${extension}`)),
|
|
85
|
+
];
|
|
86
|
+
if (unresolvedPath.endsWith('.js')) {
|
|
87
|
+
const withoutJs = unresolvedPath.slice(0, -'.js'.length);
|
|
88
|
+
candidates.unshift(`${withoutJs}.ts`, `${withoutJs}.tsx`);
|
|
89
|
+
}
|
|
90
|
+
return candidates;
|
|
91
|
+
}
|
|
92
|
+
function resolveRelativeCandidate(unresolvedPath) {
|
|
93
|
+
return relativeCandidates(unresolvedPath).find((candidate) => existsSync(candidate));
|
|
94
|
+
}
|
|
95
|
+
// The synchronous hook is process-wide, but it is inert for imports that were
|
|
96
|
+
// not initiated by this loader. The PROJECT_PARAM token selects the project
|
|
97
|
+
// resolver for a given run; nextResolve is always tried first so real Node
|
|
98
|
+
// resolution (package "exports" maps, symlinks, literal existing paths) stays
|
|
99
|
+
// authoritative, and our TypeScript-project conventions are only a fallback.
|
|
100
|
+
if (hooksSupported) {
|
|
101
|
+
nodeModule.registerHooks({
|
|
102
|
+
resolve(specifier, context, nextResolve) {
|
|
103
|
+
const run = runFromParent(context.parentURL);
|
|
104
|
+
if (!run)
|
|
105
|
+
return nextResolve(specifier, context);
|
|
106
|
+
if (specifier.startsWith('@waica/')) {
|
|
107
|
+
const bridge = run.context.packageBridges.get(specifier);
|
|
108
|
+
if (bridge)
|
|
109
|
+
return { url: bridge, shortCircuit: true };
|
|
110
|
+
const resolved = run.context.projectRequire.resolve(specifier);
|
|
111
|
+
const url = pathToFileURL(resolved);
|
|
112
|
+
return {
|
|
113
|
+
url: /\.tsx?$/.test(resolved)
|
|
114
|
+
? versioned(url, run.token, contentToken(resolved))
|
|
115
|
+
: url.href,
|
|
116
|
+
shortCircuit: true,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (specifier.startsWith('.')) {
|
|
120
|
+
const unresolved = new URL(specifier, context.parentURL);
|
|
121
|
+
let resolvedURL;
|
|
122
|
+
try {
|
|
123
|
+
resolvedURL = new URL(nextResolve(specifier, context).url);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// Directory imports (./lib -> ./lib/index.ts) and multi-dot
|
|
127
|
+
// specifiers (./foo.helper -> ./foo.helper.ts) are valid under the
|
|
128
|
+
// project's moduleResolution "bundler" and run fine in Vite, but
|
|
129
|
+
// Node's default resolver rejects both — fall back to the same
|
|
130
|
+
// candidates the editor tries before giving up.
|
|
131
|
+
const fallback = resolveRelativeCandidate(fileURLToPath(unresolved));
|
|
132
|
+
if (!fallback)
|
|
133
|
+
throw error;
|
|
134
|
+
resolvedURL = pathToFileURL(fallback);
|
|
135
|
+
}
|
|
136
|
+
const resolvedPath = fileURLToPath(resolvedURL);
|
|
137
|
+
return {
|
|
138
|
+
url: versioned(resolvedURL, run.token, contentToken(resolvedPath)),
|
|
139
|
+
shortCircuit: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return nextResolve(specifier, context);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function componentDescriptions(module, file) {
|
|
147
|
+
const descriptions = {};
|
|
148
|
+
for (const value of Object.values(module)) {
|
|
149
|
+
if (typeof value !== 'function' || !Object.hasOwn(value, 'componentName'))
|
|
150
|
+
continue;
|
|
151
|
+
const Class = value;
|
|
152
|
+
if (typeof Class.componentName !== 'string' || !Class.componentName)
|
|
153
|
+
continue;
|
|
154
|
+
descriptions[Class.componentName] = {
|
|
155
|
+
file,
|
|
156
|
+
params: objectRecord(Class.params),
|
|
157
|
+
defaults: classDefaults(Class),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return descriptions;
|
|
161
|
+
}
|
|
162
|
+
function errorChain(error) {
|
|
163
|
+
const errors = [];
|
|
164
|
+
let current = error;
|
|
165
|
+
const seen = new Set();
|
|
166
|
+
while (current instanceof Error && !seen.has(current)) {
|
|
167
|
+
errors.push(current);
|
|
168
|
+
seen.add(current);
|
|
169
|
+
current = current.cause;
|
|
170
|
+
}
|
|
171
|
+
return errors;
|
|
172
|
+
}
|
|
173
|
+
function unsupportedByNode(error) {
|
|
174
|
+
for (const candidate of errorChain(error)) {
|
|
175
|
+
const code = candidate.code;
|
|
176
|
+
if (code === 'ERR_UNKNOWN_FILE_EXTENSION' ||
|
|
177
|
+
code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
if (/not supported in strip-only mode|unsupported TypeScript syntax|unknown file extension/i.test(candidate.message)) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
function bridgeModule(token, specifier, module) {
|
|
187
|
+
const key = `waica.project-component.${process.pid}.${token}.${specifier}`;
|
|
188
|
+
Reflect.set(process, Symbol.for(key), module);
|
|
189
|
+
const exports = Object.keys(module).filter((name) => /^[$A-Z_a-z][$\w]*$/.test(name));
|
|
190
|
+
const lines = [
|
|
191
|
+
`const value = process[Symbol.for(${JSON.stringify(key)})]`,
|
|
192
|
+
`if (!value) throw new Error(${JSON.stringify(`Missing package bridge for ${specifier}`)})`,
|
|
193
|
+
];
|
|
194
|
+
let index = 0;
|
|
195
|
+
for (const name of exports) {
|
|
196
|
+
if (name === 'default') {
|
|
197
|
+
lines.push('export default value.default');
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const binding = `binding${index++}`;
|
|
201
|
+
lines.push(`const ${binding} = value[${JSON.stringify(name)}]`, `export { ${binding} as ${name} }`);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
key,
|
|
205
|
+
url: `data:text/javascript;charset=utf-8,${encodeURIComponent(lines.join('\n'))}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const BRIDGED_SPECIFIERS = ['@waica/engine', '@waica/behaviors', '@waica/archetype-platformer'];
|
|
209
|
+
async function packageBridges(projectPath, token,
|
|
210
|
+
// Defaults to a fresh resolver for standalone callers (e.g. tests); the
|
|
211
|
+
// real loadProjectComponents call site reuses validateProject's resolver
|
|
212
|
+
// so these packages are not re-resolved from scratch a second time.
|
|
213
|
+
resolver = new PackageResolver(projectPath)) {
|
|
214
|
+
// @waica/archetype-platformer joins engine/behaviors here (not just the
|
|
215
|
+
// separate projectRequire.resolve path below) so a freshly created,
|
|
216
|
+
// not-yet-installed project still gets the MCP-bundled copy the same way
|
|
217
|
+
// it already does for engine/behaviors, instead of a bare MODULE_NOT_FOUND.
|
|
218
|
+
// The three packages are independent, so they load concurrently.
|
|
219
|
+
const bridges = await Promise.all(BRIDGED_SPECIFIERS.map(async (specifier) => {
|
|
220
|
+
try {
|
|
221
|
+
const loaded = await resolver.load(specifier);
|
|
222
|
+
// Keyed by the resolved package's own version rather than a per-run
|
|
223
|
+
// counter, so re-validating without a dependency bump reuses the exact
|
|
224
|
+
// same data: URL — and therefore Node's already-cached module for it —
|
|
225
|
+
// instead of pinning a fresh, functionally identical one every run.
|
|
226
|
+
const bridge = bridgeModule(`${token}:${loaded.provenance.version}`, specifier, loaded.module);
|
|
227
|
+
return { specifier, bridge };
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// The importing project file receives the anchored resolver's real error.
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}));
|
|
234
|
+
const urls = new Map();
|
|
235
|
+
const keys = [];
|
|
236
|
+
for (const entry of bridges) {
|
|
237
|
+
if (!entry)
|
|
238
|
+
continue;
|
|
239
|
+
urls.set(entry.specifier, entry.bridge.url);
|
|
240
|
+
keys.push(entry.bridge.key);
|
|
241
|
+
}
|
|
242
|
+
return { urls, keys };
|
|
243
|
+
}
|
|
244
|
+
async function projectModuleFiles(projectPath) {
|
|
245
|
+
const groups = await Promise.all(['components', 'roles', 'states'].map(async (directory) => (await directFiles(path.join(projectPath, 'src', directory), '.ts')).map((file) => `src/${directory}/${file}`)));
|
|
246
|
+
return groups.flat();
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Executes every project-owned component/role/state module independently.
|
|
250
|
+
* Failures are returned as data so one bad file cannot abort validation.
|
|
251
|
+
*/
|
|
252
|
+
export async function loadProjectComponents(projectPath, resolver) {
|
|
253
|
+
if (!hooksSupported) {
|
|
254
|
+
return { components: {}, failures: [unsupportedNodeFailure()] };
|
|
255
|
+
}
|
|
256
|
+
const token = projectToken(projectPath);
|
|
257
|
+
const bridges = await packageBridges(projectPath, token, resolver);
|
|
258
|
+
hookContexts.set(token, {
|
|
259
|
+
projectRequire: projectAnchoredRequire(projectPath),
|
|
260
|
+
packageBridges: bridges.urls,
|
|
261
|
+
});
|
|
262
|
+
const components = {};
|
|
263
|
+
const failures = [];
|
|
264
|
+
try {
|
|
265
|
+
for (const relative of await projectModuleFiles(projectPath)) {
|
|
266
|
+
const file = path.join(projectPath, relative);
|
|
267
|
+
try {
|
|
268
|
+
const entryHref = versioned(pathToFileURL(file), token, contentToken(file));
|
|
269
|
+
const module = await nativeImport(entryHref);
|
|
270
|
+
Object.assign(components, componentDescriptions(module, relative));
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
failures.push({
|
|
274
|
+
code: unsupportedByNode(error) ? 'component-load-unsupported' : 'component-load-failed',
|
|
275
|
+
file: relative,
|
|
276
|
+
message: causeText(error),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
hookContexts.delete(token);
|
|
283
|
+
for (const key of bridges.keys)
|
|
284
|
+
Reflect.deleteProperty(process, Symbol.for(key));
|
|
285
|
+
}
|
|
286
|
+
return { components, failures };
|
|
287
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
1
4
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
5
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
3
6
|
import { createProject } from './create-project.js';
|
|
@@ -197,8 +200,35 @@ function errorResult(error, args) {
|
|
|
197
200
|
provenance: [],
|
|
198
201
|
}, true);
|
|
199
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* The version reported over MCP: whatever artifact is shipping this server.
|
|
205
|
+
* In a checkout that is packages/mcp; once the CLI vendors the build into
|
|
206
|
+
* dist/mcp it is the CLI itself. Walking up to the nearest package.json finds
|
|
207
|
+
* the right one in both layouts, and every package in this repo moves on one
|
|
208
|
+
* version, so the host is told the release it is actually running instead of
|
|
209
|
+
* a number frozen in this file — which is how it reported 0.1.0 through the
|
|
210
|
+
* 0.4.x releases.
|
|
211
|
+
*/
|
|
212
|
+
function shippedVersion() {
|
|
213
|
+
let directory = path.dirname(fileURLToPath(import.meta.url));
|
|
214
|
+
for (;;) {
|
|
215
|
+
const manifest = path.join(directory, 'package.json');
|
|
216
|
+
if (existsSync(manifest)) {
|
|
217
|
+
const { version } = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
218
|
+
if (version)
|
|
219
|
+
return version;
|
|
220
|
+
}
|
|
221
|
+
const parent = path.dirname(directory);
|
|
222
|
+
if (parent === directory)
|
|
223
|
+
return '0.0.0';
|
|
224
|
+
directory = parent;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
200
227
|
export function createWaicaMcpServer() {
|
|
201
|
-
const server = new Server(
|
|
228
|
+
const server = new Server(
|
|
229
|
+
// The name stays fixed: it identifies the server to the host, not the
|
|
230
|
+
// package that happens to carry it.
|
|
231
|
+
{ name: '@waica/mcp', version: shippedVersion() }, {
|
|
202
232
|
capabilities: { tools: {} },
|
|
203
233
|
instructions: 'Operate on user Waica projects through absolute project_path values. Keep editing JSON and TypeScript with the host file tools.',
|
|
204
234
|
});
|
package/dist/mcp/stdio.js
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
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
|
+
}
|
|
3
27
|
/**
|
|
4
28
|
* Connects the MCP server to stdio. Kept separate from the bin wrapper so the
|
|
5
29
|
* waica CLI can start the same server in-process from its bundled copy without
|
|
6
30
|
* importing a module that runs on load.
|
|
7
31
|
*/
|
|
8
32
|
export async function startStdioServer() {
|
|
33
|
+
installProjectCodeGuards();
|
|
9
34
|
await prepareWorkspaceRuntime();
|
|
10
35
|
const { createWaicaMcpServer } = await import('./server.js');
|
|
11
36
|
const server = createWaicaMcpServer();
|
|
@@ -112,6 +112,23 @@
|
|
|
112
112
|
"to": "idle"
|
|
113
113
|
}
|
|
114
114
|
]
|
|
115
|
+
},
|
|
116
|
+
"dead": {
|
|
117
|
+
"clip": "idle",
|
|
118
|
+
"transitions": [
|
|
119
|
+
{
|
|
120
|
+
"on": "timer:0.8",
|
|
121
|
+
"to": "idle"
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
"*": {
|
|
126
|
+
"transitions": [
|
|
127
|
+
{
|
|
128
|
+
"on": "signal:death",
|
|
129
|
+
"to": "dead"
|
|
130
|
+
}
|
|
131
|
+
]
|
|
115
132
|
}
|
|
116
133
|
}
|
|
117
134
|
}
|
|
@@ -124,9 +141,19 @@
|
|
|
124
141
|
}
|
|
125
142
|
},
|
|
126
143
|
{
|
|
127
|
-
"type": "Respawnable"
|
|
144
|
+
"type": "Respawnable"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"type": "Health",
|
|
148
|
+
"props": {
|
|
149
|
+
"max": 3,
|
|
150
|
+
"invulnerability": 1
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"type": "OutOfBounds",
|
|
128
155
|
"props": {
|
|
129
|
-
"
|
|
156
|
+
"minY": -12
|
|
130
157
|
}
|
|
131
158
|
}
|
|
132
159
|
]
|